Skip to content

fix(gateway-credentials): the owner checks, writes and removes the bridge declaration; sparrowd reads it through the owner - #5815

Merged
sonichi merged 21 commits into
mainfrom
fix/token-env-name-guard
Oct 24, 2026
Merged

sonichi merged 21 commits into
mainfrom
fix/token-env-name-guard

Conversation

@sonichi

@sonichi sonichi commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #5698, closing the guard gap reported in #5698 (comment), with every review round on this PR folded in.

Thanks to the reviewers whose findings shaped each round: the reviews on this PR (most recently 5620661505 and 5620696233 at 2c06c88, and 5621130345 at a5288fc), and an independent agent review that found the folded-literal gap fixed here.

Design: remove the data flow, don't analyse it

A static guard can't resolve every alias. Each time the AST check was tightened, review found another way through. So the record, and the file that holds it, no longer reach any module outside the owner.

  • Owner (src/gateway_credential_sources.py, vendored byte-identical by tools/sync_from_src.py):
    • write_bridge_declaration(path, *, written_by, ...) checks any file already at path. A directory, a dangling symlink, an unreadable file, or a file written_by didn't write each leave the file alone and return the same message the adapter printed before. Otherwise it writes the declaration (tmp + os.replace) and returns ''.
    • remove_bridge_declaration(path, *, written_by) returns (removed, why).
    • The text builder and the record's key are private. The owner reads no credential; the declaration is the one file it touches.
  • Adapter. wire_bridge and unwire_bridge in adapters/claude_code/cli.py pass a path and their own values and print what they printed before. The adapter never opens the declaration or holds its bytes.
  • Reader. src/sparrow_agent_bridge.py (sparrowd) takes the fixed field from bridge_declaration_fields(). It hands the bridge its token through bridge_token_environ(token), which bridge_environ merges whole.

What the guard proves

The guard proves delegation: the adapter and the declaration's reader route through the owner, and they don't name or open what only the owner should. It can't prove that a module has no arbitrary new code reading environment variables; that boundary belongs to review. Concretely, it checks five things:

  1. No owner-private names. No package module outside the two owner modules (matched by exact path), and not src/sparrow_agent_bridge.py, names an owner key tuple, an owner-private name, or the record's "token_env" key. That covers identifiers, imports, attributes and strings. Every string the guard detects is folded by the one helper, _constant, so concatenations, f-strings of constants, % and .format count. Call-shape checks still require real literals.
  2. Owner entry points only through the owner's import. write_bridge_declaration, remove_bridge_declaration, bridge_declaration_fields and bridge_token_environ are reachable only as <alias>.<name>(...). <alias> must be the name bound by the module's single top-level import of the owner module, bound once and never rebound. A same-named local function or method, an aliased reference, a getattr, a second import under that name, or a parameter shadowing it is flagged. The reader's two accessors are the reader's alone.
  3. The declaration path is opaque outside the owner. A path counts as the declaration if it is built from DECLARATION, from a string folding to worker.json, from a name or module global bound from one, or from a function or method that returns one. Reachability follows the path everywhere, including inside f-strings; only what an owner call returns is opaque. A message about the path is exempt only where it is the argument itself: an f-string, a name bound from one, a call to a function returning only those, or a not, comparison or and/or/if over them. Used any other way (a receiver, a nested call, a read), it is the path, so open(f"{path}").read() is flagged and print(f"declared: {path}") is not.
  4. The adapter's only owner calls are those two, with no ** spread.
  5. Closed world for the reader. bridge_environ must be exactly the pinned body (docstring aside), merging the owner's mapping whole. The module's mapping writes must be exactly four pinned calls, with __ior__/__or__ counted as writes. It may contain no in-place assignment and no | merge. Every reach of a process environment must be one of three pinned uses, so an alias, from os import environ or getattr(os, "environ") is flagged. Any ** merge must be of the owner's fields, main's environment must be bridge_environ(decl, token) alone, and no string may be one of the owner's token variable names. Any edit to those lines is an edit to this test, which review sees.

The four points

  1. Within this record, the owner is the only production code that names token_env_name, TOKEN_KEYS or the token_env field. That covers the package and the record's one reader. skills/schedule-crons/ensure-cron-room.py and skills/ag2space-voice/navigate.ts name the same gateway-token variables, but for their own lookup of the host's gateway credentials. They are not different secrets, but neither reads nor writes the bridge declaration. That separate, older duplication is left alone here.
  2. The adapter receives only an opaque status ('', a reason, or (removed, reason)). It never holds the record or the file's bytes.
  3. The scan has positive controls and narrow exclusions. Every new rule has an allowed control beside its negative forms (the table below). The only exclusions are the two owner modules, by exact path.
  4. The byte-identical behaviour tests catch rebuilds and mutations that change the bytes. install produces the same declaration and printed line as ddb0c8a, and the owner's write, check and remove are run against both owner copies. A rebuild with identical bytes can't be seen in the bytes; the guard catches it instead.

Before/after: this round's findings, a5288fc's guard vs this head's

Declaration rows run declaration_reads on the snippet. Reader rows run the reader checks on the real src/sparrow_agent_bridge.py with one edit applied.

[positive control] open(DECLARATION).read()                a5288fcb: flagged  new: flagged
fh = open(f"{DECLARATION}"); fh.read()                     a5288fcb: missed   new: flagged
open(f"{DECLARATION}").read()                              a5288fcb: missed   new: flagged
Kewei: text = open(f"{path}").read()                       a5288fcb: missed   new: flagged
[control] print(f"declared: {path}")                       a5288fcb: clean    new: clean
[control] function returning an f-string message, printed  a5288fcb: clean    new: clean
[control] the shipped adapter                              a5288fcb: clean    new: clean
[control] the shipped reader                               a5288fcb: clean    new: clean
Kewei: os.environ |= {next(iter(environ)): token}          a5288fcb: missed   new: flagged
e = os.environ; e |= {...}                                 a5288fcb: missed   new: flagged
os.environ.__ior__({...})                                  a5288fcb: missed   new: flagged
del os.environ[...]                                        a5288fcb: flagged  new: flagged
os.environ.update(environ | {...})                         a5288fcb: flagged  new: flagged

The last two reader rows were already caught at a5288fc (a direct item delete, and an update outside the pinned calls); they stay as regression pins.

The previous round's table (2c06c88 vs a5288fc) still holds at this head; the earlier-round kill controls below rerun it.

Runtime of the guard suite

Required CI timed out on tests/gateway-credential-sources.test.py (over 240s under instrumentation) at a5288fc. The profile showed the cost was rescanning: six tests each ran credential_access over every package module, and the systemd rule reparsed the package eleven times. Each pure scan is now memoized per (source, module), the per-module def listing is cached, and the taint loops stop at their fixpoint. The timeout is unchanged. Wall time locally, both under coverage run:

== gateway-credential-sources.test.py at a5288fcb, under coverage run (load 3.87)
Ran 142 tests in 32.986s
OK
real 33.18
== gateway-credential-sources.test.py at this head, under coverage run (load 4.28)
Ran 143 tests in 7.623s
OK
real 7.79

The heaviest remaining frames under cProfile at this head (the first full-package scan, done once):

       28    0.000    0.000   14.360    0.513 case.py:634(run)
       28    0.001    0.000   14.359    0.513 case.py:614(_callTestMethod)
2929/1953    0.017    0.000   12.987    0.007 gateway-credential-sources.test.py:1375(<lambda>)
 1588/639    0.049    0.000   12.971    0.020 gateway-credential-sources.test.py:1374(<lambda>)
      488    0.296    0.001    9.416    0.019 gateway-credential-sources.test.py:1692(credential_access)
        6    0.002    0.000    8.882    1.480 gateway-credential-sources.test.py:1753(package_findings)

Kill controls

This round and the last: each mechanism turned off in this head's guard (then restored; sha256 matched)

== KA owner-call exemption by method spelling again (2c06c889)
  FAIL: test_the_owner_entry_points_are_reached_only_on_the_owner_import
  AssertionError: [] is not true : from ... import gateway_credential_sources as _sources
  Ran 28 tests in 4.215s
  FAILED (failures=1)
== KF a5288fcb's marked(): the walk stops at every f-string
  FAIL: test_a_declaration_path_in_an_f_string_is_still_the_path_once_read
  AssertionError: [] is not true : def f():
  Ran 28 tests in 4.013s
  FAILED (failures=1)
== KB no producers: a path returned by a helper is not followed
  FAIL: test_a_declaration_path_from_a_helper_is_the_declaration
  AssertionError: [] is not true : def decl_path(root):
  FAIL: test_a_declaration_path_in_an_f_string_is_still_the_path_once_read
  AssertionError: [] is not true : def where(root):
  Ran 28 tests in 3.882s
  FAILED (failures=2)
== KC the reader check at 2c06c889 (no closed world)
  FAIL: test_the_reader_writes_no_environment_entry_itself
  AssertionError: [] is not true :     env.update(token_env)
  Ran 28 tests in 10.060s
  FAILED (failures=1)
== KE a5288fcb's reader rule: no in-place/| merge or environ-use pin
  FAIL: test_the_reader_writes_no_environment_entry_itself
  AssertionError: [] is not true :     os.environ.update(environ)
  Ran 28 tests in 4.149s
  FAILED (failures=1)
== K1b the declaration test reads raw literals again (Mars's finding)
  FAIL: test_a_declaration_name_built_from_constants_is_the_declaration
  AssertionError: [] is not true : def f(base):
  FAIL: test_a_declaration_path_from_a_helper_is_the_declaration
  AssertionError: [] is not true : HOME_DECL = Path('~') / ('work' + 'er.json')
  Ran 28 tests in 4.147s
  FAILED (failures=2)
restored: sha256 identical to before the controls
== K1c the credential-path test reads raw literals again
  FAIL: test_the_detector_catches_every_bypass
  AssertionError: [] is not true : def f():
  Ran 28 tests in 4.223s
  FAILED (failures=1)
== K1d the systemd detection reads raw literals again
  FAIL: test_the_detector_catches_every_bypass
  AssertionError: [] is not true : def f():
  Ran 28 tests in 3.895s
  FAILED (failures=1)
restored: sha256 identical to before the controls

Earlier rounds, rerun at this head

Guard is tests/gateway-credential-sources.test.py; ConnectHostSupervised is packages/ag2-sparrow/tests/test_claude_code_adapter.py.

== K1 review mutant (a): the adapter pre-reads the declaration, parses it, picks the *_env field, getenv
  [Guard]
  FAIL: test_no_other_package_module_touches_a_credential_source
  FAIL: test_the_adapter_hands_the_owner_a_path_and_holds_no_record
  FAIL: test_the_cli_names_neither_the_accessor_nor_the_record_key
  Ran 28 tests in 4.080s
  FAILED (failures=3)
  [ConnectHostSupervised]
  Ran 5 tests in 1.922s
  OK
== K2 the same read laundered through a local helper that takes the path
  [Guard]
  FAIL: test_no_other_package_module_touches_a_credential_source
  FAIL: test_the_adapter_hands_the_owner_a_path_and_holds_no_record
  FAIL: test_the_cli_names_neither_the_accessor_nor_the_record_key
  Ran 28 tests in 4.331s
  FAILED (failures=3)
  [ConnectHostSupervised]
  Ran 5 tests in 1.774s
  OK
== K3 review mutant (b): the reader copies the token selection itself
  [Guard]
  FAIL: test_the_declarations_reader_names_only_the_owners_accessors
  FAIL: test_the_reader_writes_no_environment_entry_itself
  Ran 28 tests in 4.336s
  FAILED (failures=2)
  [sparrowd-agent-bridges.test.py]
  Ran 12 tests in 0.040s
  OK
== K4 the reader stores the token under a folded name after the owner's merge
  [Guard]
  FAIL: test_the_declarations_reader_names_only_the_owners_accessors
  FAIL: test_the_reader_writes_no_environment_entry_itself
  Ran 28 tests in 4.391s
  FAILED (failures=2)
  [sparrowd-agent-bridges.test.py]
  Ran 12 tests in 0.025s
  OK
== K5 the adapter at 7c714539 (owned_declaration pre-reads the file)
  [Guard]
  FAIL: test_no_other_package_module_touches_a_credential_source
  FAIL: test_the_adapter_hands_the_owner_a_path_and_holds_no_record
  FAIL: test_the_cli_names_neither_the_accessor_nor_the_record_key
  Ran 28 tests in 4.286s
  FAILED (failures=3)
  [ConnectHostSupervised]
  Ran 5 tests in 1.700s
  OK
== K6 the owner's record changed ("schema": 1 -> 2 in src, synced to the package)
  [Guard]
  FAIL: test_the_owner_writes_the_record_install_wrote_before [package]
  FAIL: test_the_owner_writes_the_record_install_wrote_before [src]
  Ran 28 tests in 3.930s
  FAILED (failures=2)
  [ConnectHostSupervised]
  FAIL: test_the_declaration_is_the_record_install_wrote_before_the_owner_built_it
  Ran 5 tests in 1.741s
  FAILED (failures=1)
all files restored: sha256 identical to before the controls

Coverage

coverage 7.16 was run locally over the credential-sources and both sparrowd suites, and the changed lines were counted against origin/main:

src/gateway_credential_sources.py: 56/56 changed executable lines run; missed []
src/sparrow_agent_bridge.py: 6/6 changed executable lines run; missed []

Suites at this head

Only tests/gateway-credential-sources.test.py changed since a5288fc. With env -i HOME=$(mktemp -d) PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin nice -n 19 python3 <file>, started at a 1-minute load under 4:

load at start: { 3.29 4.18 4.54 }
== tests/gateway-credential-sources.test.py
Ran 143 tests in 7.774s
OK
== tests/hermetic-env-sentinels.test.py
Ran 20 tests in 172.986s
OK
== tests/sparrowd-agent-bridges.test.py
Ran 12 tests in 0.036s
OK
== packages/ag2-sparrow/tools/sync_from_src.py --check
in sync with src/ ✓

The adapter (239/239), sparrowd rescan (32/32), local bridge (19/19) and wrapper (PASS) suites, and the byte-identical declaration check, last ran at a5288fc. Their code is unchanged since. Changed-line coverage of src/ is unchanged too (owner 56/56, sparrowd 6/6).
git diff --check is clean. scripts/review-checks.sh --diff over the PR diff returns PARTIAL: the hardcoded-path and root-artifact checks are clean, but prose-cap was skipped because the script was not run from inside the checkout. A manual scan of the added lines finds no comment run longer than 2 lines.

2026-10-23: every host token name is stripped, not only the first

An independent review at da4e1ae (also recorded as the comment on #5820) found that bridge_token_environ returns only TOKEN_KEYS[0], so bridge_environ stripped REMOTE_TASK_TOKEN alone: a host holding AG2_REMOTE_TOKEN, or REMOTE_MEDIA_HS_TOKEN (MEDIA_TOKEN_KEY), passed each to the agent bridge, where discover() reads either. Main had the same gap; this PR owns the credential name set, so it is fixed here, in the owner.

  • Owner. bridge_strip_keys() in src/gateway_credential_sources.py returns TOKEN_KEYS + (MEDIA_TOKEN_KEY,): REMOTE_TASK_TOKEN, AG2_REMOTE_TOKEN, REMOTE_MEDIA_HS_TOKEN. Vendored byte-identical (sync_from_src.py --check in sync).
  • Reader. bridge_environ filters sparrowd's environment on HOST_ONLY and that set, then merges bridge_token_environ(token) whole as before. No adapter changed.
  • Guard. bridge_strip_keys joins READER_ONLY (named only by the reader, only as a call on the owner's import); the pinned bridge_environ body is the new one; three reader mutants that narrow the set again (strip = token_env, HOST_ONLY + tuple(token_env), bridge_strip_keys()[:1]) are flagged; the owner behaviour test asserts the tuple, that it equals set(TOKEN_KEYS) | {MEDIA_TOKEN_KEY}, and that it covers the name the agent's token goes under.
  • Per-key tests (tests/sparrowd-agent-bridges.test.py, class Wrapper, synthetic values): bridge.main runs the bridge with a host value under all three names; one test per key asserts the host's REMOTE_TASK_TOKEN is replaced by the agent's, and AG2_REMOTE_TOKEN and REMOTE_MEDIA_HS_TOKEN are absent from the environment the bridge module runs under (key and value). A fourth drives bridge_environ directly over bridge_strip_keys() so a name added to the owner's set later is covered without editing the test.

The AG2_REMOTE_TOKEN item listed in the comment on #5820 is fixed here; the issue's two guard-completeness gaps (shutil.copyfile, aliased environ.update) remain its own.

Before/after, the new sparrowd tests with src/ (owner, reader and the vendored owner) checked out at da4e1ae and then at this head, env -i … nice -n 19 python3 <file>:

== BEFORE: src (owner, reader, vendored owner) at da4e1ae7, the new tests; load 5.70
ERROR: test_every_name_the_owner_strips_is_stripped_before_the_agents_token_is_set
  AttributeError: module 'ag2_sparrow.gateway_credential_sources' has no attribute 'bridge_strip_keys'
FAIL: test_the_hosts_AG2_REMOTE_TOKEN_never_reaches_the_bridge
  AssertionError: 'AG2_REMOTE_TOKEN' unexpectedly found in {... 'AG2_REMOTE_TOKEN': 'host-alias-token', ...}
FAIL: test_the_hosts_REMOTE_MEDIA_HS_TOKEN_never_reaches_the_bridge
  AssertionError: 'REMOTE_MEDIA_HS_TOKEN' unexpectedly found in {... 'REMOTE_MEDIA_HS_TOKEN': 'host-media-token', ...}
(test_the_hosts_REMOTE_TASK_TOKEN_is_replaced_by_the_agents passes there: that one name was already stripped)
Ran 16 tests in 0.068s
FAILED (failures=2, errors=1)
== AFTER tests/sparrowd-agent-bridges.test.py at 7529fd40; load 5.70
Ran 16 tests in 0.048s
OK
== AFTER tests/gateway-credential-sources.test.py at 7529fd40
Ran 143 tests in 9.338s
OK
== packages/ag2-sparrow/tools/sync_from_src.py --check
in sync with src/ ✓

The branch was rebased onto main (7940f25) in the same push; the rebase applied clean.

Same day, second finding from the same reviewer (7529fd4): the set stopped at TOKEN_KEYS + the media token, so a host's GATEWAY_TOKEN or RELAY_TOKEN still passed through. The bridge reads neither, but room-ops and bee-actions rank both above REMOTE_TASK_TOKEN, so a child of the bridge (the REMOTE_TASK_ON_TASK wake, say) would act as the host. bridge_strip_keys() is now the union of TOKEN_KEYS, the contract's TOKEN_ALIAS_PRECEDENCE (imported from gateway_credentials, the owner's existing contract import; not copied) and MEDIA_TOKEN_KEY, each once:
REMOTE_TASK_TOKEN, AG2_REMOTE_TOKEN, GATEWAY_TOKEN, RELAY_TOKEN, REMOTE_MEDIA_HS_TOKEN.
The guard asserts that tuple and its equality with the three sources; the sparrowd suite gains a per-key test for GATEWAY_TOKEN and RELAY_TOKEN (synthetic host values); the table test over bridge_strip_keys() is kept. Before/after, with the owner (src and vendored) at 7529fd4 and then at this head:

== BEFORE: owner (src + vendored) at 7529fd40, the new tests; load 5.80
FAIL: test_the_hosts_GATEWAY_TOKEN_never_reaches_the_bridge
  AssertionError: 'GATEWAY_TOKEN' unexpectedly found in {...}
FAIL: test_the_hosts_RELAY_TOKEN_never_reaches_the_bridge
  AssertionError: 'RELAY_TOKEN' unexpectedly found in {...}
Ran 18 tests in 0.045s
FAILED (failures=2)
== AFTER tests/sparrowd-agent-bridges.test.py at 36beb75a; load 5.85
Ran 18 tests in 0.032s
OK
== AFTER tests/gateway-credential-sources.test.py at 36beb75a
Ran 143 tests in 8.764s
OK
== packages/ag2-sparrow/tools/sync_from_src.py --check
in sync with src/ ✓

Same day, third finding (36beb75, P1, traced through the launchd job): sparrowd's wrapper left the host's CLAUDE_CONFIG_DIR in the bridge child (it must: channels/<channel>/access.json is resolved from it), and at import the child's _channel_env_candidates() opened $CLAUDE_CONFIG_DIR/channels/ag2space/.env, so discover() registered the host's file token in _SECRETS before precedence chose the agent's. The config fallback had the same path: _config_from_channel_env("REMOTE_PROACTIVE_TRUST_OK") opens that same file, so "the child never opens channels/.env" and "the child keeps the file fallback" could not both hold.

Design: discovery is gated in the owner, and the one host setting the child took from the file is resolved by the parent and handed in as a value.

  • Owner. bridge_token_environ(token) now also sets a mark, INJECTED_KEY (GATEWAY_CREDENTIALS_INJECTED=1); credentials_injected(env) reads it; env_files() returns an empty EnvFiles under the mark without calling the reader. Every channel-.env reach in the bridge (_channel_env_candidates, hence _config_from_channel_env, _token_from_ag2space_env and discover's file tier) goes through env_files, so under an injected token the child has no path to the file. BRIDGE_CARRIED_CONFIG = ("REMOTE_PROACTIVE_TRUST_OK",) is the audited set of host config a supervisor carries; bridge_carried_config(env, read_text) resolves each from the host's environment, else its channel files through the caller's bounded reader, and returns only those values (a key set nowhere is absent; the files' credentials never leave the call).
  • Reader (wrapper). bridge_environ binds the host mapping once, filters it as before, merges the derived env, then _sources.bridge_carried_config(host, read_regular), then the token environ (mark included). CLAUDE_CONFIG_DIR stays. The wrapper runs as the host, so it is the one allowed to read the host's file.
  • Guard. READER_ENVIRON and READER_WRITES pin the new body; bridge_carried_config joins READER_ONLY and OWNER_READERS (its reader argument is checked like env_files'); mutants that resolve the setting in the reader, drop the carried merge, or strip the mark are flagged; an owner test proves an injected env opens nothing, registers nothing and resolves nothing itself, while the host resolves the setting (env wins, file else, absent when set nowhere).
  • Sentinel (tests/sparrowd-agent-bridges.test.py, HostCredentialFiles): a fake CLAUDE_CONFIG_DIR whose channels/ag2space/.env holds a canary token and REMOTE_PROACTIVE_TRUST_OK=1, plus an access.json; builtins.open, read_regular and read_regular_text are wrapped to record every path; bridge.main runs the shipped wrapper with an injected agent token and the bridge module imported where the child would run. Asserts: zero opens of the .env by the child (the wrapper's own open is recorded separately and expected), the canary nowhere in _SECRETS, the child's environment or its stderr, TOKEN is the agent's, _ag2space_access_path() is the fake access.json and _load_tier_map() opened it, PROACTIVE_TRUST_OK is True from the carried value; a second case sets the flag 0 in the host environment and shows neither process opens the file.

Before/after (owner, vendored owner and reader at 36beb75 with the new tests, then at this head), env -i … nice -n 19, under the shared test lock:

== BEFORE: owner, vendored owner and reader at 36beb75a, the new tests (tests/sparrowd-agent-bridges.test.py)
FAIL: test_the_bridge_child_never_opens_the_hosts_channel_env_and_keeps_access_json_and_the_trust_setting
  AssertionError: '<cfg>/channels/ag2space/.env' unexpectedly found in [the child's opens]
FAIL: test_the_hosts_environment_setting_wins_and_the_child_still_opens_nothing
  AssertionError: the child's opens hold '<cfg>/channels/ag2space/.env' beside access.json
Ran 20 tests; FAILED (failures=2)
== AFTER tests/sparrowd-agent-bridges.test.py at d094bf0a; load 7.13 (gate < 10, shared lock)
Ran 20 tests in 0.054s
OK
== AFTER tests/gateway-credential-sources.test.py (owner test added; nothing it covers changed after this run)
Ran 144 tests in 10.498s
OK
== packages/ag2-sparrow/tools/sync_from_src.py --check
in sync with src/ ✓

After review (6dcb862, 6caff4d, 0da379e, dd5b0d5, 989fa2b, 8804cf5, 49d5005, 63404c8, 43d1c5a, 97e312b)

  • 6dcb862 (tests only). tests/sparrowd-agent-bridges.test.py no longer copies os.environ, which the hermetic structural gate refuses. It records what bridge_environ returns and checks key by key that the bridge runs under exactly that.

  • 6caff4d. src/channel_token.py's file and vault readers return '' without reading in a process tree that carries the injected mark. Room ops' channel .env and vault tiers and scripts/channel-env.sh all go through them, so a declared bridge's wake child cannot reload the host's credential. bridge_environ also drops BRIDGE_CARRIED_CONFIG keys from the generic base, so a carried key comes only from bridge_carried_config. New tests/declared-bridge-wake-child-credentials.test.py is a real-subprocess sentinel from the wrapper through _fire_on_task to room ops, with an unmarked control.

  • 0da379e. The shared/ path line in channel_token.py carries the workspace-resolution lint's line pragma allow-repo-root: it walks to the repo root, not a workspace.

  • dd5b0d5. A declared agent's wake child acts as the agent. gateway_credential_sources.wake_environ (the owner) drops every gateway token and URL alias from the wake environment under the mark, then sets the agent's own token as the bridge holds it, plus its gateway URL. The host's credential never reaches it. Room ops there resolves exactly that env token and fails closed without one. The host's own unmarked bridge's wake child is unchanged and still gets no credential.

  • 989fa2b. A declared bridge's endpoint comes only from the agent's token. bridge_strip_keys() also lists every gateway URL name and alias, so bridge_environ drops an inherited REMOTE_TASK_URL, AG2_REMOTE_URL, GATEWAY_URL or RELAY_URL. Discovery would otherwise rank it above the token's own URL and send the agent's bearer to the host's gateway. The wake child gets the same agent URL. The sentinel covers all four names.

  • 8804cf5 and 49d5005. A declared agent's gateway comes from its token, or from a vault key the owner writes. A URL inside the agent's token (AG2_RELAY_TOKEN_<worker>, the combined https://<gateway>|<secret> form) wins. For a bare secret, sparrow_agent_bridge.endpoint() reads AG2_RELAY_URL_<worker>, a vault key derived from the segment like the token key. bridge_token_environ(token, url) sets REMOTE_TASK_URL beside a bare token only, and gateway_url_problem refuses anything but an http(s) URL with a host. Install prints that key's vault set line. The managed-mode README and docs/remote-gateway-protocol.md state the rule.

  • 63404c8, 43d1c5a, 97e312b. Token and URL shapes.

    • A token with no secret (https://gw|) is refused by the wrapper and the preflight (token_secret_empty()).
    • A URL-shaped token with no separator is, per the shared contract, an opaque bearer: it runs with its AG2_RELAY_URL_<worker>. Only with no gateway do the wrapper and the declared bridge's FATAL name both remedies: re-store a combined token that lost its |, or set the URL key. The host's own bridge's lost-separator hint uses the same owner, token_url_shaped().
    • gateway_url_problem() parses the URL and requires a hostname and a valid port, for the vault URL and for a combined token's embedded gateway alike.
    • The startup log and the token-file gateway-change log print the gateway through _redact_url(), so a well-formed vault URL's userinfo, query and fragment are not logged. A URL _redact_url() cannot parse is still printed raw in the rotation-warning path; making it fail closed is tracked in persisted routing record stores the gateway URL raw (userinfo) #5845.

Fail-closed consequence and transition. A declared agent never takes its endpoint from the host's environment: REMOTE_TASK_URL, AG2_REMOTE_URL, GATEWAY_URL and RELAY_URL are all stripped. So a declared agent whose vault token is a bare secret, with no AG2_RELAY_URL_<worker>, does not start. Before this PR it would have sent its bearer to the host's gateway. It now fails closed: the wrapper names the one fix, vault set AG2_RELAY_URL_<worker> <the agent's gateway URL> (or store the combined token), and the bridge's FATAL for a declared bridge names the same vault keys instead of the stripped REMOTE_TASK_URL. On a host with declared agents, run python3 src/sparrow_agent_bridge.py --preflight after pulling this change and before restarting sparrowd: the flag ships with this change, so it does not exist on the old checkout. It prints one line per declared agent, ok (gateway from its token, or from its URL key) or WILL NOT START with that vault set line, and never prints a value. Installs whose token AG2 Space issued (the combined form) need nothing.

Why the URL is not in the declaration. worker.json sits beside the agent's root and is writable by the agent's own session, which runs as the same user, so the declaration chooses nothing: every field the bridge uses is derived from its directory's segment and checked against the file. A session-chosen URL there would let that session point its own bridge at an endpoint it controls. The wrapper stamps every task it queues with the host workspace's task-envelope key, so the session could feed itself owner-tier tasks carrying a valid stamp. The URL therefore lives in the vault, under a key derived like the token's, and a URL field written into worker.json is never read (pinned by test_a_url_written_into_the_declaration_is_never_used).

What the vault does and does not enforce. AG2_RELAY_URL_<worker> is owner-written by convention: install prints the vault set line for the owner, and nothing in the bridge writes it. It is not enforced. The vault is the user's login Keychain, and the agent's session runs as the same user, so it could write that key with security add-generic-password, just as it could rewrite the agent's own token key AG2_RELAY_TOKEN_<worker>. Both exposures predate this PR: the token key has had this property since declared bridges shipped. What this PR changes is that the declaration file, which the session writes in the ordinary course of its work, no longer carries anything the bridge acts on. The residual risk is a same-user process deliberately writing the owner's Keychain entry for its own agent. That is no worse than the token key itself, and it does not reach the host's credentials, which stay stripped from the bridge and closed to its process tree.

Every channel token is closed in a declared bridge's tree, not only the gateway's. Under GATEWAY_CREDENTIALS_INJECTED, channel_token.token_from_env_file and channel_token.token_from_vault (src/channel_token.py L123 and L148 at 97e312b) return '' for every variable, not just the gateway's. So the host's Slack, Telegram and Discord tokens are also unreadable from a declared bridge and its wake child, through .env files and the vault alike. This is intended: a declared agent's tree reads none of the host's credential files or vault entries. The environment tier still reads. Apart from the stripped gateway token and URL names, a declared bridge inherits sparrowd's environment, so a host channel token exported into sparrowd's own environment would still be visible there. The closed tiers are the .env files and the vault.

Release notes

Declared agent bridges under sparrowd: endpoint source changed. After this change, a declared agent never takes its gateway URL from the host's environment. Its URL comes from its vault token (the combined https://<gateway>|<secret> form AG2 Space issues) or from the new owner-written vault key AG2_RELAY_URL_<worker>.

  • Upgrade disruption: a declared agent whose vault token is a bare secret, with no AG2_RELAY_URL_<worker>, stops starting after sparrowd restarts.
  • What to do: after pulling and before restarting sparrowd, run python3 src/sparrow_agent_bridge.py --preflight. For every agent it marks WILL NOT START, run the vault set AG2_RELAY_URL_<worker> <the agent's gateway URL> line it prints, or store the combined token instead.
  • Who is affected: agents whose token AG2 Space issued are not affected.
  • Also: a declared bridge's process tree no longer reads any channel token (Slack, Telegram, Discord or gateway) from the host's .env files or vault.

Live post-restart witness (REVIEW lesson 15)

Run at this frozen head: #5815 (comment)

  • Findings:
    • Install's declaration is byte-identical to main's, and it is written and removed only through the owner functions.
    • The head's sparrowd ran the declared bridge for a test identity with its real relay token, handed in only as REMOTE_TASK_TOKEN and marked injected. No host token or URL name reached it.
    • The bridge connected to the real gateway, survived a sparrowd restart by verified PID, and delivered exactly one result for one live message (one delivered log line).
    • Removing the declaration stopped the worker on sparrowd's next pass.
    • The token appeared 0 times in 6,127 scratch files and in no process's exec-time environment.
    • A synthetic run of the same chain, in both the combined and split credential forms, showed the wake child acting as the agent and only the wrapper opening the host's .env.
  • Stated limits:
    • The full install/uninstall launchd and tmux parts were not run; this PR does not change them.
    • The test identity holds a combined token, so the split form is covered by the synthetic run and the unit tests, not live.
    • The round trip used one test identity on one host.

Follow-ups

#5845 tracks three code items left out of this frozen head:

  • the routing record persisting the gateway URL raw
  • _declaration_owned() reading an existing declaration with no size cap (the reader caps at 64 KiB)
  • preflight() raising a traceback when agents/ cannot be listed

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 93%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/cli.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/gateway_credential_sources.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py (100%)
  • packages/ag2-sparrow/tests/test_claude_code_adapter.py (100%)
  • src/channel_token.py (100%)
  • src/gateway_credential_sources.py (100%)
  • src/sparrow_agent_bridge.py (98.5%): Missing lines 215

Summary

  • Total: 300 lines
  • Missing: 1 line
  • Coverage: 99%

src/sparrow_agent_bridge.py

Lines 211-219

  211     homes = sorted(p for p in agents.iterdir() if p.name.endswith(SUFFIX)) if agents.is_dir() else []
  212     for home in homes:
  213         path = home / adapter.DECLARATION
  214         if _stat(path) is None:
! 215             continue
  216         try:
  217             decl, why = load(path, agents)
  218         except ScanUnavailable as e:
  219             decl, why = None, str(e)

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 8056be46b17d30f0a006e313b48e469574e5c0dc. REQUEST_CHANGES — one verified structural-guard blocker.

_names_only() in tests/gateway-credential-sources.test.py:1387-1402 proves only that the accessor call starts in a dict assigned to one name inside a scope named wire_bridge; it never checks how that assigned record is subsequently used. This ordinary staged read therefore passes the guard:

def wire_bridge(cfg):
    body = {"token_env": _sources.token_env_name()}
    return os.getenv(body["token_env"])

Exact-head result:

credential_access(source, "adapters/claude_code/cli.py") => []

The same false negative occurs with os.environ.get(body["token_env"]) and os.environ[body["token_env"]]. Expected is a non-empty finding: the accessor now feeds a credential reader, contrary to its “record name only, never read the variable with it” contract. The new dict test covers the inline expression but not this two-statement form.

The allowlist identity is also wider than the reviewed top-level function: nested functions, class methods, and async functions named wire_bridge in the same module all receive the exemption. Please constrain the record/field's downstream use to audited data-only sinks and bind NAME_ONLY_SITES to the unique canonical top-level function; add the staged-read case as a negative control.

This is not evidence of a current credential leak—the shipped wire_bridge still serializes the record safely, and the diff is test-only. The risk is that the claimed enforcement remains false-negative and a future edit can read the real host token while the guard stays green.

Verified on this head: gateway credential suite 127/127; hermetic sentinels 20/20; git diff --check and review-checks.sh clean. Restoring the parent detector while retaining HEAD tests makes 4 of 12 Guard tests fail, so the intended keyword/inline-dict/alias fixes do discriminate. Required CI and CLA are green. Fixing the staged-use and canonical-scope gaps would clear my blocker.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the new audited-sink exception is still name-based and lets the credential record escape.

At exact head ddb0c8a67440c2e9c313cbf7898ca0b8e1ad68a2, line 1427 trusts any callee whose AST spelling is json.dumps; the next line also trusts any bare return body. In a complete synthetic module, wire_bridge(json) built the token_env_name() record and called a caller-supplied json.dumps(body): credential_access(...) returned [], while executing it under env -i returned the synthetic secret. The same false negative occurs with the genuine stdlib function plus a caller-supplied cls encoder. Because the serialized output and returned record are untracked, dump→load→getenv and return→caller-unwrapping probes also pass cleanly. No real credentials were used.

There is a second exact-head false negative in the same record proof: a nonconstant dict key is stored as {None} at line 1414, so {k: token_env_name()} followed by body["alias"] passes when k = "alias". The equivalent literal-key control is correctly rejected.

Please bind the allowed sink to the canonical stdlib import and constrain it to the terminal shipped use (including arguments that can replace serialization behavior), or track the resulting value/caller; remove or track the bare-return escape; and fail closed on noncanonical keys. Add kill controls for these paths. The shipped adapter is currently safe—it imports stdlib json and writes the serialization directly—so this is a future enforcement hole, not a current live leak.

I verified that all nine bypasses claimed in this update are fixed: HEAD flags all nine, while 7779e2d0 misses seven and already flags two. The gateway suite passes 132/132, the hermetic sentinels pass 20/20, the prior-guard kill control fails the new test, diff/review checks pass, and exact-head CI plus CLA are green. This remains not merge-ready until the structural guard rejects the synthetic escaping cases above.

@sonichi sonichi changed the title test(gateway-credentials): token_env_name may only be a value in the reviewed record fix(gateway-credentials): the owner builds the bridge record, so no adapter names the token key Oct 22, 2026

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head c87f6e2e85e80ef11dcfbc182668d257035fb4b7. REQUEST_CHANGES — not merge-ready. The shipped adapter is safe and I found no current credential leak, but the claimed structural enforcement still has reproducible false negatives.

  1. Blocking: the package-wide owner-key-tuple rule misses the exact string/getattr form the PR says it covers. At lines 1407-1410, KEY_TUPLES is checked only against name; unlike OWNER_ONLY, it is never checked against constant text. On this head:

    _sources.TOKEN_KEYS[0]             -> finding
    getattr(_sources, "TOKEN_KEYS")[0] -> []
    

    Planting the latter in a new package helper also made the real package_findings(...) scan return []; against the real owner it selects REMOTE_TASK_TOKEN and reached a synthetic value under env -i. The CLI-only word-set assertion catches this spelling only when it lives directly in cli.py; it does not make the package-wide rule true. Please check tuple-name string constants too and add the direct getattr positive control.

  2. Blocking: the opaque owner→adapter handoff and the general K4 claim are not enforced. The CLI pin proves only that forbidden words are absent and bridge_declaration_text appears somewhere. This exact adapter mutant names none of those words:

    declaration = _sources.bridge_declaration_text(...)
    record = json.loads(declaration)
    selector = next(value for key, value in record.items() if key.endswith("_env"))
    os.getenv(selector)
    cm._write(path, declaration, 0o644)

    It produced credential_access(...) == [], selected REMOTE_TASK_TOKEN, reached only a synthetic secret, and still passed all 17 Guard tests plus all 5 ConnectHostSupervised tests. A json.loads → json.dumps(..., indent=1, sort_keys=False) rebuild likewise stays byte-identical and passes both layers. Thus K4 catches the old literal rebuild, not byte-identical rebuilds generally. Keep the simple no-reference rule, but add a structural delegation pin that the owner's returned text reaches cm._write directly and unchanged, with these two kill controls.

  3. Blocking merge evidence is still missing/red.

    • The required diff-coverage job ran normally and failed at 88%, missing canonical owner lines 46 and 48. The new behavior test calls only the vendored owner, not OWNERS["src"]; byte-equal sync is useful but does not execute the canonical file. Drive the assertion over the canonical owner too and clear the required gate.
    • This now changes the bridge-install/live path, but the PR body and comments contain no real post-restart round trip. The worst case is a declaration the supervisor rejects or cannot launch, cutting task/result delivery. The harness and byte-parity proof reduce that risk but do not replace the live witness required by REVIEW lesson 15.

Verified passing on this head: gateway credentials 132/132 (base 124, all eight new tests collected), hermetic sentinels 20/20, Claude adapter 239/239 (base 238), vendored sync, git diff --check, and review-checks.sh. Exact-path exclusions and their positive control work, and the repo-wide out-of-package references are accurately disclosed.

One evidence correction is also needed: an isolated replay of K4 produced two Guard failures, not the three pasted in the PR body; test_the_exclusions_are_exactly_the_owner_modules passed. Fixing the two enforcement gaps, required coverage, live witness, and pasted K4 result would clear my blockers.

@sonichi sonichi changed the title fix(gateway-credentials): the owner builds the bridge record, so no adapter names the token key fix(gateway-credentials): the owner writes the bridge declaration and reads it for sparrowd Oct 22, 2026

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 7c7145391a74754ae383dbd7b94cc2db3db133bf. REQUEST_CHANGES — not merge-ready. The prior string/getattr and 88% coverage blockers are fixed: required CI, CLA, and diff coverage now pass, and the shipped paths appear safe. Two claimed structural boundaries still have reproducible false negatives.

  1. Blocking: the adapter still receives the existing declaration text, so the read/parse/environment-read class is not gone. owned_declaration() returns the complete text, and wire_bridge() binds it before calling the owner writer. The new delegation assertion checks only the _sources.write_bridge_declaration call/result; it never constrains that pre-read text.

    This exact-head mutant after the problem check names no owner-private symbol or record key:

    if text:
        record = json.loads(text)
        selector = next(value for key, value in record.items() if key.endswith("_env"))
        os.getenv(selector)

    It produced credential_access(...) == [], passed all 21 Guard tests and all 5 ConnectHostSupervised tests, and a controlled install→wire_bridge replay observed REMOTE_TASK_TOKEN resolving to the suite's synthetic token; no real credential was used. The disclosure that the file contains only the variable name does not mitigate this—the name is the selector for the token already present in the adapter process environment. K3 kills holding the owner's None return, but not this shipped pre-read route. Please move the ownership/foreign-declaration check behind the record owner boundary so the adapter never receives declaration bytes, pin that delegation, and add this existing-text mutant as a kill control.

  2. Blocking: the sparrowd reader's second owner delegation is not pinned. Current code safely calls bridge_token_environ(), but replacing that call with token_env = {"REMOTE_TASK_TOKEN": token} passed all 21 Guard tests and all 12 sparrowd-agent-bridges tests; owner_only_findings(..., reader=True) also returned []. The reader guard requires only bridge_declaration_fields to remain, not bridge_token_environ or its call shape. Pin both accessors so the direct token-selector copy cannot return while the guard stays green.

The required live post-restart round trip is also still absent, as the PR body states. REVIEW lesson 15 makes that a separate approval blocker. Worst case: a future green-guard adapter edit can select and read the host token, or reader/writer drift can prevent the bridge from launching and interrupt task/result delivery.

Verified on this head: gateway 136/136, hermetic 20/20, Claude adapter 239/239, sparrowd 12/12 + rescan 32/32, local bridge 19/19, wrapper PASS, vendored sync, git diff --check, and review-checks.sh. The coverage artifact independently resolves to 68/68 changed executable lines, including 26/26 in the canonical owner.

@sonichi sonichi changed the title fix(gateway-credentials): the owner writes the bridge declaration and reads it for sparrowd fix(gateway-credentials): the owner checks, writes and removes the bridge declaration; sparrowd reads it through the owner Oct 22, 2026

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 2c06c889e874c949cf1655fc52cbf812d437efec. REQUEST_CHANGES — not merge-ready.

The exact round-7 mutants are fixed: the adapter no longer receives declaration bytes, the reader delegates both field and token handling to the owner, all required CI and CLA checks pass, and exact changed-line coverage is 130/130. The shipped adapter and reader are currently safe. Two claimed structural boundaries still have executable false negatives.

  1. Blocking: the declaration-path guard still permits ordinary local-helper refactors to read and interpret the declaration outside the owner. declaration_reads() recognizes the path only when its assignment directly contains a marker. Moving that same expression behind a local helper and then using path.exists() / path.read_text() produced declaration_reads=[] and credential_access=[], passed all 23 Guard tests and all 5 ConnectHostSupervised tests, and a controlled env -i replay selected only the suite's synthetic REMOTE_TASK_TOKEN. Constant-equivalent filenames such as "worker" + ".json" also produce no finding. Separately, line 1469 exempts any receiver whose method is spelled write_bridge_declaration or remove_bridge_declaration; it does not establish that the receiver is _sources, so a local helper with either name can receive and read the path while the guard remains green.

    This is an adjacent-input failure in the enforcement itself, not a leak in the current implementation. Please bind owner-call exemptions to the exact owner receiver and close the helper/constant-equivalent path flows (or replace the heuristic with a closed-world structural rule), with kill controls for each route.

  2. Blocking: the reader guard does not enforce its stated “may set no entry itself” rule. After the current whole merge in bridge_environ(), adding:

    env.update(REMOTE_TASK_TOKEN=token)

    yielded token_injection_findings=[] and passed all 23 Guard tests plus all 12 sparrowd-agent-bridges tests. token_injection_findings() checks string nodes and Subscript(Store), but an ast.keyword.arg is neither. A dead if False: env.update(token_env) also satisfies the whole-merge test, while a dynamically constructed key can perform the live self-write. Please pin an effective merge into the returned environment and reject every direct mapping-entry write; add these exact forms as kill controls.

The required REVIEW §15 post-restart round trip is still absent. Since code findings remain, do not spend that witness yet: close both structural holes, rerun the exact-head gates, freeze the final head, then run and paste the live witness before re-requesting review.

Verification on this head: gateway 138/138, Claude adapter 239/239, hermetic 20/20, sparrowd 12/12 + rescan 32/32, local bridge 19/19, wrapper PASS, vendored sync, git diff --check, review-checks.sh, all non-skipped CI, CLA, and 130/130 changed executable lines. Worst case if these guards remain permissive: a future green-guard adapter edit can again select the host token, or a reader edit can bypass owner policy and disrupt bridge task/result delivery.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes at 2c06c88. I concur with Kewei's review at this head; I re-checked his reader finding myself.

Blocking (false claim in the guard; independently reproduced):

  • The PR body, guard point 4, says that in the reader bridge_environ "stores no item itself". At this head I added one line after env.update(token_env) in src/sparrow_agent_bridge.py:135: env.update(REMOTE_TASK_TOKEN=token). All 138 tests in tests/gateway-credential-sources.test.py still pass. token_injection_findings() inspects string nodes and Subscript(Store), and a keyword argument (ast.keyword.arg) is neither. So the guard does not enforce the property the body claims. File restored afterwards.
  • I did not re-run Kewei's finding 1 (helper-wrapped and constant-equivalent declaration paths, and owner-call exemptions matched by method name alone). I have no reason to doubt it, and it is the same class of gap.

Asks, matching Kewei's:

  1. Reject every direct mapping-entry write in the reader, whether keyword, update with a dict, setdefault, or a dynamic key. Pin that the owner's environ is effectively merged into the returned env, with each form as a kill control.
  2. Bind the owner-call exemptions to the exact owner module receiver, and close the helper and constant-equivalent path routes, or replace the heuristic with a closed-world rule. Add a kill control for each route.
  3. After that, freeze the head and post the REVIEW §15 post-restart round trip.

What is right: the design of removing the data flow, so the adapter gets only an opaque status and the reader goes through the owner, is the correct shape. The shipped adapter and reader are currently safe; the gap is in the enforcement.

— Sutando (susans-MacBook-Pro)

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head a5288fcb93bb90051e6474a78fac710146ed1c8f. REQUEST_CHANGES — not merge-ready.

The three routes reported at 2c06c889 are fixed: the helper/constant path cases and owner-receiver controls now discriminate, the prior keyword/setdefault/putenv/dict-merge reader writes are caught, all 27 clean Guard tests pass, and the shipped adapter/reader paths remain safe. Two false negatives still contradict this head's stated structural boundaries.

  1. Blocking: an f-string is treated as a message even when it is the path passed to a file reader. marked() skips every JoinedStr, rather than only a path formatted into a diagnostic. This exact-head mutant returns both declaration_reads=[] and credential_access=[]:

    path = bridge_home(root) / DECLARATION
    text = open(f"{path}").read()

    The adjacent controls discriminate: path.read_text() is flagged, while print(f"declared: {path}") remains allowed. This is a direct declaration read outside the owner under the opaque-path rule, not an arbitrary environment-reader rewrite. Please qualify the f-string exemption by its use context and add this read plus the legitimate message as a two-way kill control.

  2. Blocking: the reader's claimed closed-world mapping-write rule misses mapping union update. The test says the module's only mapping writes are the four pinned operations, but the scanner only rejects Subscript stores and calls whose attribute is in MAPPING_WRITES. Adding this after the canonical os.environ.update(environ):

    os.environ |= {next(iter(environ)): token}

    produced token_injection_findings=[] and owner_only_findings=[], passed all 27 Guard tests and all 12 sparrowd-agent-bridges tests, and executed through main() with return code 0. A synthetic-only probe observed the synthetic token under one unrelated environment binding in addition to REMOTE_TASK_TOKEN. This is an ordinary extra mapping write inside the explicit closed-world claim. Pinning main's environment mutation structurally, or covering AugAssign(BitOr) and __ior__, with this exact mutant as a kill control would close it.

Exact-head local verification: gateway credential sources 142/142, sparrowd bridge 12/12, git diff --check, and review-checks.sh pass; CLA is green and the published diff-coverage result is 130/130. Required CI is currently red: python standalone tests (7/9) timed out this changed guard suite after 240 seconds, which also failed the aggregate job. That may be a runtime/flakiness issue, but the required gate must be rerun green.

No REVIEW §15 post-restart witness was present in the PR or review thread when this review was submitted. Because either code fix moves the head, run and paste that witness on the final head. Worst case without these fixes: a future green-guard edit can read the declaration outside its owner or place the host token under an unrelated process variable, exposing it and/or breaking bridge startup and task delivery.

@sonichi

sonichi commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

REVIEW.md §15 live witness at da4e1ae

Head da4e1ae76d37006e667a511e8bec0f18a50ad386 (checked with git ls-remote at the start and again before posting). Required CI was green at this head before the run. Merge-base: c30087ec2a8e. Both trees were staged with git archive into a scratch dir. Every run used env -i PATH=… HOME=<home> USER=… with a scratch HOME and a scratch workspace, under nice -n 19. Nothing installed was touched: no launchd job, installed gateway, installed sparrowd or live workspace.

Which token each step used

  • Steps (1) and (3a) used no network and no token: the declaration names only a vault key.
  • Steps (2) and (3b) used the REAL relay token of a test identity (@support-cc.agent:ag2.space). The owner approved one copy of it into the derived vault key AG2_RELAY_TOKEN_BRIDGE_SUPPORT_CC_AGENT_AG2_SPACE_FD15F8B9, for this test only. The owner's agent made that copy and paused the identity's normal relay for the test (one bridge per identity). It deleted the vault copy and restored the relay after the test. The witness never read the source file.

(1) install writes the declaration through the owner — PASS

The full install registers and starts a launchd job and a Claude tmux session, which this witness must not do. So the witness drove install's declaration step, cli.wire_bridge(cfg), which is the code this PR changes. Both trees got identical inputs: agent @witness-5815.agent:ag2.space, BRIDGE=sparrowd, the same <ws>. A spy wrapped write_bridge_declaration / remove_bridge_declaration.

== BASE install
declared: <ws>/agents/witness-5815.agent_ag2.space.bridge/worker.json (sparrowd runs it as worker bridge-witness-5815-agent-ag2-space-4158729c on its next scan; the token is read from the vault: vault set AG2_RELAY_TOKEN_BRIDGE_WITNESS_5815_AGENT_AG2_SPACE_4158729C <the agent's relay token>)
owner calls: none (the owner functions do not exist in this tree)
sparrowd load(): accepted, worker bridge-witness-5815-agent-ag2-space-4158729c
== HEAD install
declared: <ws>/agents/witness-5815.agent_ag2.space.bridge/worker.json (sparrowd runs it as worker bridge-witness-5815-agent-ag2-space-4158729c on its next scan; the token is read from the vault: vault set AG2_RELAY_TOKEN_BRIDGE_WITNESS_5815_AGENT_AG2_SPACE_4158729C <the agent's relay token>)
owner calls: [('write_bridge_declaration', 'worker.json', '')]
sparrowd load(): accepted, worker bridge-witness-5815-agent-ag2-space-4158729c
token env names the bridge gets: ['REMOTE_TASK_TOKEN']
92dfc52c8b89f47f137932fa5e0d8eb64339a338af2dbd9d83b9caf29d6e80d2  base.json
92dfc52c8b89f47f137932fa5e0d8eb64339a338af2dbd9d83b9caf29d6e80d2  head.json
cmp: byte-identical
-rw-r--r-- 705 base.json
-rw-r--r-- 705 head.json
== HEAD install over a foreign file
owner calls: [('write_bridge_declaration', 'worker.json', '<ws>/…/worker.json was not written by install; remove it by hand (rm <ws>/…/worker.json) if it is yours to remove')]
declaration exists: True        # left as {"written_by":"someone else"}

Call path: cli.wire_bridge → _sources.write_bridge_declaration(path, written_by=…, host=mode, …), which writes tmp + os.replace, mode 0644. The src/ and packages/ag2-sparrow/ copies of gateway_credential_sources.py are byte-identical at the head.

(2) the head's sparrowd runs the declared bridge with the token only via bridge_token_environ — PASS

The head's wire_bridge wrote the declaration for the test identity into <ws>. The head's src/sparrowd.py --agent-bridges --rescan-s 10 ran from the scratch tree. The scratch workspace's vault manifest held the key NAME only. The scratch HOME has no login keychain in its search list, so a scratch-PATH security shim answered find-generic-password for that one key only, from the login keychain by path. Every other call exited 44. Apart from that shim, the shipped path ran unchanged: sparrow_agent_bridge.main → get_vault_key(decl["token_vault_key"]) → bridge_environ → _sources.bridge_token_environ(token) → os.environ.update.

[sparrowd] starting 1 worker(s): bridge-support-cc-agent-ag2-space-fd15f8b9
[sparrowd] worker bridge-support-cc-agent-ag2-space-fd15f8b9: started pid <bridge>
[remote-gateway-bridge] singleton: acquired workspace poller lock (acquired)
[remote-gateway-bridge] starting — gateway=<gateway> provider=remote tasks=<ws>/agents/support-cc.agent_ag2.space/tasks (restored 0 in-flight)
gateway-status: connected: True, error: None

In-process check of the same functions. It reads the key through the shim and counts only, never prints. The host's own token and device env were set in the parent environment:

owner called: ['bridge_token_environ']
keys holding the token: ['REMOTE_TASK_TOKEN']
host REMOTE_TASK_TOKEN passed through: False ; AG2_DEVICE_ENV kept: False

Restart: SIGTERM to the sparrowd PID, verified first with ps -o command=. Output: [sparrowd] shutdown requested / [remote-gateway-bridge] received SIGTERM — exiting / [sparrowd] all workers stopped. No scratch process was left. After starting again, a new worker pid came up and gateway-status read connected: True, error: None.

One round trip after the restart: the owner's agent posted one message to the test agent in a test room. The witness wrote the result file atomically into the agent's results dir.

[remote-gateway-bridge] served task-5c13db4111f545aad9 lease=lease-…
[remote-gateway-bridge] queued task-5c13db4111f545aad9
[remote-gateway-bridge] result task-5c13db4111f545aad9 delivered via DeliveryCore (provider=AG2SpaceResultProvider, backend=DesignAClaimBackend, worker=gateway-result-drain)
[remote-gateway-bridge] archived result for task-5c13db4111f545aad9
delivered lines for it: 1

Token absence. These are in-process counts only; the value was never printed:

scratch files scanned (logs, state, declarations, trees): 6095; token hits: 0
ps -E: processes whose environment holds the token: []

ps -E shows 0 processes, the live bridge included. That is expected: sparrow_agent_bridge puts the token into os.environ in-process after exec. So the token is in no process's exec-time environment, sparrowd's included, and it lives only in that bridge process's memory.

(3) uninstall removes the declaration through the owner; sparrowd stops the worker — PASS

Synthetic, no network:

removed: <ws>/agents/witness-5815.agent_ag2.space.bridge/worker.json
owner calls: [('remove_bridge_declaration', 'worker.json', (True, ''))]
declaration exists: False
== again (nothing there)
owner calls: [('remove_bridge_declaration', 'worker.json', (False, ''))]
== over a foreign file
owner calls: [('remove_bridge_declaration', 'worker.json', (False, '<ws>/…/worker.json was not written by install; remove it by hand …'))]
declaration exists: True

Live, on the running test bridge:

removed: <ws>/agents/support-cc.agent_ag2.space.bridge/worker.json
owner calls: [('remove_bridge_declaration', 'worker.json', (True, ''))]
[sparrowd] worker bridge-support-cc-agent-ag2-space-fd15f8b9: removed — stopping
[remote-gateway-bridge] received SIGTERM — exiting
bridge pid gone

Teardown: sparrowd was stopped by verified PID and no scratch process was left. The scratch dirs were removed. The owner's agent deleted the vault copy and restored the test identity's relay.

What this proves: on this head, install's declaration is byte-identical to main's and is written and removed only through the owner functions. The head's sparrowd accepts the declaration and runs the bridge. The bridge gets the token only as REMOTE_TASK_TOKEN from bridge_token_environ, and with it authenticates to the real gateway. It survives a sparrowd restart and delivers exactly one result in a real round trip. Removing the declaration stops the worker. What it does not prove: the full install/uninstall commands' launchd and tmux parts. Those are unchanged by this PR and were not run here.

@keweichen keweichen left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing exact head da4e1ae76d37006e667a511e8bec0f18a50ad386.

REQUEST_CHANGES — this head is not merge-ready.

The two requested regressions are fixed: the prior open(f"{path}").read() case is now reported, and the prior os.environ |= {...} case is now reported. The memoization is effective, the shipped adapter/reader paths still look safe, and the exact-head gates are green: local gateway 143/143, sparrowd 12/12, adapter 239/239, ConnectHostSupervised 5/5, git diff --check, and the repository review checks all pass; GitHub CI and CLA pass, with 130/130 changed executable lines reported covered.

Two executable false negatives remain inside the claims this test now makes:

  1. The f-string exemption still trusts every callee. message() classifies every JoinedStr as a message, and the call check exempts that argument without checking the sink. This direct consumer copies the declaration bytes outside the owner while declaration_reads(), credential_access(), and the package finding all remain empty:

    path = bridge_home(root) / DECLARATION
    shutil.copyfile(f"{path}", dst)

    Executing the mutant copied synthetic declaration bytes, while all 28 Guard tests passed. I also confirmed the same miss with an ordinary helper that calls Path(p).read_text(): 28/28 Guard and 5/5 ConnectHostSupervised still passed, and the helper read the synthetic declaration. This is not an arbitrary environment-read rewrite; it is exactly a call being handed the declaration path, including the helper case named in declaration_reads()'s contract. Please restrict the message exemption to audited message sinks/argument positions, or conservatively track path arguments through unknown consumers, and add a copying/reading kill control plus a legitimate-print control.

  2. The reader's closed world permits a stored process-environment writer. READER_ENVIRON_USES allows the os.environ.update attribute expression, while the write check recognizes only calls whose callee still has a listed attribute name. Appending this after the pinned update produces no token_injection_findings() finding:

    write = os.environ.update
    write({next(iter(environ)): token})

    All 28 Guard tests and all 12 sparrowd tests passed. A synthetic execution then put the token under an unrelated process-environment key (LANG) in addition to the owner's key. Please bind allowed environment operations to the exact pinned call sites rather than allowing retrieval/storage of a bound mutator, and add this alias as a kill control. An independent probe also found the same closed-world weakness through functional mapping writers such as operator.setitem(environ, ..., token).

§15 correction after inspecting the newly posted witness: the exact-head witness was posted seconds before this review but had not yet appeared in my room context. It exercises the real gateway and shipped scratch-tree sparrowd at this SHA, restarts sparrowd, delivers one real round trip through DeliveryCore, verifies owner-mediated declaration write/removal and token handoff, checks zero scratch-file token hits, and records teardown. Its stated limitation is the unchanged launchd/tmux portion of full install/uninstall. This satisfies REVIEW.md §15. The live-path gate is therefore PASS; the two code-enforcement blockers above are unchanged and still prevent approval.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing exact head da4e1ae76d37006e667a511e8bec0f18a50ad386.

I accept the owner's stated stopping rule. Under that scope, my prior f-string sink and stored-os.environ.update findings are guard-completeness follow-ups in #5820, not blockers. The exact-head live witness satisfies REVIEW.md §15, and the focused suites and GitHub gates remain green.

REQUEST_CHANGES — one current shipped-path credential leak remains.

The owner recognizes two live token source names: TOKEN_KEYS = ("REMOTE_TASK_TOKEN", "AG2_REMOTE_TOKEN"). But bridge_token_environ() returns only the first name, and bridge_environ() strips only HOST_ONLY plus the keys in that one-entry mapping. HOST_ONLY itself contains neither token name. Therefore a sparrowd host credential supplied under the supported legacy alias AG2_REMOTE_TOKEN survives into the agent bridge. main() then installs that environment and runs the bridge module.

I reproduced this through the real wrapper path with synthetic values: the declaration was written by cli.wire_bridge, the wrapper fetched agent-token from its mocked vault reader, and the launched module saw:

rc 0
{'REMOTE_TASK_TOKEN': 'agent-token', 'AG2_REMOTE_TOKEN': 'host-legacy', 'KEEP_ME': 'yes'}

The current tests/sparrowd-agent-bridges.test.py 12/12 and tests/gateway-credential-sources.test.py 143/143 both pass despite that result. This is execution on the shipped reader/wrapper path, not another bypass form of the static guard. It exposes the host/core token to an agent bridge alongside the bridge's own per-agent token, so I cannot approve this SHA under the stated rule.

Please have the owner expose the full set of host-only token names, strip every supported token source name, and inject the per-agent token only under the canonical bridge name. Pin the behavior with both host variables present: REMOTE_TASK_TOKEN must be replaced by the vault token and AG2_REMOTE_TOKEN must be absent. The same finding is now recorded in #5820; because it affects the shipped path, it belongs in this PR under the stopping rule. Once that is fixed, I have no remaining blocker within the agreed scope.

sonichi and others added 9 commits October 23, 2026 18:04
…reviewed record

The guard accepted the accessor beneath any keyword and any dict value, and did
not model import aliases, so os.getenv(key=...), a read through an inline dict,
and an aliased import all passed. A call is now allowed only as a value of a
dict literal assigned whole to one name inside a listed (module, function) site;
aliasing the accessor on import is flagged. One negative test per form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e one top-level site

A record that started at the reviewed site could still be read back in the
next statement, and any def named wire_bridge (nested, method, async, or a
second one) counted as the site. The site is now the unique module-level def,
the record bound there may only be passed whole, returned, or read at a key
other than the one holding the name, and no module may read the record key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed sink

Treating the record as whole whenever it was a positional argument to any call
let dict(body).popitem()[1], d.update(body), list(body) and similar launder the
name. The record may now go only to a listed sink (json.dumps, as its first
positional argument, the one shipped use), be returned, or be read at a fixed
key that does not hold the name. Any other use is flagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dapter names the token key

Four review rounds showed that statically following the record's data flow
through an adapter cannot be made complete: a shadowed or aliased json, an
encoder passed as cls=, or laundering through another call all slipped past.
The owner now builds the declaration's JSON text (bridge_declaration_text),
wire_bridge writes what it returns, and the guard reduces to one rule: no
package module outside the owner names token_env_name, a key tuple, or the
"token_env" key. The scan excludes the two owner modules by exact path, and
a positive control plants a reference in the adapter. The declaration bytes
and the printed worker name are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… reads it for sparrowd

The adapter could still hold the owner's text and parse a selector out of it,
and the guard checked key-tuple names but not their strings. The owner now
writes the declaration itself (write_bridge_declaration, tmp + os.replace) and
returns nothing, so the adapter hands it a path and never holds the record.
The declaration's reader, sparrow_agent_bridge, takes the fixed fields and
the token's variable from owner accessors instead of naming the field or its
own constant. The guard flags any identifier or string naming an owner key
tuple, an owner-private name or the record's token key, keeps the reader's
accessors to the reader, scans the reader too, and pins that the adapter's
one owner call is a discarded statement. Both owner copies are exercised.
The declaration bytes, the printed worker name and sparrowd's checks are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eader's token comes from the owner

The adapter still read an existing declaration to check it wrote it, so it held
the bytes it could parse a selector out of, and the reader's token injection
was not pinned to the owner's accessor. The owner now does the ownership check,
the write and the removal (write_bridge_declaration returns '' or why not;
remove_bridge_declaration returns removed and why), with the same messages.
The guard treats a path built from the declaration's file name as opaque
outside the owner: it may be formatted or handed to those two owner calls,
nothing else. The reader's bridge_environ must merge bridge_token_environ(...)
whole, store no item itself, and name no owner token variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alls to the owner's import, close the reader

The guard read raw string literals where it detected a file name, a credential
path or a systemctl probe, so "worker" + ".json" and similar folded forms went
unseen; every detection now reads _constant (shape checks keep requiring a
literal). The owner-call exemption trusted the method name: it now needs a
direct call on the module's one import of the owner module, bound once and
never rebound, and any other route to an owner entry point is flagged. A path
returned by a helper, a lambda or a module global is the declaration too.
The reader's bridge_environ is pinned to its exact body, and its mapping
writes and main's environment to an exact allowlist, so a keyword write, a
dead merge or a dynamic key after the merge is flagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pin every environ reach; scan each module once

The previous head stopped the declaration walk at every f-string, so
open(f"{path}").read() went unseen. marked() is pure reachability again (it
stops only at an owner call), and a message is exempt only where it is the
argument itself: an f-string, a name bound from one, or a call to a function
returning only those, together with not/compare and and/or/if over them.

The reader allowed os.environ |= {...}: it now has no in-place or | merge,
every reach of a process environment is one of three pinned uses, and
__ior__/__or__ count as mapping writes.

The guard re-scanned the whole package in several tests, which timed out
under CI instrumentation. Each pure scan is memoized per (source, module),
the def listing is cached per module, and the taint loops stop at their
fixpoint: the file runs in ~8s instead of ~33s under coverage locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… must not inherit

`bridge_environ` stripped only the name `bridge_token_environ` hands the agent's
token under (`TOKEN_KEYS[0]`), so a host holding `AG2_REMOTE_TOKEN` or
`REMOTE_MEDIA_HS_TOKEN` passed both to the agent bridge, where `discover()`
reads either. The owner now exposes `bridge_strip_keys()`, every `TOKEN_KEYS`
entry plus `MEDIA_TOKEN_KEY`, and the reader filters on that set before merging
the agent's token. The guard pins the new body, admits the accessor to the
reader-only set, and flags a reader that narrows the set again; the sparrowd
suite builds the bridge's environment with a synthetic host value under each
name and checks each is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sonichi
sonichi force-pushed the fix/token-env-name-guard branch from da4e1ae to 7529fd4 Compare October 24, 2026 02:08
…f the gateway token

`bridge_strip_keys()` named `TOKEN_KEYS` and the media token, so a host's
`GATEWAY_TOKEN` or `RELAY_TOKEN` still reached the agent bridge. The bridge
reads neither, but room-ops and bee-actions rank both above `REMOTE_TASK_TOKEN`,
so a child the bridge spawns would act as the host. The set is now the union of
`TOKEN_KEYS`, the contract's `TOKEN_ALIAS_PRECEDENCE` (imported, not copied)
and `MEDIA_TOKEN_KEY`, each once; the guard asserts the tuple against the
contract, and the sparrowd suite gains one per-key test for each alias.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 36beb75aeee96bdfc233b2cdd444710b8f4a3700. REQUEST_CHANGES — not merge-ready.

The direct environment-key fix is now sound. I reproduced the shipped cli.wire_bridge -> sparrow_agent_bridge.main path with synthetic host values under REMOTE_TASK_TOKEN, AG2_REMOTE_TOKEN, GATEWAY_TOKEN, RELAY_TOKEN, and REMOTE_MEDIA_HS_TOKEN: the child retained only the injected agent REMOTE_TASK_TOKEN plus an unrelated sentinel. The parent 7529fd40 still leaked GATEWAY_TOKEN and RELAY_TOKEN, so the new control discriminates. Credential-source tests pass 143/143, bridge tests pass 18/18, canonical/vendored sync passes, and review checks pass.

Two blocking findings remain:

  1. P1 — the per-agent bridge still reads the host credential file through inherited CLAUDE_CONFIG_DIR. The launchd job supplies that host config root (plist L87-L88); bridge_environ() preserves it (reader L127-L135); and the child resolves $CLAUDE_CONFIG_DIR/channels/ag2space/.env (source L220-L227) and registers every discovered credential candidate (bridge L1815-L1820). An exact-head synthetic witness produced:

    winner_url https://agent.example/relay
    winner_token_is_agent True
    host_file_token_was_read True
    candidate_paths ['<worktree>/.review-host/channels/ag2space/.env']
    

    The agent token wins, but the host token bytes still enter the per-agent bridge and its secret registry. That violates the credential-ownership boundary through an indirect shipped path, and the new direct-key regression does not cover it. Please add a shipped-path sentinel for this route and separate the agent bridge's credential discovery from the host .env. Blindly stripping CLAUDE_CONFIG_DIR is not necessarily safe because this bridge also uses it for access.json and REMOTE_PROACTIVE_TRUST_OK; preserve required noncredential policy through an explicit audited path.

  2. P2 — exact-head required CI is red. tests/sparrowd-agent-bridges.test.py:366 uses seen.update(os.environ), which the hermetic credential-test gate correctly reports as a whole-environment copy. The local exact-head run failed both its normal and Python-3.9 fallback variants with tests/sparrowd-agent-bridges.test.py:366: copy, and GitHub's python standalone tests (7/9) job is now failed. Capture only the asserted keys (or use the repository's hermetic helper).

The final-head live gate is also still outstanding. The only posted REVIEW.md §15 post-restart witness is for da4e1ae7; both 7529fd40 and 36beb75a changed the shipped bridge environment afterward, and the PR body says the new witness has not been run. After the two blockers above are fixed and the head is frozen, please record the real post-restart round trip for that final SHA and get required CI green.

No separate nits.

…e wrapper carries the host's trust setting

The per-agent bridge inherited the host's CLAUDE_CONFIG_DIR (needed for
access.json), and at import `_channel_env_candidates()` opened
`$CLAUDE_CONFIG_DIR/channels/ag2space/.env`, so `discover()` registered the
host's file token before precedence picked the agent's. The owner now marks a
handed-in token (`bridge_token_environ` sets GATEWAY_CREDENTIALS_INJECTED) and
`env_files()` returns nothing under that mark, so no caller in the child, the
config fallback included, reaches the file. The one host setting the child took
from that file, REMOTE_PROACTIVE_TRUST_OK, is resolved by the wrapper through
`bridge_carried_config()` (environment, else the host's files, credentials
staying inside the owner) and handed in as a plain value. The guard pins the new
reader body and the carried set; the sparrowd suite runs the shipped path with
the reader and open instrumented and a canary token in the host's .env.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head dd5b0d5f690fab23b01042ab7f0c7122bf24d753 against base 7940f25ff965f51df3273d94f009cb1b771119c0.

The three blockers from my review at d094bf0a are resolved: marked readers no longer reopen host file/vault credentials, carried trust comes only from the owner, and the hermetic structural gate passes. The new wake-child identity decision is also explicit: it acts as the declared agent. I am replacing that stale review state with the current-head finding below.

Blocker

[P1] Bind the declared-agent bearer only to an agent-owned endpoint.

bridge_environ() strips token aliases but not URL aliases. The bridge's production discovery then prefers REMOTE_TASK_URL / AG2_REMOTE_URL from its inherited environment over the URL embedded in the agent token. Finally, dd5's new wake path passes that already-selected URL beside _RAW into wake_environ.

I exercised the shipped real-subprocess chain (wrapper -> bridge import/discovery -> queued task -> _fire_on_task -> wake subprocess -> room ops) four times. Both supported inherited aliases fail:

REMOTE_TASK_URL: host_token_loaded=false host_url_loaded=true agent_token=true agent_url=false
AG2_REMOTE_URL:  host_token_loaded=false host_url_loaded=true agent_token=true agent_url=false
GATEWAY_URL:     host_token_loaded=false host_url_loaded=false agent_token=true agent_url=true
RELAY_URL:       host_token_loaded=false host_url_loaded=false agent_token=true agent_url=true
FAILING ALIASES: ['REMOTE_TASK_URL', 'AG2_REMOTE_URL']

The existing sentinel seeds only GATEWAY_URL, which this discovery path does not read, so its green assertion does not cover either actual URL_KEYS alias. Worst case, the agent bearer is sent to an unintended/stale host gateway; even without interception, declared-agent task, result, and room operations are routed to the wrong deployment.

Please resolve the endpoint under the shared declared-agent credential owner before it becomes bridge state: exclude both supported host URL aliases from the generic base, then derive or explicitly carry an agent-owned endpoint. Add both REMOTE_TASK_URL and AG2_REMOTE_URL cases to the real-chain sentinel. Fixing only the final wake environment is insufficient because the bridge itself has already selected the wrong endpoint. For precision, bridge-level URL contamination exists at the base commit; dd5 newly propagates that contaminated endpoint with the agent bearer into the wake child and claims the changed-path invariant that the child gets the agent's URL.

Required live evidence

The PR body still says the REVIEW.md section 15 post-restart witness has not run. After the code fix and a frozen final head, please provide the real restarted-service declared-agent round trip; the hermetic subprocess sentinel is valuable but does not replace that merge gate.

Verification

  • Independent production-path alias repro: both actual aliases fail as shown above; both controls pass.
  • tests/declared-bridge-wake-child-credentials.test.py: 9/9 pass stock.
  • tests/sparrowd-agent-bridges.test.py: 21/21 pass.
  • tests/gateway-credential-sources.test.py: 144/144 pass.
  • tests/hermetic-env-structural.test.py: 26/26 pass.
  • Adapter suite: 239/239 pass; vendor sync and review scans pass.
  • Exact-head GitHub checks: 35 success, 3 skipped, none failing/pending; license/cla is successful.

Requesting changes for the credential/endpoint pairing above and the still-pending exact-final-head live witness.

…m the agent's token

bridge_environ stripped the host's token aliases but kept its URL names, and discover() ranks an
environment URL above the one inside the token. An inherited REMOTE_TASK_URL or AG2_REMOTE_URL
therefore pointed a declared bridge at the host's gateway with the agent's bearer, and the wake
child inherited that endpoint. The owner's bridge_strip_keys now also lists each URL name and
alias (URL_KEYS and the contract's URL_ALIAS_PRECEDENCE), so bridge_environ drops them from the
generic base and the bridge's endpoint is the one inside the agent's token. wake_environ drops
the same names and sets the bridge's URL beside the agent's token.

Tests: the real-chain sentinel runs once per host URL name (REMOTE_TASK_URL, AG2_REMOTE_URL,
GATEWAY_URL, RELAY_URL) and requires both the bridge's own URL and the wake child's room-ops URL
to be the agent's. A bridge_environ test covers all four names, and the pinned strip list is
updated. Without the URL names in the owner, all four sentinel cases fail.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

@keweichen @liususan091219 the URL finding from both reviews at dd5b0d5 (Kewei 5636966255, Susan 5636948182) is fixed at the owner in 989fa2b. CI is green there: 35 checks pass, the required ones included. Two unrelated tests, worker-config-directory and reap-orphans, failed on the first attempt and passed on rerun.

  • Fix. gateway_credential_sources.bridge_strip_keys() now also lists every URL name and alias (URL_KEYS and the contract's URL_ALIAS_PRECEDENCE: REMOTE_TASK_URL, AG2_REMOTE_URL, GATEWAY_URL, RELAY_URL), with both copies byte-identical. bridge_environ therefore drops them from the generic base before the bridge's discovery runs, so the bridge's endpoint is the one inside the agent's token. wake_environ drops the same names and sets that URL beside the agent's token. There is no separate declared-URL contract: the declaration carries no URL, so the agent's onboarding token is the one source.
  • Consequence. A declared agent whose vault token is a bare secret, with no URL inside it, now stops with the bridge's existing no gateway URL FATAL instead of borrowing the host's endpoint.
  • Tests, run with the load gate and nice 19:
    • tests/declared-bridge-wake-child-credentials.test.py: the real-chain sentinel (wrapper → bridge import and discovery → queued task → _fire_on_task → wake subprocess → room ops) now runs once per host URL name: REMOTE_TASK_URL, AG2_REMOTE_URL, GATEWAY_URL, RELAY_URL. Each run requires both the bridge's own URL and the wake child's room-ops URL to be the agent's, with the agent's bearer.
    • tests/sparrowd-agent-bridges.test.py: new test_no_host_gateway_url_reaches_the_bridge_under_any_name.
    • The pinned bridge_strip_keys() tuple in tests/gateway-credential-sources.test.py is updated.
    • Mutation: with the URL names removed from the owner, all four sentinel cases fail. REMOTE_TASK_URL and AG2_REMOTE_URL fail on the bridge's own endpoint ('https://host-gw.example/relay' != 'https://agent-gw.example/relay'). GATEWAY_URL and RELAY_URL fail on room ops resolving the host URL.
    • Results: declared-bridge-wake-child-credentials 9/9, sparrowd-agent-bridges 22/22, gateway-credential-sources 144/144, hermetic-env-structural 26/26, test_on_task_wake, the claude-code adapter suite 239/239, credential-contract-golden 37/37, the channel-env-resolve, channel-token and room-ops gateway tests, and sync_from_src --check in sync.
  • §15. The live post-restart round trip at the frozen final head is still owed. It runs once the owner gives his word for that head. No credential was copied for these checks.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment at 989fa2b5. The URL finding from my review at dd5b0d5f (5636948182) is fixed. I checked it in the code and re-ran it myself. Not approving: there is no approval from anyone else at this head, and license/cla is absent.

Where this account's change request stands. Its CHANGES_REQUESTED (at 2c06c889) stays on the record until an approval replaces it. Every blocking ask from this account is now cleared: asks 1 and 2 (cleared at 0da379e2), the wake-child identity question (answered at dd5b0d5f), and the URL finding (fixed here). The only open item is ask 3, the §15 post-restart round trip at the frozen final head, which is named, not blocking.

The strip now covers every URL name discover() can read. Verified in code.

  • URL_KEYS is ("REMOTE_TASK_URL", "AG2_REMOTE_URL"), and discover() reads the environment URL only through first_env(env, URL_KEYS). Its other URL tiers are the URL inside the winning token, and the file tier, which under the mark gets no files.
  • The contract's URL_ALIAS_PRECEDENCE is GATEWAY_URL, RELAY_URL, REMOTE_TASK_URL, AG2_REMOTE_URL. bridge_strip_keys() now returns all four plus both URL_KEYS, once each, in both byte-identical copies (sync_from_src.py --check: in sync).
  • bridge_environ strips bridge_strip_keys(). wake_environ now strips the same set, which is exactly the names it stripped by hand at dd5b0d5f, then sets the bridge's URL beside the agent's token.
  • remote_gateway_bridge.py takes its endpoint only from _FOUND.url. It reads no other URL-type environment name.

Tests, run at 989fa2b5 in a scratch worktree under Python 3.12

  • declared-bridge-wake-child-credentials 9/9 (the sentinel runs once per URL name), sparrowd-agent-bridges 22/22, gateway-credential-sources 144/144, hermetic-env-structural 26/26, test_on_task_wake all pass, claude-code adapter 239/239, test_vault_token_tier 7/7, credential-contract-golden 37/37, and the channel-env-* and channel-token-* suites all pass.
  • The new tests fail on the old code. With the three source files at dd5b0d5f and the new tests kept, the sentinel fails for REMOTE_TASK_URL and AG2_REMOTE_URL on the bridge's own endpoint ('https://host-gw.example/relay' != 'https://agent-gw.example/relay'). GATEWAY_URL and RELAY_URL pass there, as they should, because dd5b0d5f's wake child already stripped them. The new bridge_environ test and the pinned strip tuple also fail.
  • The author's mutation claim holds. At this head, with only the two URL terms removed from bridge_strip_keys() in both copies, all four sentinel cases fail. The first two fail on the bridge's endpoint, the last two on "room ops resolved the host's gateway URL". Files restored afterwards.
  • Probe. bridge_environ with all four host URL names and a host token set: a https://agent/relay|<secret> token makes discover() return the agent's URL. A bare secret makes it return '', and no host value survives either way.

The fail-closed consequence. I looked for a current deployment that this would break, and found none. The documented install path says vault set AG2_RELAY_TOKEN_<worker> <the agent's relay token>. The relay token AG2 Space issues for an agent is the full <relay_url>|<secret> string, so the URL comes with it. A bare secret only fails if someone pasted one by hand. On main that would have borrowed the host's endpoint. Two small notes, neither one an ask:

  • The FATAL hint that a declared bridge then prints, "set REMOTE_TASK_URL", cannot help it, because that name is stripped. A declared-bridge-specific line would be clearer: "the vault token must be the combined https://…|<secret> string".
  • The PR body's 989fa2b bullet does not state this consequence. Your comment does, and the body is the record.

Gates at this head: CI 35 success, 3 skipped, none failing. license/cla is absent: the status endpoint returns 0 contexts at 989fa2b5. It was success at dd5b0d5f. 0 unresolved threads. Mergeable, but BLOCKED.

What it still needs

  1. An approval at this head. Kewei's latest review is a CHANGES_REQUESTED at dd5b0d5f for this same URL finding plus the §15 witness. Nobody else has reviewed 989fa2b5.
  2. license/cla reported at this head.
  3. Still owed (named, not blocking): the §15 post-restart round trip at the frozen final head.

— Sutando (susans-MacBook-Pro)

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 989fa2b56c3017a0d19624b69a6561174708130d (direct parent dd5b0d5f690fab23b01042ab7f0c7122bf24d753, base 7940f25ff965f51df3273d94f009cb1b771119c0).

The endpoint-binding blocker from my review at dd5b0d5f is fixed. The shared owner now strips all four URL aliases before bridge discovery, the real wrapper → bridge → queue → wake subprocess → room-ops chain selects the agent endpoint for REMOTE_TASK_URL, AG2_REMOTE_URL, GATEWAY_URL, and RELAY_URL, and a parent-owner mutation makes all four cases fail. The prior token/file/vault/trust isolation remains green.

I am requesting changes for one compatibility blocker plus the required live gate.

Blocker

[P1] Preserve or explicitly migrate the supported declared-agent split credential form.

The security boundary in bridge_strip_keys() is correct, but bridge_environ() now removes the only endpoint available to a declared bridge whose vault entry is a bare secret. That is a previously accepted persistent configuration: docs/remote-gateway-protocol.md says the split URL + token form is recommended and calls the combined token a legacy/bootstrap shortcut; the managed adapter instructions say to put <token> in the derived vault key and define no agent-owned URL field or combined-only requirement.

I reproduced the shipped declaration/wrapper/bridge startup with a synthetic bare vault secret and inherited REMOTE_TASK_URL, stopping before any network call:

dd5b0d5f: URL=https://host-gw.example/relay, agent bearer selected, startup validation passed
989fa2b5: URL='', agent bearer selected, FATAL: no gateway URL

The fail-closed direction is right; restoring an unbound host endpoint is not the remedy. The rollout is not mitigated, though: an existing declared split-form agent stops processing after its worker restarts. The generic fatal also says to set REMOTE_TASK_URL, but this wrapper deliberately strips that setting, so one of its two recovery instructions cannot work for this mode.

Please keep the endpoint/credential binding and provide either a trusted agent-owned endpoint source or an explicit managed-mode migration/preflight to combined tokens, with accurate persistent-configuration docs and an actionable diagnostic. Add a regression for the chosen transition. An owner-accepted compatibility break would still need a concrete pre-upgrade migration for existing declared agents; one live test identity using a combined token does not establish that transition.

Required live evidence

The REVIEW.md §15 exact-head post-restart round trip is still explicitly outstanding. The four-alias subprocess sentinel is good harness evidence, but approval requires the authorized real restarted-service round trip at this frozen SHA.

Verification / gates

  • declared-bridge-wake-child-credentials: 9/9; sparrowd-agent-bridges: 22/22; credential owner: 144/144; adapter: 239/239; hermetic structural: 26/26.
  • Vendored sync, git diff --check, and cumulative + child-only review scans pass.
  • Exact-head GitHub Actions: 35 success, 3 skipped, none failing or pending.
  • license/cla is absent at this SHA (statuses=[], positive control: success at parent dd5b0d5f), so the complete merge gate is not green.

Verdict: changes requested; this head is not merge-ready. Resolve the declared split-form transition, provide the exact-head §15 witness, and restore the CLA status.

@sonichi sonichi closed this Oct 24, 2026
@sonichi sonichi reopened this Oct 24, 2026
sonichi and others added 2 commits October 24, 2026 01:42
…token or the owner's vault URL key

With every host URL name stripped, a declared agent on the split credential form (a bare secret
in AG2_RELAY_TOKEN_<worker>) had no endpoint and died with "no gateway URL", and that FATAL told
it to set REMOTE_TASK_URL, which the wrapper strips. The agent now gets an endpoint from a source
the owner writes. sparrow_agent_bridge.endpoint() reads AG2_RELAY_URL_<worker>, a vault key
derived from the segment like the token key, only when the token embeds no URL; an embedded URL
wins. gateway_credential_sources.bridge_token_environ(token, url) sets REMOTE_TASK_URL beside a bare
token, and gateway_url_problem refuses anything but an http(s) URL with a host. The declaration
still chooses nothing: a URL the agent's own session writes into worker.json is never read.

A bare secret without the URL key does not start, and the wrapper names the one `vault set` that
fixes it. `sparrow_agent_bridge.py --preflight` lists every declared agent and whether it will
start, never printing a value. The bridge's FATAL for a declared bridge now names the vault keys
and the preflight. Install prints the URL key's `vault set` line, and the managed-mode README and
the protocol doc describe the rule.

Tests: the split form with the URL key works, and without it fails with the diagnostic; a combined
token ignores, and never reads, the URL key; a host URL under any of the four names never reaches
the bridge; a URL written into worker.json is never used; an unusable vault URL is refused by name;
preflight reports each case. The real-chain sentinel runs the combined and split forms for every
host URL name, and the pinned reader shapes are updated.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es and the preflight's failure paths

Diff coverage measures the src/ owner copy, which the new behavior was exercised only through the
package copy. The both-copies owner test now covers bridge_token_environ with and without an
embedded URL, token_embeds_url, gateway_url_problem and wake_environ. The preflight tests add a
missing token, an unreadable declaration, and the default vault reader.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

@keweichen your split-form P1 from review 5637489086 is fixed at 49d5005 (commits 8804cf5, 49d5005). The endpoint now has an agent-owned source, and nothing is inherited from the host. CI is green there: 36 checks pass, the 15 required ones included, and license/cla is success.

  • The source. A URL inside the agent's token wins. For a bare secret, the wrapper (sparrow_agent_bridge.endpoint()) reads AG2_RELAY_URL_<worker>, a vault key derived from the segment exactly like AG2_RELAY_TOKEN_<worker> and written only by the owner. bridge_token_environ(token, url) sets REMOTE_TASK_URL beside a bare token only. gateway_url_problem refuses anything but an http(s) URL with a host, and the refusal names the key. All four host URL names stay stripped. The token's URL wins over the key: for a combined token the key is not even read.

  • Why the vault and not the declaration. worker.json is writable by the agent's own session, so the declaration keeps choosing nothing. A URL there would let the session point its bridge at an endpoint it controls and receive tasks stamped with the host's task-envelope key. A URL field written into worker.json is never read, and a test pins that. The PR body has the full paragraph.

  • Transition.

    • A bare secret with no URL key fails closed with sparrow-agent-bridge: not starting: the token under AG2_RELAY_TOKEN_<worker> is a bare secret and the vault has no AG2_RELAY_URL_<worker>: run vault set AG2_RELAY_URL_ <the agent's gateway URL>, or store the combined https://<gateway>|<secret> token under AG2_RELAY_TOKEN_<worker>.
    • The preflight python3 src/sparrow_agent_bridge.py --preflight prints one line per declared agent, ok: <worker> (gateway from its token | from AG2_RELAY_URL_<worker>) or WILL NOT START: <worker>: <that line>, never printing a value. It exits 1 when any agent will not start.
    • Install prints the URL key's vault set line.
    • The bridge's FATAL for a declared bridge (marked) now names both vault keys and the preflight, not the stripped REMOTE_TASK_URL.
    • The managed-mode README and docs/remote-gateway-protocol.md state the rule. The PR body states the fail-closed consequence and the pre-upgrade preflight.
  • Regressions (tests/sparrowd-agent-bridges.test.py, AgentEndpoint):

    • split form with the URL key works
    • split form without it fails with the diagnostic (no value printed)
    • a combined token ignores the URL key and never reads it
    • a host URL under any of the four names never reaches the bridge, in either form
    • a URL written into the declaration is never used
    • an unusable vault URL is refused by name
    • preflight reports ok, ok and WILL NOT START, plus a missing token and an unreadable declaration

    The real-chain sentinel (tests/declared-bridge-wake-child-credentials.test.py) now runs the combined and split forms for each of the four host URL names, 8 cases. Each requires both the bridge's own URL and the wake child's room-ops URL to be the agent's. A new test covers the declared-bridge FATAL text. The both-copies owner test covers the new owner functions.

  • Mutation: with bridge_token_environ never setting the URL, 4 sentinel cases and 4 wrapper tests fail. With it setting the URL even beside a combined token, the owner test fails.

  • Local runs (load-gated, nice 19): declared-bridge-wake-child-credentials 10/10, sparrowd-agent-bridges 31/31, gateway-credential-sources 145/145, claude-code adapter 239/239, hermetic-env-structural 26/26, test_on_task_wake, credential-contract-golden 37/37, the channel-env, channel-token and room-ops gateway tests, sync_from_src --check in sync, the workspace-resolution lint clean.

The §15 live round trip at the frozen final head is still owed. It runs once the owner gives his word for this sha.

sonichi and others added 2 commits October 24, 2026 02:28
…d, not as a bare secret

A token that starts with a gateway URL but carries no url|secret separator (an encoded or lost
'|') was reported to a declared bridge as a bare secret, and the wrapper would have looked for its
URL key instead. The owner's token_malformed() classifies it. The wrapper refuses it with "a
malformed combined token: store it again as https://<gateway>|<secret> under
AG2_RELAY_TOKEN_<worker>" without reading the URL key, and the bridge's FATAL for a declared bridge
says the same.

Tests: the both-copies owner test covers token_malformed; the wrapper refuses a malformed token by
name and never reads its URL key; the declared bridge's FATAL names it as malformed. With the
classification removed, all three fail.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… refuse a token with no secret

The host's own bridge still decided its missing-separator hint with its own `TOKEN[:4] == "http"`
test, which also hinted for a bare secret such as `httpfoo...`. It now asks the owner's
token_malformed(_RAW). A token with an empty secret (`https://gw|`) passed --preflight and then
exited FATAL in the bridge. The owner's token_secret_empty() now lets the wrapper and the preflight
refuse it by name first.

Tests: the host's bridge hints only for a malformed combined token, not for `httpfoo...`; the
wrapper and the preflight refuse a token with no secret; the both-copies owner test covers
token_secret_empty. With either fix reverted, its regression fails.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current exact head 43d1c5ac1d868930131d4362774233c05c97c7c1 against base 7940f25ff965f51df3273d94f009cb1b771119c0, including the last-minute 43d1c5ac delta.

The previous endpoint-source design is directionally fixed: the split URL key is derived from the agent segment, worker.json cannot choose it, every host URL alias is stripped, and combined tokens win without reading the URL key. The new head also correctly makes an empty-secret combined token fail preflight and centralizes the host bridge's missing-separator hint. I am still requesting changes for three exact-head findings plus the required live gate.

Blockers

  1. [P1] Preserve the canonical URL-shaped opaque bearer in the split form.

    token_malformed() classifies every scheme-prefixed value without a separator as a broken combined token, and endpoint() refuses it before reading the derived URL key. That contradicts the canonical parser's explicit rule that a scheme-prefixed/no-separator value remains an opaque bare secret (shared contract lines 74–94) and its existing pinned valid row URL_SHAPED_BEARER + REMOTE_TASK_URL (test lines 68–97). At this head:

    endpoint(URL_SHAPED_BEARER, owner_url_key) -> malformed combined token
    discover(REMOTE_TASK_TOKEN=URL_SHAPED_BEARER, REMOTE_TASK_URL=owner_url) -> the same token + owner_url
    

    So the earlier split-form compatibility blocker is not fully resolved: a previously supported opaque bearer still stops after this upgrade even though its owner-written URL exists. Do not infer a lost separator solely from the secret's shape; honor the canonical opaque-bearer case when the derived URL is present, or deliberately revise and migrate the shared contract. Add the existing URL_SHAPED_BEARER sentinel to the declared-agent endpoint tests.

  2. [P1] Redact the newly admitted vault URL before the startup log persists it.

    The new path reads AG2_RELAY_URL_<worker>, injects it as REMOTE_TASK_URL, and reaches the unchanged raw interpolation at remote_gateway_bridge.py:7554. The validator permits URL userinfo, query, and fragment; this module already registers those pieces as secrets and owns _redact_url(), but the startup log does not call it. A synthetic production-main run, stopped at refresh_routing before the first network request, printed and persisted:

    [remote-gateway-bridge] starting — gateway=https://url-user-5815:url-pass-5815@gw.example/relay?api_key=query-secret-5815#frag-secret-5815 ...
    registry_contains_query_secret=True
    redacted_form=https://gw.example/relay
    

    The raw sink existed at the parent, but this delta newly gives a declared bare-token bridge an owner-vault URL path to it; at 989fa2b5 that split URL could not reach the process. Log the redacted form and add a split-vault URL sentinel with synthetic userinfo/query/fragment values.

  3. [P2] Reject hostless URLs in both credential forms before preflight says ok.

    The new empty-secret check is correct. The adjacent URL decision remains incomplete: gateway_url_problem() treats text before the first slash as a host instead of parsing the authority, while endpoint() bypasses that validator entirely for combined tokens. At this head, split URL keys https://?missing-host, https://#missing-host, and https://:443/relay all yield gateway_url_problem == '' and preflight exit 0; the corresponding combined forms with |secret also pass. urllib.parse.urlsplit(...).hostname is None for every case, and the production request path cannot use them.

    Since this command is the advertised pre-restart transition gate, a false ok defeats its purpose. Validate a parsed hostname for both forms and pin the hostless adjacent cases.

Required live evidence

The PR body still says the REVIEW.md §15 post-restart round trip has not run at this frozen head. The 8-case subprocess sentinel is useful harness evidence, but approval still requires the author's real restarted-service declared-agent round trip on the final SHA.

Verification / gates

  • Exact-head focused suites: declared wake chain 12/12, wrapper 33/33, credential owner 145/145; the prior-head hermetic structural 26/26 and adapter/install subsets passed, and this delta does not touch those paths.
  • Mutation control: disabling split-URL propagation makes exactly the four split-form wake cases fail while the four combined cases pass, so the new real-chain coverage discriminates.
  • Review checks (hardcoded paths, root artifacts, prose cap) pass.
  • Exact-head GitHub checks are still running with no failure reported at review time; license/cla is successful.

Worst case on upgrade: a supported declared agent stops because its opaque bearer resembles a URL; a credential-bearing vault URL is copied into persistent logs; or the safety preflight blesses a structurally unusable endpoint. Verdict: changes requested; this head is not merge-ready. Re-review after these findings are fixed and the exact-final-head §15 witness is posted.

…e gateway in logs, require a parsed host

Three findings from review at 43d1c5a:

- The shared contract reads a scheme-prefixed token with no separator as an opaque bare secret. The
  wrapper refused it as a malformed combined token before reading AG2_RELAY_URL_<worker>. It now
  reads the URL key first; only with no gateway does it name both remedies (re-store a combined
  token that lost its '|', or set the URL key for an opaque bearer). The owner's helper is now
  token_url_shaped(), documented as never a reason to refuse a token whose gateway is known.
- The vault URL reaches the bridge as REMOTE_TASK_URL, and the startup log printed URL raw,
  userinfo, query and fragment included. The startup line and the token-file gateway-change line
  now go through the module's _redact_url().
- gateway_url_problem() parses the URL and requires a hostname and a valid port, so
  https://?x, https://#x and https://:443/relay are refused. The wrapper and the preflight
  validate a combined token's embedded gateway with the same check.

Tests: a URL-shaped bearer (the contract's URL_SHAPED_BEARER) runs with its URL key and, without
one, is refused naming both remedies; the declared bridge's FATAL names both; the startup log
carries no userinfo, query or fragment of a vault URL; hostless gateways are refused in both forms
by the wrapper and the preflight; the both-copies owner test covers the parsed host check. With
each fix reverted, its regressions fail.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner Author

@keweichen all three findings from review 5637862457 at 43d1c5a are fixed at 97e312b. CI is green there: 36 checks pass, the 15 required ones included, and license/cla is success.

  1. URL-shaped opaque bearer (P1). endpoint() no longer refuses a scheme-prefixed, separator-less token. It reads AG2_RELAY_URL_<worker> first and runs the token as the opaque bearer the shared contract says it is. Only when there is no gateway at all does the wrapper, and the declared bridge's FATAL, name both remedies: re-store a combined token that lost its |, or vault set AG2_RELAY_URL_<worker> … for an opaque bearer. The owner helper is now token_url_shaped(). Its docstring says it is never a reason to refuse a token whose gateway is known, and the host's own bridge's lost-separator hint uses the same owner.
    • New tests in tests/sparrowd-agent-bridges.test.py use the contract's URL_SHAPED_BEARER. With its URL key it runs (REMOTE_TASK_URL = the key's URL, token unchanged). Without one it is refused, naming both remedies, after the URL key was read.
    • The both-copies owner test pins token_url_shaped and token_gateway.
  2. Vault URL in logs (P1). The startup line now logs gateway={_redact_url(URL)}. The token-file gateway-change line redacts both URLs too.
    • New test test_the_startup_log_redacts_a_vault_gateway_url. It is a split form whose vault URL carries synthetic userinfo, query and fragment. It runs the bridge's main() up to refresh_routing and asserts the stderr and every .log under the state tree carry none of the four pieces and do carry gateway=https://127.0.0.1:9/relay.
    • One related sink I have not changed: the routing record the bridge persists under state/ stores "gateway": URL raw and compares it on reload. Redacting it changes that record's identity comparison, so I would like your read on whether it belongs in this PR or in a follow-up.
    • Update: the author scoped it as a follow-up, filed as persisted routing record stores the gateway URL raw (userinfo) #5845.
  3. Hostless URLs (P2). gateway_url_problem() now parses with urllib.parse.urlsplit and requires a hostname and a valid port. The wrapper and the preflight run the same check on a combined token's embedded gateway.
    • Your three cases (https://?missing-host, https://#missing-host, https://:443/relay) are refused in both forms by the wrapper and the preflight (new test_a_hostless_gateway_is_refused_in_both_forms_by_the_wrapper_and_the_preflight).
    • The owner test adds https://user@/relay, a non-numeric port and an out-of-range port, and confirms that a URL with userinfo, port, query and fragment is accepted.

Mutation checks, each fix reverted alone:

  • Refusing a URL-shaped token before reading the URL key: 2 tests fail.
  • Logging the raw URL: the redaction test fails.
  • Going back to the old host check and dropping the embedded-gateway validation: the owner test fails in both copies, and 4 hostless wrapper/preflight cases fail.

Local runs (load-gated, nice 19): gateway-credential-sources 145/145, sparrowd-agent-bridges 35/35, declared-bridge-wake-child-credentials 13/13, hermetic-env-structural 26/26, test_on_task_wake, credential-contract-golden 37/37, both remote-gateway-bridge suites, sync_from_src --check in sync, and the workspace-resolution lint clean.

The PR body's "After review" section covers these commits. The §15 live round trip at the final head is still owed, pending the owner's word for this sha.

@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

REVIEW.md §15 live witness at 97e312b

Head 97e312b1a02dc178e4ea7d1c1e6f602185aecaf8, checked with git ls-remote at the start and again before posting. CI was green at this head (36 checks pass, the 15 required ones included, license/cla success). Merge-base: 7940f25ff965. The head and merge-base trees were staged with git archive into a scratch dir. Every run used env -i PATH=… HOME=<home> USER=… with a scratch HOME and a scratch workspace, under nice -n 19, with the 1-minute load under 10 and the shared test lock. No launchd job, installed gateway, installed sparrowd or live workspace was touched.

Which token each part used

  • (1), (3a) and the synthetic sentinel used synthetic tokens only: a loopback stub, no network beyond it.
  • (2) and (3b) used the real relay token of a test identity (@support-cc.agent:ag2.space). The owner approved one copy of it, at this sha only, into the derived vault key AG2_RELAY_TOKEN_BRIDGE_SUPPORT_CC_AGENT_AG2_SPACE_FD15F8B9. The owner's agent made that copy and paused the identity's own relay for the run (one bridge per identity). It deleted the vault copy afterwards and restored the relay. The witness never read the source file.
  • The test identity's token is the combined https://<gateway>|<secret> form, so the live run took its endpoint from the token. The split form (a bare secret plus AG2_RELAY_URL_<worker>) is covered by the synthetic sentinel below.

(1) install writes the declaration through the owner — PASS (synthetic)

The full install registers and starts a launchd job and a Claude tmux session, which this witness must not do. So the witness drove install's declaration step, cli.wire_bridge(cfg), with a spy on the owner functions. Both trees got identical inputs.

== BASE install
declared: <ws>/agents/witness-5815.agent_ag2.space.bridge/worker.json (sparrowd runs it as worker bridge-witness-5815-agent-ag2-space-4158729c on its next scan; …)
owner calls: none (the owner functions do not exist in this tree)
== HEAD install
declared: <ws>/agents/witness-5815.agent_ag2.space.bridge/worker.json (sparrowd runs it as worker bridge-witness-5815-agent-ag2-space-4158729c on its next scan; …)
if that token is a bare secret, not https://<gateway>|<secret>, also: vault set AG2_RELAY_URL_BRIDGE_WITNESS_5815_AGENT_AG2_SPACE_4158729C <the agent's gateway URL>
owner calls: [('write_bridge_declaration', 'worker.json', '')]
sparrowd load(): accepted, worker bridge-witness-5815-agent-ag2-space-4158729c
5869db35a8c792a3a69adcaa3e460f4975c30752b48264ddc24dcaf955b80467  base.json
5869db35a8c792a3a69adcaa3e460f4975c30752b48264ddc24dcaf955b80467  head.json
cmp: byte-identical
== HEAD install over a foreign file
owner calls: [('write_bridge_declaration', 'worker.json', '<ws>/…/worker.json was not written by install; remove it by hand …')]
file now: {"written_by":"someone else"}

Synthetic sentinel: the real sparrowd → wrapper → bridge → wake child chain — PASS

Setup. The run was the head's sparrowd.py --agent-bridges, against a loopback stub that serves one task. sparrowd's environment held:

  • a synthetic host GATEWAY_TOKEN
  • all four host URL names (REMOTE_TASK_URL, AG2_REMOTE_URL, GATEWAY_URL, RELAY_URL), each pointing at a host URL
  • CLAUDE_CONFIG_DIR=<hostcfg>, holding a synthetic host token in channels/ag2space/.env
  • REMOTE_TASK_ON_TASK set to a script that resolves room ops' gateway(), does one GET with it, and runs scripts/channel-env.sh

An audit hook (sys.addaudithook, from a scratch sitecustomize) logged process starts, every open under <hostcfg>, and the moment the bridge module's code runs. It ran once with a combined token and once with the split form: a bare secret plus AG2_RELAY_URL_<worker>.

combined:
OPEN <wrapper> INJECTED=- path=<hostcfg>/channels/ag2space/.env
EXEC remote_gateway_bridge module INJECTED=1 token_names_in_env=['REMOTE_TASK_TOKEN'] url_names_in_env=[] host_token_value_in_env=0
OPEN <bridge>  INJECTED=1 path=<hostcfg>/channels/ag2space/access.json
START wake.py INJECTED=1 host_token_value_in_env=0 host_url_names_in_env=[]
wake: {"marked": true, "agent_token": true, "gateway_base": "http://127.0.0.1:<stub>/relay", "host_token_loaded": false,
       "room_ops_get_status": 200, "channel_env_rc": 1, "channel_env_named_a_file": false}
stub: 1 GET /relay/v1/agents ua=sutando-room-ops/1 auth_ok=True
split:
EXEC remote_gateway_bridge module INJECTED=1 token_names_in_env=['REMOTE_TASK_TOKEN'] url_names_in_env=['REMOTE_TASK_URL'] host_token_value_in_env=0
wake: {"marked": true, "agent_token": true, "gateway_base": "http://127.0.0.1:<stub>/relay", "host_token_loaded": false,
       "room_ops_get_status": 200, "channel_env_rc": 1, "channel_env_named_a_file": false}
stub: 1 GET /relay/v1/agents ua=sutando-room-ops/1 auth_ok=True
  • In both forms the bridge's gateway is the agent's, never the host's, even with all four host URL names inherited.
  • The bridge module sees only the agent's token. In the split form it also sees the agent's own REMOTE_TASK_URL, read from the vault key.
  • Only the wrapper opens the host .env, through bridge_carried_config.
  • The wake child reads the room as the agent: the agent's bearer and gateway, GET 200. channel-env.sh refuses.

(2) the head's sparrowd runs the declared bridge with the real token, restart, one round trip — PASS

The head's wire_bridge wrote the test identity's declaration into <ws>. The scratch HOME has no login keychain in its search list, so a scratch-PATH security shim answered find-generic-password for that one token key only, from the login keychain by path. Apart from that shim, the shipped path ran unchanged: sparrow_agent_bridge.main → get_vault_key → endpoint() (combined token: no URL key read) → bridge_environ → bridge_token_environ.

[sparrowd] starting 1 worker(s): bridge-support-cc-agent-ag2-space-fd15f8b9
[sparrowd] worker bridge-support-cc-agent-ag2-space-fd15f8b9: started pid <bridge>
[remote-gateway-bridge] starting — gateway=<gateway> provider=remote tasks=<ws>/agents/support-cc.agent_ag2.space/tasks (restored 0 in-flight)
bridge connected: True
EXEC remote_gateway_bridge module INJECTED=1 token_names_in_env=['REMOTE_TASK_TOKEN'] url_names_in_env=[] host_token_value_in_env=0

Restart. SIGTERM went to the sparrowd PID after ps -o command= verified it. sparrowd logged shutdown requested, the bridge logged received SIGTERM — exiting, sparrowd logged all workers stopped, and no scratch process was left. On the next start there was a new sparrowd pid and a new bridge pid, bridge connected: True, and the same EXEC … INJECTED=1 token_names_in_env=['REMOTE_TASK_TOKEN'] url_names_in_env=[] line.

One round trip after the restart. The owner's agent posted one message to the test agent in a test room. The witness wrote the result file atomically.

[remote-gateway-bridge] served task-ea6351e9cc146ff829 lease=lease-…
[remote-gateway-bridge] queued task-ea6351e9cc146ff829
[remote-gateway-bridge] result task-ea6351e9cc146ff829 delivered via DeliveryCore (provider=AG2SpaceResultProvider, backend=DesignAClaimBackend, worker=gateway-result-drain)
[remote-gateway-bridge] archived result for task-ea6351e9cc146ff829
delivered lines for it: 1

Token absence. These are in-process counts only; the value was never printed.

scratch files scanned (logs, state, declarations, audit trace, trees): 6127; token hits: 0
ps -E: processes whose exec-time environment holds the token: []

sparrow_agent_bridge hands the bridge its token inside the process, after exec. So the token is in no process's exec-time environment, the live bridge's included. The audit line above shows it present only as REMOTE_TASK_TOKEN in the bridge module's environment.

(3) uninstall removes the declaration through the owner; sparrowd stops the worker — PASS

Synthetic: remove_bridge_declaration returns (True, '') for this install's file, (False, '') when nothing is there, and refuses and keeps a foreign file. Live, on the running test bridge:

removed: <ws>/agents/support-cc.agent_ag2.space.bridge/worker.json
owner calls: [('remove_bridge_declaration', 'worker.json', (True, ''))]
[sparrowd] worker bridge-support-cc-agent-ag2-space-fd15f8b9: removed — stopping
[remote-gateway-bridge] received SIGTERM — exiting
bridge pid gone

Teardown. sparrowd was stopped by verified PID and no scratch process was left. The owner's agent deleted the vault copy and restored the test identity's relay.

What this proves at 97e312b:

  • install's declaration is byte-identical to main's, and it is written and removed only through the owner functions.
  • The head's sparrowd runs the declared bridge with its token handed in only as REMOTE_TASK_TOKEN, marked GATEWAY_CREDENTIALS_INJECTED=1. Every host token and URL name is stripped. Its endpoint comes from the agent's token or the owner's URL key, never the host's.
  • The wake child acts as the agent and never reloads the host's credential.
  • The bridge authenticates to the real gateway, survives a sparrowd restart, delivers exactly one result in a real round trip, and is stopped when the declaration is removed.

What it does not prove:

  • The full install/uninstall commands' launchd and tmux parts, which this PR does not change.
  • A live split-form run: the test identity holds a combined token. The split form is covered by the synthetic sentinel and the unit tests.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment at 97e312b1. Kewei's three findings from review 5637862457 (at 43d1c5ac) are fixed. I checked each one in the code and ran the tests myself. The §15 witness at this head ran the production path. I am not approving yet, because nobody else has approved at this head. Kewei's CHANGES_REQUESTED at 43d1c5ac is still his latest verdict.

Where this account stands. Its CHANGES_REQUESTED at 2c06c889 stays on the record until an approval replaces it. All of its blocking asks were cleared by 989fa2b5. Ask 3, the §15 round trip, is now supplied (see below). From this account's side the code is ready to approve.

1. URL-shaped opaque bearer: fixed. endpoint() in src/sparrow_agent_bridge.py no longer refuses a token that starts with a scheme but has no separator. A combined token still takes its URL from the token. Any other token, URL_SHAPED_BEARER included, reads AG2_RELAY_URL_<worker>. It is refused only when that key is empty, and then the message names both remedies. token_malformed is gone, replaced by token_url_shaped, and its only remaining uses are hints shown when no gateway was found.

2. Vault URL in logs: fixed for logs. The persisted record is a fair follow-up.

  • The startup line (remote_gateway_bridge.py:7555) and both URLs in the token-file gateway-change line (:2742–2743) now go through _redact_url. gateway-status.json (:3651) already did.
  • I grepped every URL use in remote_gateway_bridge.py, plus event_channel.py, sparrow_agent_bridge.py and the owner. Every other use is a request target (_req, EventChannel, CardPoster), a comparison (_same_origin), or the wake child's environment. --preflight prints no value.
  • One raw sink is left: refresh_routing() writes "gateway": URL to state/owner-routing*.json (:1910). That write is unchanged from base 7940f25f. On base, a combined token whose URL carried userinfo already reached it, so this PR adds one more source rather than a new sink. AG2 Space issues https://<gateway>/relay|<secret>, with no userinfo or query. #5845 tracks it, so I accept it as a follow-up. A cheap way to close it for the newly admitted path: have gateway_url_problem refuse userinfo, query and fragment, which no gateway needs. Note that _redact_url's own docstring says state/ vault-syncs, so #5845 should be fixed and not left open indefinitely. Not an ask for this PR.

3. Hostless URLs: fixed. gateway_url_problem() now parses with urlsplit and requires a hostname and a parseable .port. endpoint() runs the same check on a combined token's embedded gateway, so both forms share one validator. I probed it directly:

  • Refused: https://?missing, https://#x, https://:443/relay, https:///relay, gw.example/relay, ftp://h/r, https://h:99999/r, https://h:abc/r, https://[::1/r.
  • Accepted: https://h/r, https://h:8443/r, http://h/r (http was allowed before too), and also https://u:p@h/relay and …?api_key=x. Userinfo and query are accepted and then redacted in logs, which is why the suggestion under 2 is relevant.

Tests, run at 97e312b1 in a scratch worktree, Python 3.12.15, scratch HOME

  • gateway-credential-sources 145/145, sparrowd-agent-bridges 35/35, declared-bridge-wake-child-credentials 13/13. sync_from_src.py --check: in sync, and the two owner copies are byte-identical.
  • The new tests fail on the old code. I put src/ and packages/ back to 43d1c5ac and kept the new tests:
    • the hostless test fails for all three cases in both forms;
    • both URL_SHAPED_BEARER endpoint tests fail;
    • the startup-log redaction test and the both-remedies FATAL test fail;
    • the owner test errors on token_url_shaped / token_gateway in both copies.
      So 8 failures, 2 failures and 2 errors across the three suites. The tests check behaviour, not source text. The redaction test runs main() up to refresh_routing, so it does not cover the routing record, which matches the follow-up scope.

§15 witness (comment 6386036603): it ran the production path.

  • The head's sparrowd.py ran sparrow_agent_bridge.main → endpoint() → bridge_environ → the real bridge.
  • It used a real test identity's token against the real gateway. It did a SIGTERM restart checked by PID, then delivered exactly one result after the restart.
  • After that it removed the declaration through the owner, and sparrowd stopped the worker.
  • The only stand-ins were a scratch HOME/workspace and a security shim that answered that one keychain key, which I accept for a witness that must not touch the installed service.
  • Its limits are stated: the live run used a combined token, so the split form was covered only by the synthetic sentinel. The launchd/tmux parts of install were not exercised, and this PR does not change them.

Gates at this head: 36 checks pass (license/cla among them, success, 1 status context), 3 skipped, none failing. 0 unresolved threads. Mergeable. reviewDecision CHANGES_REQUESTED.

What it still needs: an approval at 97e312b1 from another reviewer, ideally Kewei re-reviewing against his three findings and the witness. This account will approve once one exists at this head.

— Sutando (susans-MacBook-Pro)

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 97e312b1a02dc178e4ea7d1c1e6f602185aecaf8 against merge-base 7940f25ff965f51df3273d94f009cb1b771119c0.

APPROVE — the three blockers from my review of 43d1c5ac are fixed, the exact-head live gate is satisfied, and I found no scoped code blocker.

Verified:

  • A URL-shaped opaque bearer works in split form: the token remains opaque and the gateway is loaded from the derived vault URL key. Combined URL|token still takes precedence.
  • Gateway URL parsing/host/port validation is centralized in gateway_url_problem() and both endpoint resolution and --preflight delegate to it. Hostless, malformed-port, and malformed-IPv6 inputs fail closed.
  • The startup and valid token-file gateway-change log paths call _redact_url(); the lost-separator diagnostic now distinguishes malformed combined credentials from an intentional URL-shaped bearer.
  • The PR body gives the required transition order: pull, run --preflight, then restart.
  • The exact-head §15 witness in issue comment 6386036603 documents a post-restart task/result round trip through the real declared bridge and DeliveryCore, exactly-once archive behavior, token non-leak checks, uninstall shutdown, and cleanup. I treated this as published live evidence rather than independently rerunning the owner's real-token path.

Local evidence at this head:

  • tests/gateway-credential-sources.test.py: 145/145 pass
  • tests/sparrowd-agent-bridges.test.py: 35/35 pass
  • tests/declared-bridge-wake-child-credentials.test.py: 13/13 pass
  • packages/ag2-sparrow/tests/test_gateway_status.py: pass
  • packages/ag2-sparrow/tools/sync_from_src.py --check: pass
  • git diff --check and scripts/review-checks.sh: pass

Worst-case transition behavior is deliberate and documented: a legacy bare token without a URL vault key now stops rather than guessing, with --preflight required before restart.

Non-blocking residual: _redact_url() still returns its input on parse failure. I reproduced _reload_rotated_token() logging userinfo/query/fragment from a malformed token-file gateway URL. That behavior is also present at the merge-base and the new shared validator prevents such a declared endpoint from starting, so it is not a regression or a reason to hold this PR. Please track a fail-closed redactor follow-up and narrow the PR-body claim that credentials are never logged; issue #5845 already tracks the separate persisted raw routing-URL sink.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 97e312b1. This replaces my change request from 2c06c889 and my comments at 989fa2b5 and 97e312b1.

The bar is met: keweichen (a collaborator with write access) approved this exact head (review 5638861472), and the code is verified.

Evidence, from my review at this head (#5815 (review)):

  • Every one of my earlier asks is cleared: the injected-mark gate on the file and vault readers, owner-exclusive REMOTE_PROACTIVE_TRUST_OK, the hermetic lane, the wake child acting as the agent, and URL names stripped in bridge_environ / wake_environ.
  • Kewei's three findings at 43d1c5ac are fixed: the URL-shaped bearer, _redact_url on every gateway log line, and host/port validation in the single gateway_url_problem. With the sources put back to 43d1c5ac, the new tests fail.
  • The §15 post-restart round trip at this head ran the production sparrowd → bridge path (comment 6386036603).
  • CI: 36 pass, 3 skipped. license/cla success (1 context). 0 unresolved threads.

Follow-up, not blocking: #5845, the raw gateway URL in the persisted routing record. Having gateway_url_problem refuse userinfo, query and fragment would close it for the new path.

— Sutando (susans-MacBook-Pro)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 97e312b (suites run locally: declared-bridge-wake-child-credentials 13 OK, sparrowd-agent-bridges 35 OK, claude_code_adapter 239 passed, gateway-credential-sources 145 OK; vendored copy identical; review-checks clean). The code holds: worker.json (0644) carries only names, paths and the vault key name; the token is read only through vault_intercept.get_vault_key; the gateway URL comes only from the token or the AG2_RELAY_URL_<worker> vault key; --preflight prints names never values (asserted); write_bridge_declaration is temp-file + chmod + os.replace with a failed write leaving nothing behind; foreign file, invalid JSON, list, directory, dangling symlink and unreadable file are each refused untouched; startup/rotation log lines pass URLs through _redact_url.

Requesting changes on one gate only, REVIEW.md lesson 15: the body says the live post-restart witness has not been run, and this changes how sparrowd starts bridges and what the wake child's environment holds — a live path, not approvable on harness proof alone (the same bar my hold on #4196 applies). Run one restart round trip on an affected host that completes a task and I approve at that head.

Should-fix alongside: (1) the body's "the vault, which only the owner writes" is not enforced — the agent session runs as the same user and can write AG2_RELAY_URL_<worker> with security, which is exactly the threat the body gives for keeping the URL out of worker.json; correct the threat model (pre-existing exposure, not a regression). (2) GATEWAY_CREDENTIALS_INJECTED makes channel_token.token_from_vault/token_from_env_file return '' for every variable (src/channel_token.py:~121,~146), so Slack/Telegram/Discord tokens are also closed in a declared bridge's tree — safe, but say so. (3) The upgrade disruption (a declared agent with a bare-secret token and no AG2_RELAY_URL_<worker> stops starting until --preflight) belongs in the release notes. (4) _declaration_owned reads with no size cap while the reader caps at 64 KiB; preflight tracebacks when agents/ cannot be listed.

@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

@qingyun-wu thanks for the review at 97e312b (5638933083). This is a PR body change only: the head is still 97e312b1, so nothing you reviewed has moved.

The §15 gate. The live post-restart round trip ran at this exact head: #5815 (comment). The PR body's old "has not been run" line is replaced by a "Live post-restart witness" section pointing there, with its findings and stated limits.

  • Findings:
    • The head's sparrowd ran a declared bridge for a test identity with its real relay token, handed in only as REMOTE_TASK_TOKEN and marked injected. No host token or URL name reached it.
    • The bridge connected to the real gateway and was restarted by verified PID.
    • One live message was served, and exactly one delivered line was logged for it.
    • Removing the declaration stopped the worker.
    • The token appeared 0 times in 6,127 scratch files and in no process's exec-time environment.
  • Limits: one test identity on one host, with a combined token, so the split form is covered only by the synthetic chain run and unit tests. The install/uninstall launchd and tmux parts, which this PR does not change, were not run.

Should-fixes:

  1. Threat model. You're right that it isn't enforced. The body now has a "What the vault does and does not enforce" paragraph: AG2_RELAY_URL_<worker> is owner-written by convention (install prints the vault set line, and nothing in the bridge writes it). A same-user session could write it with security, exactly as it could rewrite AG2_RELAY_TOKEN_<worker>, a pre-existing exposure. What this PR changes is that worker.json, which the session writes routinely, no longer carries anything the bridge acts on. The residual risk is no worse than the token key itself and does not reach the host's credentials. The "which only the owner writes" wording is gone.
  2. Every channel token is closed. The body now says that under GATEWAY_CREDENTIALS_INJECTED, channel_token.token_from_env_file and token_from_vault (src/channel_token.py L123, L148) return '' for every variable, so the host's Slack, Telegram and Discord tokens are unreadable from .env and the vault in a declared bridge's tree. It also states the limit: the environment tier still reads, and a declared bridge inherits sparrowd's environment apart from the stripped gateway names. So a host channel token exported into sparrowd's own environment would still be visible there.
  3. Release notes. There is a new "Release notes" section. A declared agent with a bare-secret token and no AG2_RELAY_URL_<worker> stops starting after sparrowd restarts. Run python3 src/sparrow_agent_bridge.py --preflight after pulling and before restarting sparrowd, then the vault set line it prints. Agents with AG2 Space-issued combined tokens are unaffected.
  4. _declaration_owned size cap and the preflight traceback. These are code, so to keep this head frozen they are added to follow-up persisted routing record stores the gateway URL raw (userinfo) #5845 with the exact functions and lines, and referenced from the body's new "Follow-ups" section: persisted routing record stores the gateway URL raw (userinfo) #5845 (comment)

@sonichi
sonichi requested a review from qingyun-wu October 24, 2026 17:34

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 97e312b (unchanged head; body-only update). The REVIEW.md lesson 15 gate is met by the live witness in issuecomment-6386036603: the head's sparrowd ran the declared bridge for a test identity with its real relay token handed in only as REMOTE_TASK_TOKEN and marked injected, no host token or URL name reached it, the bridge connected to the real gateway, was restarted by verified PID, served one live message with exactly one delivered line, removing the declaration stopped the worker, and the token appeared 0 times in 6,127 scratch files and in no process's exec-time environment; the synthetic sentinel covers the split-token form with all four host URL names inherited. Stated limits (one identity, one host, combined token; install/uninstall launchd and tmux parts not run) are the ones I expected. The threat-model paragraph now says what the vault does and does not enforce (same-user writes to AG2_RELAY_URL_<worker> are a pre-existing exposure, no worse than the token key, not reaching host credentials); the body states that every channel token is closed under GATEWAY_CREDENTIALS_INJECTED and the environment-tier limit; the release notes carry the upgrade step (--preflight before restarting sparrowd); the _declaration_owned cap and the preflight traceback are in #5845. Code approved earlier as sound; approval at this head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants