Skip to content

feat(credentials): one owner for gateway credential precedence and masking (prerequisite for #5665) - #5698

Merged
sonichi merged 27 commits into
mainfrom
fix/credential-policy-owner
Oct 22, 2026
Merged

sonichi merged 27 commits into
mainfrom
fix/credential-policy-owner

Conversation

@sonichi

@sonichi sonichi commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Priority

Level: high
Reason: a prerequisite for #5665. Its credential handling was split between the live bridge and the Hatch adapter, so each fix missed a site; this gives that policy one owner first.

Closes

N/A. This is the prerequisite agreed on #5665 (option A): #5665 (comment)

Stacked PR

Merge order: A → B → C.

After A and B merge, this PR retargets main. #5665 depends on this PR. After this merges, #5665 rebases onto it, removes its private copies of this policy (the bridge's and the Hatch adapter's), fixes its own findings 3 and 6, and then runs the final-SHA tests and the §15 witness.

Split again at 4b0568e: the sweep is two stacked PRs

At 355 files, #5703 was itself past the 300-file limit of gh pr diff, so the sweep is now two PRs. There was no force-push.

Split at 28b2423: the test-hermeticity sweep moved to #5703

At 5949fcf this PR changed 371 files, past the 300-file limit of CI's gh pr diff. Most of them were the repo-wide test-hermeticity sweep.

  • What moved. The sweep now lives on its own branch, test/hermetic-env, and its own PR, test: one hermetic environment for every credential-touching test #5703, based on main. It carries tests/_helpers/hermetic_env.py, credential_scope.py, sentinel_audit.py, the structural and sentinel tests, and 348 test conversions that do not depend on this PR's code.
  • The fix that came with it. tests/skill-symlink-ephemeral-target.test.py no longer derives its durable fixtures from Path.home(), which is now a temp directory.
  • How this branch was rebuilt. There was no force-push. test/hermetic-env was merged into this branch (merge commit 28b2423), and the base was changed to test/hermetic-env.
  • What the diff shows now. Only this PR's own work: 18 files.
  • What stays here.
    • The credential owner and its copies, and bounded_file_read.
    • The bridge, Hatch, census and sync changes.
    • tests/gateway-credential-sources.test.py, and the sentinel case that audits it.
    • The Hatch tests for --no-service and systemd behaviour that only this PR adds.
  • Tree check. The tree at 28b2423 equals 5949fcf except for the skill-symlink-ephemeral-target fix.
  • Tests at 28b2423. Each ran under env -i with a temp HOME and USER:
    • structural: 8/8;
    • sentinels: 10/10;
    • gateway-credential-sources: 120/120;
    • skill-symlink-ephemeral-target: 9/9;
    • drift: PASS.

At 5949fcf: no host user site in any child; scope derived from the code that reads credential names (reviews 5564207460, 5564171259)

Blocker 1: the host user site defeated the sentinel audit. hermetic_env carried PYTHONUSERBASE, so a host .pth ran before sentinel_audit installed its hook. A real sentinel read and "0 leaks" looked the same.

  • Every child now gets PYTHONNOUSERSITE=1, and PYTHONUSERBASE is not forwarded. The user site is never turned back on.
  • Coverage still works through a controlled bootstrap. When the coverage-runner variables are set (SUTANDO_TEST_SUBPROCESS_COVERAGE=1 or COVERAGE_*), the parent imports coverage and adds its install directory to PYTHONPATH. A PYTHONPATH directory runs no .pth file.
  • tests/ag2-sparrow-drift.test.py launches python -m coverage run children. Under env -i with only the CI-shaped variables (SUTANDO_TEST_SUBPROCESS_COVERAGE=1 PYTHONFAULTHANDLER=1, parent under coverage run), it passes. With the bootstrap removed, the child fails with No module named coverage.
  • New UserSite cases in tests/hermetic-env-sentinels.test.py plant a synthetic user-site .pth that copies the sentinel token file:
    • from an outer PYTHONUSERBASE, it must not run, and hermetic_env must not carry the variable;
    • from the temp HOME's own user site, it must not run;
    • as a positive control, the same plant does run once a child turns the user site on.

Blocker 2: bee-actions, and the scope behind it.

  • tests/bee-actions.test.py now isolates at module level. _run_room() overlays its fixture with patch.dict(os.environ, hermetic_env(**e), clear=True), so an outer GATEWAY_*/RELAY_* value can no longer outrank the fixture's REMOTE_TASK_TOKEN.
  • New case test_outer_gateway_selectors_never_reach_a_room_request. Sentinel GATEWAY_TOKEN/RELAY_TOKEN and GATEWAY_URL/RELAY_URL are set, with the URLs aimed at the localhost stub. For post-room and register-room, the stub must see only Bearer tok-gw and no sentinel path.
  • The sentinel audit also runs tests/bee-actions.test.py.
  • Scope is derived, not listed. tests/_helpers/credential_scope.py scans src/, skills/ and packages/ for modules whose code (not comments or docstrings) holds a GATEWAY_/RELAY_/REMOTE_TASK_/AG2_REMOTE_ name as a string constant. Today that is 16 modules. A test that names one by file, import or dotted path is in scope. This brought in 205 test files, all converted: each isolates at module level, and its child envs come from isolated_child_env(). The modules behind them are health-check.py (most), task-progress notify.py, runtime-health.py, runtime-api dispatcher.py, channel_token, ensure-cron-room.py, bee_actions.py and room_ops _gateway.py.

Non-blocking, included.

  • Each sentinel case asserts its target's rc is 0.
  • VALUES is now the fixed identity names plus every token, URL and identity name the same scan finds. A case pins that the scan is non-empty and covered.
  • The PYTHONUSERBASE follow-up from 5564171259 is closed by blocker 1.

Revert checks (each fails as expected, then passes again when restored).

  • hermetic_env restored to b3d5c3f: both user-site cases FAIL ("a host .pth read the sentinel token file before the audit hook"). This is the reviewed repro.
  • PYTHONUSERBASE forwarded again, with PYTHONNOUSERSITE kept: FAIL (PYTHONUSERBASE present in the child env).
  • PYTHONNOUSERSITE dropped: FAIL.
  • _run_room back to patch.dict(os.environ, e): FAIL on both verbs.
  • bee-actions and one converted health-check file restored to b3d5c3f: the structural tree test FAILS (unisolated, copy).
  • Derived reference pattern removed from the structural scope: FAIL (test_scope_is_derived_from_the_modules_that_read_credential_names).

Counts. All runs used env -i with a temp HOME and USER, one file at a time.

  • Structural: 8/8.
  • Sentinels: 10/10.
  • bee-actions: 27/27.
  • Drift: passes.
  • All 205 newly scoped files, before and after conversion: 203 pass both times. Two fail identically before and after on this host (skill-symlink-ephemeral-target, start-cli-model-pin); both are green in CI at b3d5c3f.
  • ruff clean, git diff --check clean, review-checks PASS.
The 205 newly in-scope test files
  • tests/a-fallback-hits-probe.test.py
  • tests/agents-md-sync.test.py
  • tests/bee-actions.test.py
  • tests/bridge-skill-path-resolution.test.py
  • tests/briefing-all-clear-verified.test.py
  • tests/channel-token-relay-quoting.test.py
  • tests/channel-token-vault-fallback.test.py
  • tests/claude-hooks-autofix.test.py
  • tests/cli-auth-failure-needs-login.test.py
  • tests/cli-wedge-health-probe.test.py
  • tests/codex-scheduler-import-without-plistlib.test.py
  • tests/core-health-verdict.test.py
  • tests/core-input-watch.test.py
  • tests/core-model-pin-probe.test.py
  • tests/core-restart-heartbeat-and-cron-hold.test.py
  • tests/core-supervisor-relay.test.py
  • tests/cron-notify.test.py
  • tests/daily-punctuality-completion-sentinels.test.py
  • tests/discord-post-gate-wiring.test.py
  • tests/discord-readers-429.test.py
  • tests/discord-token-delegation.test.py
  • tests/engine-conflict-resolve.test.py
  • tests/gateway-age-helpers-overflow-safe.test.py
  • tests/gateway-connected-requires-last-ok.test.py
  • tests/gateway-serving-shared-owner.test.py
  • tests/git-binary-resolution.test.py
  • tests/github-status-authority.test.py
  • tests/health-check-alert-history-legacy.test.py
  • tests/health-check-alert-history-prune.test.py
  • tests/health-check-artifact-rebuilt-under-process.test.py
  • tests/health-check-bodhi-dist.test.py
  • tests/health-check-bridge-launcher-dupe.test.py
  • tests/health-check-bridge-log-content.test.py
  • tests/health-check-bridge-log-freshness.test.py
  • tests/health-check-bridge-probe-coverage.test.py
  • tests/health-check-bridge-skip.test.py
  • tests/health-check-carrier-set-enforced.test.py
  • tests/health-check-claude-hook-registration.test.py
  • tests/health-check-claude-task-notifier.test.py
  • tests/health-check-codex-presence.test.py
  • tests/health-check-codex-task-notifier.test.py
  • tests/health-check-comm-sweep-freshness.test.py
  • tests/health-check-commits-behind-shallow.test.py
  • tests/health-check-community-support.test.py
  • tests/health-check-compiled-artifact-stale.test.py
  • tests/health-check-context-read-budget.test.py
  • tests/health-check-conversation-server-pin-composes.test.py
  • tests/health-check-core-quota.test.py
  • tests/health-check-core-request-rejections.test.py
  • tests/health-check-core-supervisor.test.py
  • tests/health-check-credential-proxy-drivability.test.py
  • tests/health-check-credential-proxy-staleness.test.py
  • tests/health-check-cron-runner.test.py
  • tests/health-check-cron-schedule-liveness.test.py
  • tests/health-check-daily-punctuality.test.py
  • tests/health-check-dangling-symlinks.test.py
  • tests/health-check-disk-space.test.py
  • tests/health-check-dynamic-loop-freshness.test.py
  • tests/health-check-emit-task.test.py
  • tests/health-check-engine-revision-drift.test.py
  • tests/health-check-fix-launchd-label.test.py
  • tests/health-check-gateway-lane-verdict.test.py
  • tests/health-check-gateway-outage-duration.test.py
  • tests/health-check-gateway-probe-unknown.test.py
  • tests/health-check-gateway-stale-lane.test.py
  • tests/health-check-gateway-transient-outage.test.py
  • tests/health-check-git-stdin-no-pipe.test.py
  • tests/health-check-git-stdin-pipe-pool.test.py
  • tests/health-check-held-no-consumer.test.py
  • tests/health-check-hitl-archive.test.py
  • tests/health-check-host-subtrees-self.test.py
  • tests/health-check-import-without-plistlib.test.py
  • tests/health-check-index-trend-call-site.test.py
  • tests/health-check-index-trend-unavailable.test.py
  • tests/health-check-legacy-notes-divergence.test.py
  • tests/health-check-live-tree-drift.test.py
  • tests/health-check-lstart-blank-line.test.py
  • tests/health-check-memory-dir-override.test.py
  • tests/health-check-memory-dir-siblings.test.py
  • tests/health-check-memory-freepct.test.py
  • tests/health-check-memory-index-trend.test.py
  • tests/health-check-memory-pressure.test.py
  • tests/health-check-memory-sync-vault.test.py
  • tests/health-check-memory-sync.test.py
  • tests/health-check-migrate-reader-contract.test.py
  • tests/health-check-names-workspace.test.py
  • tests/health-check-notes-split-brain.test.py
  • tests/health-check-notify-gateway.test.py
  • tests/health-check-notify-slack.test.py
  • tests/health-check-optional-service-pin-composes.test.py
  • tests/health-check-orphaned-results-skip-markers.test.py
  • tests/health-check-orphaned-results.test.py
  • tests/health-check-outbox-parked.test.py
  • tests/health-check-pane-key-fence.test.py
  • tests/health-check-per-host-config-backup.test.py
  • tests/health-check-phone-server-restart-safety.test.py
  • tests/health-check-pin-covers-every-prescription.test.py
  • tests/health-check-pin-orphan-surfaces.test.py
  • tests/health-check-pin-veto-survives-later-checks.test.py
  • tests/health-check-pin-vetoes-voice-kickstart.test.py
  • tests/health-check-pinned-proxy-stays-live.test.py
  • tests/health-check-pool-advertisement.test.py
  • tests/health-check-pool-suspended.test.py
  • tests/health-check-pool-watchers-all-tracked.test.py
  • tests/health-check-probe-drain.test.py
  • tests/health-check-probe-names-its-subject.test.py
  • tests/health-check-proxy-pin-withdraws-restart-advice.test.py
  • tests/health-check-punctuality-completed-unpublished.test.py
  • tests/health-check-punctuality-dispatch.test.py
  • tests/health-check-punctuality-scores-dispatch.test.py
  • tests/health-check-quarantine-skip-markers.test.py
  • tests/health-check-quota-account-identity.test.py
  • tests/health-check-quota-telemetry.test.py
  • tests/health-check-recover-core.test.py
  • tests/health-check-recover-cron.test.py
  • tests/health-check-recovery-telemetry.test.py
  • tests/health-check-root-tidy-personal-assets.test.py
  • tests/health-check-secret-scanner-mode.test.py
  • tests/health-check-session-cron-stamp.test.py
  • tests/health-check-severe-status-glyph.test.py
  • tests/health-check-skill-junctions.test.py
  • tests/health-check-skill-symlinks-live.test.py
  • tests/health-check-skill-symlinks.test.py
  • tests/health-check-skills-driver-drift.test.py
  • tests/health-check-slack-unfurl.test.py
  • tests/health-check-stale-proactive-backlog.test.py
  • tests/health-check-stuck-loop.test.py
  • tests/health-check-summary-warnings.test.py
  • tests/health-check-supervised-bridge-restart.test.py
  • tests/health-check-supervised-watcher-not-orphan.test.py
  • tests/health-check-sutando-app-absent.test.py
  • tests/health-check-sutando-app-electron.test.py
  • tests/health-check-sutando-app-hotkeys.test.py
  • tests/health-check-symlink-fix-reachable.test.py
  • tests/health-check-sync-conflicts-unmerged.test.py
  • tests/health-check-task-claim-age.test.py
  • tests/health-check-task-queue-pool-aware.test.py
  • tests/health-check-task-queue-vanish.test.py
  • tests/health-check-task-watcher-standby-only.test.py
  • tests/health-check-task-watcher.test.py
  • tests/health-check-telegram-poll-conflict.test.py
  • tests/health-check-twilio-gate.test.py
  • tests/health-check-unread-watcher.test.py
  • tests/health-check-user-defined-checks.test.py
  • tests/health-check-vault-manifest.test.py
  • tests/health-check-vendored-resolver-env.test.py
  • tests/health-check-voice-config.test.py
  • tests/health-check-voice-dep-detail.test.py
  • tests/health-check-voice-guarded-fix.test.py
  • tests/health-check-voice-pin-suppression.test.py
  • tests/health-check-warn-suppression.test.py
  • tests/health-check-web-client-port.test.py
  • tests/health-check-workspace-root-tidy.test.py
  • tests/health-check-workspace-wiring.test.py
  • tests/health-slack-remedy-tofu.test.py
  • tests/health-sutando-app-warn-names-watchdog.test.py
  • tests/health-task-queue-inflight.test.py
  • tests/heartbeat-freshness-bounds.test.py
  • tests/lint-hermetic-bridge-tests.test.py
  • tests/lint-resolver-stub-arity.test.py
  • tests/live-tree-drift-reference.test.py
  • tests/local-core-freshness-bounds.test.py
  • tests/local-task-protocol-write.test.py
  • tests/memory-index-integrity.test.py
  • tests/morning-briefing-src-dir-wiring.test.py
  • tests/node-runtime-resolve.test.py
  • tests/onboarding-status-check.test.py
  • tests/pending-questions-stamp-under-state.test.py
  • tests/pool-suspension-shared-owner.test.py
  • tests/proactive-destination.test.py
  • tests/proactive-loop-memory-index-budget.test.py
  • tests/process-pins.test.py
  • tests/punctuality-dead-job-not-green.test.py
  • tests/punctuality-stale-lane-does-not-shadow.test.py
  • tests/recovery-issues-http.test.py
  • tests/review-checks-py.test.py
  • tests/runtime-api-agents-view.test.py
  • tests/runtime-api-dispatcher.test.py
  • tests/runtime-api-human-action.test.py
  • tests/runtime-api-identity-view.test.py
  • tests/runtime-api-runtime-view.test.py
  • tests/runtime-api-server.test.py
  • tests/runtime-api-task-binding-cli.test.py
  • tests/runtime-api-tasks-view.test.py
  • tests/runtime-health-ag2space-station.test.py
  • tests/runtime-health-atomic-writes.test.py
  • tests/runtime-health.test.py
  • tests/service-sources-covers-launchd.test.py
  • tests/session-cron-config-drift.test.py
  • tests/session-runtime.test.py
  • tests/skill-hooks-discovery.test.py
  • tests/skill-symlink-ephemeral-target.test.py
  • tests/slack-bridge-heartbeat-wedge.test.py
  • tests/slack-bridge-write-task.test.py
  • tests/slack-context-first-ungated.test.py
  • tests/start-cli-model-pin.test.py
  • tests/startup-ceremony-gate.test.py
  • tests/sutando-config-python-resolution.test.py
  • tests/task-progress-skill.test.py
  • tests/task-progress-step.test.py
  • tests/task-workstreams.test.py
  • tests/vault-intercept.test.py
  • tests/voice-agent-kill-path-gate.test.py
  • tests/watcher-identity.test.py
  • tests/watcher-sentinel-naming.test.py

At e69284d: one allowlist for every test that reaches credential discovery (reviews 5563848156, 5563980203)

Blocker 1, and the class behind it. tests/_helpers/hermetic_gateway.py overlaid its values onto the outer environment, so REMOTE_TASK_TOKEN_FILE and REMOTE_TASK_CHANNEL_DIR reached the real bridge import. That was the second instance of one pattern: a test that imports or runs the bridge, Hatch, room_ops' gateway client or the credential owner, and builds its environment by overlaying onto the outer one, copying it, or copying it minus a denylist. One commit removes the pattern repo-wide.

Shared helper: tests/_helpers/hermetic_env.py. It is the only place such an environment is built.

  • hermetic_env(home=None, **extra) starts from nothing. It carries PATH, LANG, TMPDIR and the suite runner's coverage controls, plus the process essentials (SYSTEMROOT and similar) on Windows only. It adds a temp HOME, a fixed USER=hermetic-test, and then the synthetic values.
  • isolate_os_environ(**extra) swaps this process's environment for that one through patch.dict(os.environ, …, clear=True).
  • isolated_child_env(**extra) gives an already isolated process's own environment to a child. It refuses to run before isolation.

Converted: 145 files.

  • The two shared harnesses: hermetic_gateway (isolate, then import) and gateway_relay (its bridge children get hermetic_env).
  • 143 test files under tests/, packages/ag2-sparrow/tests, src/remote-gateway-bridge.test.py and skills/agent-room-ops/test_room_ops.py. Each isolates at module level before any of those modules runs.
  • Every dict(os.environ…), {**os.environ…} and denylist comprehension is now hermetic_env(...). This includes the three sites in 5563980203:
    • test_wheel_contents.py used a host denylist;
    • gateway-outage-recovery (:618) copied the host environment;
    • gateway-relay-result-no-ceiling (:1240) copied the host environment.
  • setUp snapshot/restore pairs became patch.dict(clear=True). Hatch's py_env() sets a fixed USER.
  • One follow-on edit: gateway-archive-the-sent-generation's checker self-test now names the host home as hermetic_gateway.HOST_HOME, since Path.home() is the temp one.

Structural check: tests/hermetic-env-structural.test.py.

  • Scope: every test file that names the bridge, Hatch, room_ops' gateway or the credential modules, in tests/, packages/*/tests, src/*.test.py and skills/**/test_*.py. The helper and the audit instrument are exempt.
  • It flags:
    • os.environ.copy(), dict(os.environ…), {**os.environ…}, f(**os.environ), or a dict comprehension over os.environ;
    • env=None or env=os.environ passed to a subprocess;
    • an env parameter that defaults to None and is handed to a spawn without being rebuilt;
    • any subprocess, module loader or import of those modules in a file that never isolates. room_ops' skill modules count when imported by bare name.
    • such code at module level that runs before the isolation call.
  • A spawn whose env comes from the helper counts as covered, and so does code after an isolation call in the same function.
  • The self-test fires every rule on a flagged sample. It also covers a bridge loaded by path, a skill module imported by bare name, and code under test spawning with an inherited env.
  • Reverting any of the 145 conversions to 28130f9 makes the check fail (145/145).

Regression: tests/hermetic-env-sentinels.test.py.

  • Each case runs a real entry point under an audit hook (tests/_helpers/sentinel_audit.py).
  • The outer environment carries:
    • sentinel REMOTE_TASK_TOKEN_FILE, AG2_DEVICE_ENV, REMOTE_TASK_CHANNEL_DIR, and a CLAUDE_CONFIG_DIR holding channel .env files;
    • marked values for REMOTE_TASK_TOKEN, AG2_REMOTE_TOKEN, REMOTE_MEDIA_HS_TOKEN, GATEWAY_TOKEN, RELAY_TOKEN, MATRIX_AS_TOKEN, the four URL keys, GATEWAY_INSTANCE, AGENT_MXID, AGENT_ID, SUTANDO_INSTANCE_ID, SUTANDO_WORKER_ID, SUTANDO_WORKER_SEAT and SPARROW_HA_OWNER.
  • A case fails if any of these happens:
    • a sentinel file is opened;
    • anything under the sentinel channel name is opened;
    • a marked value reaches a child's environment, or the harness's own environment at a gateway, Hatch, room_ops or credential import or at exit.
  • The gateway-import case runs once with the path selectors alone. That keeps the reviewer's file-read repro visible, because an instance value steers discovery past the files.
Case Fails on revert of What the revert reads or leaks
hermetic gateway real import hermetic_gateway.py the token file and the selected channel .env are opened (path-only run); 15 of the 21 sentinels (the two path selectors it kept and 13 values) are present at the bridge import
relay harness (gateway-voice-outcome-contract) hermetic_gateway.py the same 15 at import and exit; the path-only revert opened the channel .env 302 times and handed both path selectors to 54 bridge children
Hatch suite test_hatch_adapter.py all 21 sentinels in child envs and at import; an instance-named state file opened
credential-owner suite gateway-credential-sources.test.py the token file, the device env and the channel .env are opened; all 21 sentinels in child envs
room_ops entry points agent-room-ops-context-rename, room-ops-say-extra-content, turn-ledger-stop-gate, each alone all 21 at the room_ops import; turn-ledger's hook children carry 20

Reverting only gateway_relay.py does not fail the relay case, because its parent test has already isolated the environment it copies. room_ops_gateway_channel_env and room_ops_gateway_vault set every selector themselves, so they are not the room_ops cases.

Measured across the class. Every executing in-scope test file was run under the same audit, with the path selectors only. At 28130f9, 104 of 141 opened a sentinel file or handed one to a child. At this head, 0 of 141 do. With the value and identity sentinels added, 139 of 141 are clean. In sci-notify-reviewers and test_signal_exit_log the marked values stay in the test process's own environment, though every child they start gets hermetic_env and neither imports gateway code in-process. Both are in the follow-up below.

Counts.

  • Each file was run on its own with python3 under env -i, with a temp HOME and USER set. That covers the 169 touched or candidate test files.
  • 166 pass. The 3 that fail also fail identically at 28130f9 on this host:
    • agent-room-ops-read-redaction and import-claude-context-digest-parts (no requests module for detect-secrets);
    • health-check-fix-down-bridges case f (a live slack-bridge on the host).
  • sparrowd-c0 overflows the AF_UNIX path limit under the long scratch TMPDIR. With a short TMPDIR it passes at both heads.
  • Suite counts:
    • gateway-credential-sources 120
    • test_hatch_adapter and ag2-sparrow-hatch-adapter 195 each, 2 skipped
    • credential-contract-golden 37/37
    • room_ops_gateway_vault 9, room_ops_gateway_channel_env 16, gateway-proactive-room-from-channel-env 11
    • both remote-gateway-bridge all green
    • hatch-reply-real-drain 6, test_vault_token_tier 7, test_env_token_quotes 15, test_gateway_status ALL PASS
    • test_adapter_boundaries 8, test_room_ops 173, gateway-outage-recovery 76
    • test_wheel_contents 4 PASS lines
    • hermetic-env-structural 7, hermetic-env-sentinels 5
  • Both sync checks, test_no_drift, gen-src-map --check, scripts/review-checks.sh (PR diff piped in), git diff --check, ruff check . and lint-hermetic-bridge-tests pass.

CI at e69284d. 33 pass and 4 skip. python standalone tests (5/9) fails on tests/ag2-sparrow-drift.test.py alone, and the tsc + tests aggregate fails because of it.

  • Under the coverage runner, that test's child runs python -m coverage. coverage is installed in the runner's user site-packages, which the temp HOME hides, so the child fails with No module named coverage. I reproduced this locally at e69284d.
  • A follow-up is ready and not pushed, because this round is limited to one push:
    • hermetic_env keeps the host's PYTHONUSERBASE (an interpreter package path, not a credential selector). With it, the drift test passes under the coverage child.
    • The structural rule stops accepting a helper-built spawn env as cover in a test file. That cover now applies only to tests/_helpers libraries, so a test file must isolate its own process.
    • The two files above are converted. Both then probe clean.

Blocker 2, the §15 exact-head witness, is owner-gated and still pending.

At d485bce: every candidate name, a package-wide guard, and a failed install that says so (reviews 5563658019, 5563643767)

One commit fixes 5563658019's two blockers and 5563643767's N1 and N2.

Closed-world rules, each with its single enforcement point (current)

  1. Credential names: credential_name() is the only name verdict. _name_positions() hands it every candidate span. Before each = or : that does not start ://: every suffix of the text back to the start of the line, and the quoted key (any characters, escaped quotes included). It judges the whole span, whose folded text holds every shorter suffix's, plus each suffix that starts where a pw/pat word can (the only way a suffix judges differently; pinned against a brute-force check of every suffix). For each flag: the run to a blank or =, and the shell word with its quotes removed, also when the quote opens before the dashes. If any candidate is accepted, the line is withheld. The regexes only locate positions. _HEADER_RE (an Authorization: or bearer value) and _ENVIRONMENT_DOUBT_RE (environment directives) also decide, and both only add withholding.

  2. Unit state: _unit_props() is the only parser. This is unchanged from 7706cb0.

  3. Guard: _systemctl() is the only place systemctl is named. The rule runs over every package module, gateway_credentials.py and gateway_credential_sources.py included. Exactly one _systemctl and one _systemd_run exist in the package, each a top-level def in adapters/hatch/cli.py; only that _systemctl may hold the "systemctl" literal. The guard flags:

    • an import under another name (from .cli import _systemctl as run, _systemd_run as run);
    • a rebinding (run = _systemctl, run = cli._systemctl);
    • a parameter or keyword of either name;
    • any other def or class of either name (a method, a nested def, a same-named function in another module, a second top-level def);
    • either name in any string literal, constant-built ones included (getattr(_cli, "_systemctl"), "_system" + "ctl");
    • any use that is not a direct call by the canonical name: _systemctl with one literal tuple, _systemd_run on a literal program.

    No dataflow is followed.

Findings map (5563658019, 5563643767)

Finding Fix Pinned by
5563658019 B1: pa ss: synth-only-value and run --"pa ss" synth-only-value printed through the real _diff Rule 1: every suffix to line start, and the dequoted shell word test_every_key_position_is_judged_whole_by_credential_name (3 copies; unquoted assignment, colon and flag forms for every separator, space included); test_the_real_hatch_diff_judges_every_complete_key (real _diff, empty registry injected; the two exact repro lines plus "--pa ss" and --pa" "ss pinned to <text withheld…>); test_a_name_position_judges_every_suffix_of_its_line (3 copies)
5563658019 B2: the credential owners were skipped before _systemd_findings() Rule 3 runs over every module test_the_systemd_rule_covers_every_package_module: shipped package []; a synthetic subprocess.run(["systemctl", *rest]) appended to gateway_credentials.py, gateway_credential_sources.py, remote_gateway_bridge.py or keepalive.py in a copied package is flagged in that module
5563658019 B2: from .cli import _systemctl as run / _systemd_run as run unflagged; any _systemctl def in cli.py exempt Rule 3: aliases, rebinding, redefinition and string names flagged; one qualified helper test_every_non_literal_systemctl_argument_is_flagged (11 new repros); the package test (a second _systemctl in keepalive.py, a _systemctl method in cli.py)
5563658019 B2: positive fixtures were only truthy after prepending HELPER each positive fixture must yield a finding located in the appended source Guard.appended(); the helper alone in cli.py is pinned to []
5563643767 N1: install stopped the unit, then a malformed second show left it stopped silently install prints error: service: this install stopped ag2-sparrow.service and did not start it again, …; it stays stopped until you run: systemctl --user start ag2-sparrow.service, skips the Sparrow check and exits 1 test_install_that_stopped_the_service_and_then_lost_its_state_fails_with_the_start_command (only stop and daemon-reload reach the manager; rc ≠ 0; the line and command on stderr)
5563643767 N2: "the only verdict" now "the only name verdict", in the rules above and in the credential_shapes docstring

Known limit (unit state). If install has already stopped the unit and the second state query then comes back malformed, install does not restart it on an unverified state. It prints error: service: this install stopped ag2-sparrow.service and did not start it again …, followed by the command systemctl --user start ag2-sparrow.service, skips the Sparrow check and exits 1.

Stated limits.

  • Rule 1: an unquoted flag split by an ASCII blank (run --pa ss v) is two shell words, so --pa is judged alone and prints. A bare token that no source holds and no credential name precedes still prints.
  • Rule 3: a runner name assembled at runtime from non-constant pieces (getattr(_cli, a + b)) is outside what an AST guard sees.

Cost of rule 1. Over-withholding is accepted. In the shipped hook script, the prose value of REMAINING_WORK_NOTE= is now <withheld>; the same 2 of 176 lines are withheld whole as at 7706cb0. A 6,192-character line with 400 = masks in 0.08 s.

At 28130f9

Review 5563772408 blocker 1: the real-sink harnesses no longer copy the host environment minus a denylist. RealSinks.hatch_diff(), install_service() and the detached-install case build theirs with hermetic_env() (PATH, LANG, TMPDIR, a temp HOME, USER, plus the synthetic values each sets); test_hatch_adapter.py's py_env() is an allowlist too, and the three in-process cases that merged into the host environment now clear it. The bridge child already ran on an explicit environment.

  • Regression: test_no_harness_reads_a_credential_path_the_outer_environment_names. Sentinel REMOTE_TASK_TOKEN_FILE, AG2_DEVICE_ENV and CLAUDE_CONFIG_DIR + REMOTE_TASK_CHANNEL_DIR files in the outer environment; reads are recorded through gateway_credential_io.read_file. Fails on revert of install_service() (all three sentinel paths read).
  • Counts: gateway-credential-sources 120 (119); test_hatch_adapter 195, 2 skipped (unchanged). Both sync checks, scripts/review-checks.sh (PR diff piped in) and git diff --check pass. Each file run on its own with python3 under env -i, a temp HOME and USER set.
  • Blocker 2 (the §15 witness) is still pending.

Pins and counts at d485bce

  • Name sweep: 665 keys (17 stems × 12 separators × every split position, plus 5 controls). Contract: 3 quoted shapes per key, plus these unquoted shapes for every separator, space included: k=v, k: v, --k=v, --"k" v and '--k' v. --k v is added for every separator except ASCII space. That is 5,930 lines per copy, 17,790 in all (4,215 per copy at 7706cb0). Real _diff sink: 4,605 (665 at 7706cb0).
  • Every-suffix oracle: 4,000 seeded lines per copy. Each name position whose line has any suffix that credential_name accepts must be withheld.
  • Guard: 221 bypass sources flagged, each with a finding located in the appended source (211 at 7706cb0), 5 allowed, the helper alone [], and 6 package-wide traversal cases.
  • Test files:
    • gateway-credential-sources: 119 (115).
    • test_hatch_adapter/ag2-sparrow-hatch-adapter: 195, 2 skipped (194).
    • credential-contract-golden 37/37, room_ops_gateway_vault 9, room_ops_gateway_channel_env 16, gateway-proactive-room-from-channel-env 11.
    • Both remote-gateway-bridge PASS (326 checks each).
    • hatch-reply-real-drain 6, test_vault_token_tier 7, test_env_token_quotes 15, test_gateway_status ALL PASS, both adapter-boundary suites 8 each.
  • Both sync checks, gen-src-map --check, scripts/review-checks.sh (PR diff piped in) and git diff --check pass.
  • Each file was run on its own with python3, a cleared environment, a temp HOME and USER set.

Revert checks at d485bce

Mutant Fails
Rule 1 reverted (7706cb0 contract in all three copies) 2,004 subtests + 3 errors: 580 per copy of the key sweep, 264 real-_diff lines, and the every-suffix oracle in each copy
Rule 1: judge only the last blank-separated word before a separator 936: 777 key-sweep, 156 real-_diff, and the oracle (3 copies)
Rule 1: drop the suffix-head check (whole span and quoted key only) the every-suffix oracle in all 3 copies
Rule 1: drop the dequoted shell word 919: 810 key-sweep, 109 real-_diff
Rule 3 detector reverted (7706cb0 _systemd_findings) 11: the 9 alias, rebinding, parameter, string-name and redefinition repros, and the second-def cases in the package test
Rule 3 traversal skips the credential owners (as at 7706cb0) 2: the gateway_credentials.py and gateway_credential_sources.py cases
N1 reverted (no error branch) test_install_that_stopped_the_service_and_then_lost_its_state_fails_with_the_start_command: install exits 0 with no line

At 7706cb0: one enforcement point per rule (review 5563400596)

Each 5563400596 blocker was a narrower pre-filter in front of the rule's real decision. This commit removes the pre-filter, so each rule has exactly one place that decides, and pins that place.

Closed-world rules, each with its single enforcement point

(Refined at d485bce above: rule 1 judges every suffix and the dequoted flag word; rule 3 runs package-wide and pins one helper.)

  1. Credential names: credential_name() is the only name verdict. _names_and_values() hands it the complete key before every = or : that does not start :// (inside its quotes with any characters, escaped quotes included, and as the unquoted run back to a blank or quote), and every flag name (to a blank or =, and to , or a quote); the regexes only locate positions, and any accepted name withholds its line.
  2. Unit state: _unit_props() is the only parser. It returns fully parsed values (ActiveState a documented state; MainPID ≤ 10 ASCII digits and ≤ 4,194,304, Linux PID_MAX_LIMIT; ExecMainStartTimestampMonotonic ≤ 20 ASCII digits and ≤ 2^64−1, as ints), and any exception or out-of-range value is None; on None, install_service, ensure_sparrow and restart_listener stop, write, reload, enable, reset, restart, start and signal nothing.
  3. Guard: _systemctl() is the only place systemctl is named. Every systemctl run (and the PATH lookup, via locate=True) goes through it with one literal tuple of literal strings; the guard flags any "systemctl" program literal outside that helper's definition, any use of _systemctl or _systemd_run that is not a direct call with a literal argv, and show-environment in any string literal outside systemd_user_available. No constant is evaluated and no dataflow is followed.

Findings map (5563400596)

Finding Fix Pinned by
1: {"pa/ss":…} (and pa+ss, pa:ss) printed through the real Hatch _diff Rule 1: the complete key reaches credential_name test_every_key_position_is_judged_whole_by_credential_name (3 copies), test_the_real_hatch_diff_judges_every_complete_key (real _diff, empty registry injected; the exact {"pa/ss":"synth-only-value"} line is pinned to <text withheld…>)
1: the stem oracle came from the production constants (deleting a stem left 111/111 green) the test owns ORACLE_STEMS and ORACLE_SEPARATORS deleting any one stem from all three copies now fails 28 to 1,161 tests/subtests (16 of 17; apikey is an equivalent mutant, since key already covers it)
2: 5,000-digit MainPID → show → stop → daemon-reload → show → ValueError Rule 2: parse and bound in _unit_props; unknown does nothing test_a_number_its_consumer_cannot_take_stops_reloads_and_starts_nothing (full install: only show-environment and one show reach the manager, no unit is written, no traceback); test_unit_props_accepts_only_a_positively_verified_state (oversized and edge table)
2 (audit): after a failed post-reload show, install still ran enable unknown does nothing there too test_install_neither_stops_nor_starts_the_service_when_the_unit_state_query_fails (no enable, daemon-reload, stop, start, restart, reset-failed)
3: ["systemctl"] + rest, staged cmd += rest, starred, and the four earlier verb forms appended Rule 3: any "systemctl" literal outside _systemctl test_every_non_literal_systemctl_argument_is_flagged

Side-effect audit. install_service calls _unit_props() before the stop and before the unit write; ensure_sparrow before _stop and restart_listener; restart_listener before reset-failed/restart. Each consumer now reads only the parsed ints (main_pid = props["MainPID"], holder != props["MainPID"], timestamp != 0), so no conversion is left after a side effect.

Behaviour change. With the user manager's state unknown, install no longer writes the unit, reloads or enables it; it prints …did not give a well-formed state, so nothing was stopped, written, reloaded or enabled; run ag2-sparrow-hatch install again once the user manager answers. When no unit existed yet, the keepalive then treats the host as having no service (the same path as a host without systemctl --user).

Stated limits (unchanged in kind). Rule 1: a bare token that no source holds and no credential name precedes still prints. Rule 3: a program name never spelled as a "systemctl" literal (assembled at runtime, from the environment, as bytes, or inside a shell string) is outside what an AST guard sees; the package has no shell=True call. Prose naming systemctl (note(f"run systemctl --user …")) is not argv and is not flagged.

Pins and counts at 7706cb0

  • Name sweep: 17 stems × 12 separators (/ . - space + : _ \" • · → U+3000) × every split position = 660 keys + 5 controls = 665. Contract: 3 quoted shapes for every key, plus 4 unquoted shapes (k=v, k: v, --k v, --k=v) for non-blank separators = 4,215 subtests per copy, 12,645 in all. Real _diff sink: 665.
  • Unit numbers: 7 oversized (11-digit pid, pid 4,194,305, 10-digit pid 9,999,999,999, zero-padded 11 digits, 21-digit timestamp, 2^64, 4,301-digit timestamp) + 5 accepted edges (pid 4,194,304, zero-padded 10 digits, 2^64−1, 20-digit zero timestamp, pid 1) in _unit_props; the 5,000-digit repro + the 7 oversized as full installs (run in Service and InstallOrderTest).
  • Guard: 211 bypass sources flagged (19 built verbs × 10 placements, the review's repro and its staged, concatenated, starred and absolute-path forms, and non-literal, aliased or keyword-splatted uses of the helper), 5 allowed, and the helper itself flagged outside cli.py.
  • gateway-credential-sources: 115 (111 at 251781e). test_hatch_adapter/ag2-sparrow-hatch-adapter: 194, 2 skipped (192). credential-contract-golden 37/37, room_ops_gateway_vault 9, room_ops_gateway_channel_env 16, gateway-proactive-room-from-channel-env 11, both remote-gateway-bridge PASS (326 checks each), hatch-reply-real-drain 6, test_vault_token_tier 7, test_env_token_quotes 15, test_gateway_status ALL PASS, both adapter-boundary suites 8 each. Both sync checks, gen-src-map --check, scripts/review-checks.sh (PR diff piped in) and git diff --check pass. Run one at a time with python3, a cleared environment, a temp HOME and USER set.

Revert checks at 7706cb0

Mutant Fails
Rule 1 reverted (251781e contract in all three copies) 6,026 subtests: 1,870 per copy of the key sweep and 416 of 665 real-_diff keys
One stem deleted from all three copies (each of the 17 in turn) 16 of 17 fail (sig: 387, cred: 579, session: 1,161, …); apikey is equivalent (key covers it)
Rule 2 reverted (251781e cli.py + keepalive.py) 18: the full-install repro for all 8 oversized answers in both classes (stop/reload reached), test_unit_props_accepts_only_a_positively_verified_state, and the no-enable assertion
Rule 3 reverted (251781e detector) test_every_non_literal_systemctl_argument_is_flagged and the shipped-package guard; the old detector misses 134 of the 211 bypass sources, including 19 of 20 staged/concatenated/starred literal-"systemctl" forms

At 251781e: closed-world rules (review 5563208499)

Each review round found an adjacent case of an enumerated rule. This commit replaces each enumerated rule with a closed-world rule that fails safe by construction, so reviewers can judge the rule rather than a list of cases.

Closed-world rules

(Refined at 7706cb0 above: each rule now names its single enforcement point.)

  1. Credential names. A key, flag or header name is a credential when, lowercased with every non-alphanumeric removed, it contains any of pass pwd secret token key auth cred bearer jwt session cookie priv sig otp apikey, or has or ends in a pw or pat segment; any such name in a name position (a flag, or a name followed by = or a : that does not start ://) withholds its line whatever its value or length.
  2. Unit state. Hatch stops, signals, resets, restarts or starts only on a positively verified state (ActiveState, MainPID and ExecMainStartTimestampMonotonic each exactly once, a documented ActiveState, ASCII-decimal numbers), and with the manager's state unknown it reports success only for a lock holder proven by is_sparrow_pid to run Sparrow; anything malformed, missing, unexpected or unproven does nothing and says why.
  3. Guard. No constant is evaluated for show-environment: any string literal (f-string, % and .format template parts included) holding it outside systemd_user_available, and any systemctl invocation (a list holding the literal "systemctl", any _systemd_run call, or "systemctl" anywhere but shutil.which) whose arguments are not a plain literal list of literal strings, is flagged; the probe must pass a literal list straight to _ok.

This reverses an earlier loosening. At f42fe3f the name rule became whole-word so that --author, AUTH_MODE, --tokenizer-style names printed. Rule 1 withholds --author, AUTH_MODE, Author:, OAuthClientId and oauth-client-id, COMPASS, --bypass-proxy, KillSignal and Bearer of … again: over-withholding is the accepted price. pat is not in the requested stem list; it stays a credential (as before) under the segment-or-suffix rule, because as a substring it would withhold every path. The 8-character minimum and the path exclusion now apply only to registering a value for reuse on other lines, never to withholding the shaped line itself (the old 8-character gate on Bearer values is gone).

(Superseded at 7706cb0 by the _systemctl helper.) To satisfy rule 3, the shipped systemctl calls in cli.py and keepalive.py pass literal lists ("ag2-sparrow.service" written out; a guard test pins every such literal equal to SERVICE_NAME), _unit_props() always queries the same three properties, and enable/enable --now are two literal calls.

Stated limit (unchanged in kind). A bare token that no source holds, with no credential name in a name position on its line and no recurrence on a shaped line, still prints (Description=Sparrow <tok>). For rule 3: a systemctl command assembled at runtime from non-literal pieces that never spell the literal "systemctl" (for example a shell string or a program name from the environment), or one passed as bytes, is outside what an AST guard can see.

Findings map (5563208499)

Finding Rule Pinned by
B1: DB_PASSCODE printed (regression from 6f8de04) 1 test_adjacent_shapes_from_review_are_withheld, test_the_hatch_install_diff_withholds_the_adjacent_review_shapes (real Hatch _diff)
B1: args: [--token,…], args: [--access-key=…] 1 (a flag after any non-name character; , separates a flag value) same two tests, plus the sweep
B1: dotted {"auth.token":…}, access.token: … 1 (dots are name characters) same two tests, plus the sweep
Nit: COMPASS/--bypass-proxy withheld, OAuthClientId inconsistent 1: all withheld, consistently test_a_credential_name_holds_a_stem_after_folding
B2: ActiveState=not-a-systemd-state/bogus/active acted 2 test_a_malformed_unit_answer_stops_signals_and_starts_nothing, test_unit_props_accepts_only_a_positively_verified_state
B2: MainPID=² raised after the stop; MainPID=٠ read as 0 2 (ASCII digits; full property set checked before any stop) same two tests
B2: unrelated fresh holder + unanswering manager returned 0 2 (is_sparrow_pid before success) test_an_unproven_fresh_lock_holder_is_no_success_while_the_manager_is_silent
B2: K2′ revert witness lacked relay.env fixture now writes it, so an accepting parser reaches SIGTERM unit_state_unknown; revert row below shows the SIGTERM
B2 (audit): restart_listener reset/restarted without a verified state 2 test_keepawake_restarts_nothing_on_a_malformed_unit_answer
B3: converted f-string, keyword .format, mapping %, tuple-concat join 3 test_every_non_literal_systemctl_argument_is_flagged (19 spellings × 5 placements, plus aliases, starred, concatenated, shell and message strings)

Pins and counts at 251781e

  • Name sweep: 17 stems × 5 separators (none, _, -, ., __) × 4 casings × 13 shapes (KEY=, export, --f v, --f=v, -f v, [--f,v], [--f=v], double- and single-quoted JSON, YAML, YAML list, header, env -i) = 4,420 subtests per contract copy, 13,260 in all.
  • Malformed unit answers: 24 (19 single-field changes to a valid stopped unit, plus duplicate key, unknown property, line without =, empty answer, no stdout) × 2 install modes = 48 full-install runs; plus all 8 documented states accepted.
  • gateway-credential-sources: 111 (102 at 3cbfb5b). test_hatch_adapter/ag2-sparrow-hatch-adapter: 192, 2 skipped (187). credential-contract-golden 37/37, room_ops_gateway_vault 9, room_ops_gateway_channel_env 16, gateway-proactive-room-from-channel-env 11, both remote-gateway-bridge PASS, hatch-reply-real-drain 6, test_vault_token_tier 7, test_env_token_quotes 15, test_gateway_status ALL PASS, both adapter-boundary suites 8 each. Both sync checks, gen-src-map --check, scripts/review-checks.sh (diff piped in) and git diff --check pass. Run one at a time with python3, a cleared environment and a temp HOME.

Revert checks at 251781e

Mutant Fails
Rule 1 reverted (3cbfb5b contract in all three copies, stem constants injected so the sweep runs) sweep 9,600 of 13,260 subtests; adjacent repros 72 of 78; test_a_credential_name_holds_a_stem_after_folding (3 copies); both real-sink tests (DB_PASSCODE and the other 5 lines, --author)
Rule 2 reverted (3cbfb5b cli.py + keepalive.py) malformed table 28 of 48 runs (27 SIGTERM the Sparrow-shaped holder, MainPID=² raises ValueError mid-install); _unit_props table 24 of 24 malformed accepted; unproven holder returns 0 and is_sparrow_pid is never called; keepawake resets and restarts on a malformed answer
Rule 3 reverted (constant-folding detector) test_every_non_literal_systemctl_argument_is_flagged: 26 subtests, including all four 5563208499 forms

Re-scope at dcc978a: option (a)

Both reviews at 38f4b68 (5562704937 and 5562717028) converged on option (a): credential truth lives in the process that runs (the bridge); Hatch fails closed at presentation and stops emulating systemd.

  • Hatch reads credential sources only to mask its install diff, through the edge readers the bridge also uses, and decides nothing from them (winners, notes and fatal are discarded).
  • Hatch no longer reads or models the systemd manager environment. _manager_env, the analysis show-environment read, _service_env and the manager/installer baseline are gone. systemd_user_available() is main's reachability probe again (its output is discarded), so it no longer depends on --no-service or on any analysis choice.
  • Hatch no longer validates, demotes or refuses credentials: _credential_discovery and credentials_usable are gone, with Discovery.withheld, which only they used. Each bridge validates the environment it actually started with, in main(), before it polls or publishes anything.
  • Hatch's install diff withholds every environment line whole (below). It decides nothing about which token wins.

At f42fe3f: the re-review of 6f8de04 (5563096096, 5563059400)

One commit fixes 5563096096's three blockers and 5563059400's three nits; nothing else changes.

  • Only LF ends a line (K1'). _groups and the Hatch _diff split at LF only (physical_lines; a CRLF stays whole), as systemd reads a unit. str.splitlines also broke at U+2028, U+2029, U+0085 and others, so Environment="REMOTE_TASK_TOKEN=qa-first<U+2028>qa-tail-9988" rendered -Environment=<withheld>qa-tail-9988". Now that line is one group and prints Environment=<withheld> alone. A directive is also judged in every piece any line break starts (CR, NUL and every str.splitlines break), and any other shown line holding such a break is withheld whole, so no renderer that breaks there shows an unmasked piece. systemd_words splits on systemd's whitespace (space, tab, CR, LF) only.
  • Unknown unit state (K2'). _unit_props returns None when systemctl --user show fails, cannot run, or answers without an ActiveState or with a non-numeric MainPID. Every decision on it fails safe: ensure_sparrow treats None like an unanswering manager (no SIGTERM, no detached spawn, no start; it says systemctl --user show ag2-sparrow.service did not give its state), and install_service does not stop the unit first, then only enables it (never --now, never restart) and says so. Audited: _unit_owner (unit file + show-environment), restart_listener, unmanaged_health_problem, keepawake and install_service are the only deciders; none reads a missing property as inactive now.
  • Leftover unit, no user manager (N3). With the unit's state unknown and no Sparrow holding the lock or reporting up, install exits 1 with error: Sparrow is not running: <unit> is installed but systemctl --user did not answer, so whether that service runs Sparrow is unknown; install neither stops nor starts a Sparrow. Start your user manager (systemctl --user) and run install again, or remove <unit> if it is left over. When a Sparrow does hold the lock, it exits 0 and prints NOTE sparrow: … Sparrow (pid N) was left as it is.
  • (Superseded at 251781e by closed-world rule 3.) Guard folds constants (Q2'). The guard's _constant folds +, implicit concatenation, f-strings, sep.join((…))/[…], .format(…) and %; the probe gate and the store check use it, so "show-" + "environment" and the other four forms 5563096096 listed are caught.
  • More shapes (N1). --key, --access-key, --credentials, --pat flags; Name: value headers and YAML keys whose name is a credential word (X-Api-Key:, X-Auth-Token:, token:, api_key:, password:).
  • (Superseded at 251781e by closed-world rule 1.) Whole-word names and less poisoning (N2). One credential_name() decides every key, flag and header name: split on separators, case changes and digits; a word counts when it is a credential word (token, secret, password, passwd, pass, credential(s), key, apikey, auth, authorization, bearer, cookie, session, pat, …) or ends in a glued one (MYTOKEN, DBPASSWORD). --author, --tokenizer, X-AUTHOR= and --oauth-client-id no longer count; Bearer needs a value of 8+ characters, so Description=Bearer of tasks prints. A shaped line is still withheld; its value is registered for reuse elsewhere only at 8+ characters and when it is not a plain path (/…), so --auth=1 no longer hides RestartSec=10, --auth-mode=basic no longer hides Description=A basic relay, and a --token-file path no longer hides a line that lists it.

At 6f8de04: the re-review of ad3cbb1 (5562940921, 5562941475)

One commit fixes the five code blockers; nothing else from option (a) changes.

  • Whole systemd values (K1). register_assignments splits an Environment= value as systemd does (quotes anywhere in a word, C escapes such as \x54 and \s decoded, an escaped key judged decoded) as well as as written, and registers each value raw, unescaped and quote-stripped. Every rendered line is matched by raw substring in four views: as written, percent-decoded, systemd-unescaped and with its quotes off. No tokenization of the shown line decides whether a registered value is masked.
  • Unanswering user manager (K2). An installed unit whose systemctl --user probe fails is UNIT_UNREACHABLE, not "no unit" (no systemctl binary at all is still "no unit"). install --no-service defers and prints why; ensure_sparrow and restart_listener neither SIGTERM the lock holder nor spawn a detached Sparrow, and say so.
  • Shape rule (Q1). Any shown line carrying a credential shape is withheld whatever the registry holds: a credential-named KEY= anywhere in the line (env -i KEY=…, sh -c 'export KEY=…', an argument), a quoted JSON credential key, a --token/--token=/--secret/--password/--api-key/--bearer/--auth… flag, an Authorization: header or a Bearer value, also in the unescaped and unquoted views. An assignment line shows only its key (ExecStart=<withheld>); any other line prints <text withheld: it may contain a credential>. The value of each shape is registered at any length, so the same token on a neutral line elsewhere in the diff is withheld too. This covers a vault-only token without reading the vault.
  • Probe output (Q2). The guard flags any scope that runs show-environment unless the run's result goes straight into _ok(...) (a bool) and the scope neither reads .stdout/.stderr, prints, writes, appends nor assigns, walruses, yields or declares a global. A behavioural test feeds the probe a synthetic REMOTE_TASK_TOKEN= on stdout and stderr: it returns exactly True, prints nothing, and leaves no copy on the module.
  • What the unit receives (Q3). The diff's masking is seeded from dict(os.environ, AG2_DEVICE_ENV=<device_env_path()>), the value the unit and a detached Sparrow are given, so Hatch's default relay.env is registered when the installer's shell does not name it. The seeded registry is built once per install (on its first diff) and reused, and the vault reader is resolved once and only when that tier is reached: each vault key is looked up at most once per install. No new I/O and no Keychain read is added: the vault is still reached only when no earlier tier holds a token, now at most once instead of per diff.

Stated limit. A token that no source holds (retired, another account's) or that only the vault holds while the vault tier is not reached, placed bare in an arbitrary position with no credential shape on its line and not recurring on a shaped line in the same diff (Description=Sparrow <tok>, NOTE=<tok>, free text), still prints. So does such a token after a short flag (-t, -k), under a neutral key (env X=<tok>, sh -c 'T=<tok> /x'), after a Unicode dash (—token), as a bare base64 value, in a comment line, or after Bearer when shorter than 8 characters. A shaped value shorter than 8 characters or a plain path is withheld on its own line but not registered for other lines. Names are matched as whole credential words, so AUTHKEY, PRIVKEY, SESSIONID, JWT, --pw and --pwd are not shapes (none is the gateway bearer), and an environment line's whole-line withholding still registers its value, so Environment=AUTH_MODE=1 can hide an unrelated RestartSec=10. Both are candidates for a follow-up. Closing that would need reading the vault on every install (a possible Keychain prompt), which this PR does not do. Pinned by the limit assertions in test_a_credential_shape_is_withheld_whatever_the_registry_holds and test_the_hatch_install_diff_withholds_every_credential_shape.

Soundness limits of the name rule. A credential name with no stem in it is not caught: abbreviations (TKN), stemless names (CERT, PEM, DSN), lookalike letters from other scripts, leetspeak, and names assembled across variables. Adding crt/pem/dsn/tkn as stems is a candidate follow-up.

Unchanged from the reviewed head: the SOURCES precedence table, register-every-candidate, the bounded reader, option (b)'s general-config fall-through, both bridge FATAL paths, the structural guard (less the entry for the removed Hatch reader) and the sync and drift checks.

Summary

The gateway credential policy now has two owners, one pure and one for precedence.

1. Pure contract: shared/ag2_gateway_credentials.py. It keeps the existing ruling: no I/O, no module-global state. It gains the masking contract:

  • SecretRegistry, an object its caller owns. A secret is matched in every rendering: any letter case, percent-encoding in either hex case, double encoding, repr and unicode_escape escapes, and pieces of it. Rendering is total over any str, including a non-UTF-8 environment byte.
  • code_field, redact_url, redact_text, safe_status (non-finite numbers withheld) and describe (exception type, errno or EAI name, HTTP status, a KeyError key only when the code wrote it, and the raising file:line; never the exception's text).
  • mask_env_assignments and register_assignments, for showing a unit, drop-in, .env or script (the Hatch install diff).
    • Any line whose directive could set, load, unset or pass the environment prints as the fixed Environment=<withheld>: no key, value or path. That covers Environment=, EnvironmentFile=, UnsetEnvironment= and PassEnvironment=, and any directive in doubt: the word in any letter case, a backslash (a hex, octal or Unicode escape systemd might decode) or a non-ASCII character. Nothing is decoded; doubt withholds.
    • Lines end at LF only (physical_lines), as systemd reads a unit; any other line break inside a line puts it in doubt (an environment directive in any piece withholds the group, and any other such line is withheld whole). Continuations are grouped at least as widely as systemd joins them: any trailing backslash continues (comment lines too), and a comment inside a continuation does not end it. A group with any environment line is withheld whole.
    • register_assignments runs over both texts before anything prints: each value on an environment line, split as systemd splits it and as written (at 8+ characters), each credential-named value (any length, the key judged systemd-unescaped), and each credential shape's value (8+ characters, not a plain path); every value raw, systemd-unescaped and quote-stripped. A duplicate under a neutral key or on another line is then withheld too.
    • Every shown line is matched against the registry by raw substring in four views (as written, percent-decoded, systemd-unescaped, quotes off), and any line with a credential shape (credential_shapes: a credential-named KEY= anywhere, a quoted JSON credential key, a credential Name: value header or YAML key, a credential flag, Authorization: or a Bearer value of 8+ characters; names judged by credential_name as whole words) is withheld whatever the registry holds.
    • Other KEY=value lines keep credential-name masking (credential_name: closed-world rule 1 since 251781e; before it, a whole word such as TOKEN, SECRET, PASSWORD, PASSWD, PASS, CREDENTIAL, KEY, AUTH, BEARER, COOKIE, SESSION or PAT, or a word ending in a glued one such as MYTOKEN), onboarding strings and credential-bearing URLs.
  • It is regenerated by tools/sync_gateway_credentials.py into the package and the room-ops skill, as before.

2. Discovery and precedence owner: src/gateway_credential_sources.py. It is vendored into ag2-sparrow by tools/sync_from_src.py.

  • SOURCES states the precedence once, and discover() walks it: the table is the code path, not a description of one. Reordering a tier list reorders the winner (tested).

    Value Sources, first wins
    token env (REMOTE_TASK_TOKEN, then AG2_REMOTE_TOKEN) → file (AG2_DEVICE_ENV, then $CLAUDE_CONFIG_DIR/channels/<dir>/.env) → vault
    URL env → the URL inside the winning onboarding string → the file that supplied the token
    token file env REMOTE_TASK_TOKEN_FILE → the file that supplied the token
    media token env
  • discover() takes the environment, the .env files and token file the caller read, and a vault read the caller injects. It registers every materialized candidate before it picks the winners, so a sink can withhold a credential that precedence discarded. Each value is registered whole as well as by its parts, so a URL-shaped opaque bearer is a secret too.

  • It opens nothing itself. The AG2-specific file, env and vault I/O stays at the adapter edge.

  • channel_dir(env) and env_files(env, read_text) are the one answer to which channel .env files are read and in what order, including REMOTE_TASK_CHANNEL_DIR. The caller injects the reader.

3. Delegation.

  • Bridge: it keeps only its I/O and asks the owner for every decision: the env, file and vault tiers, the URL chain, the token file, the media token and token-file parsing. The startup messages, the legacy-alias warning and the media-marker carry are unchanged. The helpers that tests call keep their names and results.
  • Hatch adapter: it makes no credential decision. Before its install diff renders, it seeds the masking registry (once per install, from the environment the unit receives: the installer's with AG2_DEVICE_ENV set to Hatch's device file) from the owner's register-every-candidate path (gateway_credential_io.register_candidates: the environment, the device and channel .env files, the token file, and the vault when no earlier tier holds a token, read through the same readers the bridge hands the owner). That is for masking only: the winners, notes and fatal are dropped, and a refused file yields no values. It then registers the values assigned in the two texts (register_assignments) and renders through mask_env_assignments.
  • Edge readers: packages/ag2-sparrow/ag2_sparrow/gateway_credential_io.py holds the reads the adapters hand the owner (the bounded file reader, the monorepo walk and the vault lookup). The bridge's _vault_lookup and _monorepo_src delegate to it, so there is one implementation. A Sparrow that Hatch starts detached (no unit, or a service setup that failed) validates its own environment at startup, like any other bridge.

What this fixes

The finding numbers are from the #5665 review at 11e0504.

Not in this PR

Behaviour changes

Precedence and value parsing are unchanged for every launcher: a file value is unquoted exactly once, as on main, so a padded or quoted-blank token resolves the same (tested). Fail-closed covers the credential (token/URL) tiers only; general config keeps main's behaviour.

Bridge (the one place credentials are decided):

  • It validates the environment it started with in main(), before it polls or publishes anything. Messages carry source names, paths and reasons, never values.
  • REMOTE_TASK_TOKEN_FILE is now read once at import, so its token and URL are registered as secrets before anything is shown. On main it was first read at rotation.
  • Credential files are read through one bounded reader: a non-regular file, a file over 1 MiB or a non-UTF-8 file is refused with a reason, and the path and reason (never content) print on stderr. A refused default channel .env is skipped with that warning.
  • A refused, explicitly named AG2_DEVICE_ENV is FATAL: main() exits with FATAL: (even when an env token is set), and no later file or the vault stands in for the token. General config such as REMOTE_PROACTIVE_ROOM keeps falling through to the later channel .env, as on main. A missing file is still skipped silently.
  • A refused, explicitly named REMOTE_TASK_TOKEN_FILE is FATAL at startup (path and reason, no content), since it would silently disable rotation. If it becomes unreadable later, each rotation reload names the refusal on stderr and does not rotate. On main a non-UTF-8 token file raised from the rotation read.
  • Every assignment of a credential key is registered (repeats, export lines, losing aliases in the token file); the last value still wins.
  • The legacy-alias deprecation warning keeps main's behaviour.

Hatch install (presentation only):

  • The install diff never prints the content of an environment line, in the old or the new text, for a retained hand-written unit, a drop-in-shaped text or any other file it diffs: each such line, and its whole continuation group, prints as Environment=<withheld>. On main those lines printed raw. The cost: the diff no longer shows which environment line changed, only that one did.
  • A value that appears on an environment line, or under a credential-named key, is withheld wherever else it appears in the diff (for example ExecStart=<withheld>).
  • A live token that appears only outside an assignment (an ExecStart= argument, a hook script's Authorization: Bearer header) is withheld when discovery reaches it from the environment, Hatch's own device file (named or default), the channel file, the token file or the vault. At dcc978a such a token printed in full, because only values assigned inside the two texts were registered.
  • A line with a credential shape is withheld even when no source holds the token (see the stated limit for what remains).
  • Each credential source is read for masking at most once per install, and only when a diff is shown; the vault only when no earlier tier holds a token.
  • Hatch reads credential sources only through the owner's injected readers and only to mask: no manager environment, no drop-in, and it never validates, refuses or fails an install over credentials. Main did not either; the reviewed head did, and that is removed.
  • install --no-service on a host where the user service is installed: as on main, the service stays the one Sparrow and no detached Sparrow is started beside it. Install now also prints skipped: the ag2-sparrow user service (--no-service); <unit> is already installed, so that service runs Sparrow and its environment is the live identity; no detached Sparrow is started beside it.
  • install --no-service where the unit is installed but systemctl --user does not answer: install prints skipped: … <unit> is installed but systemctl --user did not answer, so whether that service runs Sparrow is unknown; no Sparrow is stopped and no detached one is started beside it, and the Sparrow step says it neither stops nor starts one. If a Sparrow holds the lock, that is a NOTE sparrow: naming its pid and install exits 0; if none runs (a leftover unit with no user manager), it is error: Sparrow is not running: … and install exits 1. keepawake in that state does not spawn a detached Sparrow either; it reports the restart as failed with the reason.
  • install (with or without --no-service) where the manager answers show-environment but the following systemctl --user show fails or gives no usable state: the same as above (no SIGTERM, no spawn, no start or restart; the reason names systemctl --user show ag2-sparrow.service did not give its state). With a service install, the unit is written and enabled but not stopped first and not started or restarted.
  • install --no-service with no unit, or a service setup that failed: as on main, Sparrow starts detached from the installer's environment, and that bridge's own startup check decides (a refused named file is FATAL there).

Files to read first

src/gateway_credential_sources.py, the masking section of shared/ag2_gateway_credentials.py (sets_environment, register_assignments, mask_env_assignments), packages/ag2-sparrow/ag2_sparrow/gateway_credential_io.py, then the credential block of remote_gateway_bridge.py (the _FOUND = discover(...) block, main()'s FATAL check) and _diff / install in adapters/hatch/cli.py.

Docs

docs/src-map.md is regenerated for the new src/ module. The D1 identity census row for the ag2space credentials owner is re-anchored to the owner's discover() (scripts/census-d1.py, and the regenerated docs/census/d1-identity-census.md). No other docs change: precedence and behaviour are unchanged for every launcher.

Before / after evidence

On main, ag2-sparrow-hatch install over an existing unit holding Environment="SAFE=x" "REMOTE_TASK_TOKEN=<token>" printed the token in its diff. At this head that line prints Environment=<withheld>, as does \x45nvironment=NOTE=<token>, REMOTE_TASK_\x54OKEN=<token>, an EnvironmentFile= path, and a neutral-key duplicate of the token elsewhere in the unit.

Test plan

tests/gateway-credential-sources.test.py: 102 tests (88 at 6f8de04, 78 at ad3cbb1, 76 at dcc978a). Hermetic: temp HOME and files, a faked vault, fake systemctl runners, no network. Contract cases run against every copy (the package copy, shared/ and the skill copy); owner cases against both owner copies.

  • Contract: registry renderings and case, non-UTF-8 input, pieces and code_field, URL parts, redact_text, safe_status, describe, purity by AST; and the presentation rules: test_every_environment_line_is_withheld_whole, test_an_escaped_or_doubtful_directive_is_withheld, test_a_continued_environment_line_is_withheld_with_every_line_it_joins, test_a_value_on_an_environment_line_is_withheld_wherever_it_recurs, test_edge_paths_never_raise (malformed escapes included).
  • Discovery: eight mixed and conflicting rows pick the tabled winner and register every candidate; reordering SOURCES changes the winner; refused named files are fatal and named; config still reaches a later file.
  • Real bridge (child process): the tabled winner and every candidate registered per row; both FATAL paths at startup; a token file refused at reload is named; a FIFO never hangs the import; repeated keys registered (test_every_assignment_of_a_repeated_key_is_registered_by_the_bridge).
  • Real Hatch: test_a_token_only_outside_an_assignment_is_masked_from_the_env_or_the_device_file and test_a_token_only_outside_an_assignment_is_masked_from_the_token_file_or_the_vault (a fake vault reader; a unit's ExecStart= and a hook script's Authorization header, each holding the token outside any assignment), test_the_hatch_install_diff_withholds_every_candidate_of_every_row (each candidate now only in ExecStartPre= arguments, no longer also on an Environment= line; the vault candidate is checked in the row where the vault tier is reached, as in the bridge), ..._masks_past_a_comment_in_a_continuation, ..._follows_systemd_continuations, ..._withholds_escaped_directive_spellings, test_an_owned_or_retained_unit_prints_no_environment_content, test_a_drop_in_is_never_read_and_its_lines_would_be_withheld, and test_after_a_failed_service_install_the_detached_bridge_checks_its_own_environment (two routes: a service setup that failed, and --no-service with no unit; the environment Hatch hands the detached Sparrow is fed to the real bridge, which exits FATAL on the refused token file, while Hatch printed no refusal).
  • New at 6f8de04 (contract, every copy): test_a_systemd_value_is_registered_whole_whatever_its_quotes_or_escapes (5562940921's exact REMOTE_TASK_TOKEN='abcdefgh' + --token='abcdefgh' pair, then 'abcdefgh', REMOTE_TASK_\x54OKEN=abc and alpha\somega each recurring on neutral, quoted and escaped lines) and test_a_credential_shape_is_withheld_whatever_the_registry_holds (5562941475's rows plus --secret, --password=, --api-key, --bearer, Bearer\s…, a JSON key and --auth-token, with and without a registry; a shaped value recurring under env X=, Description=, NOTE= and a JSON line; the bare-token limit).
  • New at 6f8de04 (real Hatch sink): test_the_hatch_install_diff_registers_a_systemd_value_whole (the same repros through _install_file → _diff), test_the_hatch_install_diff_withholds_every_credential_shape (5562941475's six ExecStart*/curl forms with an empty vault, the recurrence rows, and the limit), test_the_hatch_install_diff_seeds_from_the_default_device_file (AG2_DEVICE_ENV unset, the token only in $HOME/.config/ag2-sparrow/relay.env: ExecStart=/x --flag <live> and NOTE=<live> withheld, and the vault fake records no lookup), and Guard.test_the_reachability_probe_discards_what_show_environment_prints (the shipped form passes; eight variants that return the run, return .stdout, print, stash in a global, append, walrus, write to stderr or yield are flagged).
  • New at f42fe3f (contract, every copy): test_only_an_lf_ends_a_line_and_no_other_break_carries_a_tail (U+2028, U+2029, U+0085, VT, FF, FS, GS, RS, CR and NUL: physical_lines, 5563096096's Environment= repro, a directive after the break, a shown line and an assignment holding one), test_flag_header_and_yaml_credential_shapes (N1's flags, headers and YAML keys), test_a_credential_name_is_a_whole_word (MYTOKEN, DBPASSWORD, apiKey count; AUTHOR, tokenizer, oauth-client-id, keyboard, patch do not; --author, --tokenizer, X-AUTHOR=, --oauth-client-id, Bearer of tasks and Author: lines print unchanged) and test_a_short_or_path_shaped_value_hides_no_other_line (--auth=1 next to RestartSec=10 and Description=Sparrow 1.0, --auth-mode=basic, a --token-file path; an 8+ value is still reused).
  • New at f42fe3f (real Hatch sink): test_the_hatch_install_diff_shows_no_tail_past_a_unicode_line_break (5563096096's repro for U+2028, U+2029 and U+0085 through _install_file → _diff: the body is exactly [Service], -Environment=<withheld>, -ExecStart=/x, +ExecStart=/y) and test_the_hatch_install_diff_keeps_harmless_lines_and_names (--auth=1 withheld, RestartSec=10 and --author <name> shown, an X-Api-Key: header withheld). Guard.test_the_reachability_probe_discards_what_show_environment_prints now also builds the command eight ways (+, implicit concatenation then +, "".join(…), "-".join([…]), f-string, .format, % with one and two args): each is flagged when returned via .stdout or stored in a local, flagged as a store read in an unlisted scope, and passes when only _ok(...) tests it.
  • Guard: the detector self-test now includes two show-environment reads; the allowlist names Hatch's reachability probe (systemd_user_available), drops the removed Hatch reader, and names the edge readers in gateway_credential_io (read_file, vault_lookup, _vault, and _vault_once, the per-install memo of _vault). Hatch itself hands the owner no reader and opens no credential source in its own code.

packages/ag2-sparrow/tests/test_hatch_adapter.py: 187 tests, 2 skipped (184 at 6f8de04, 180 at ad3cbb1). New at f42fe3f: test_no_service_never_stops_a_service_sparrow_when_the_unit_state_query_fails (5563096096's sequence with fakes: an owned active unit, a live synthetic Sparrow holding the production-format lock without the health command, show-environment answering and show failing, not running, answering no ActiveState, or an empty MainPID: the old holder alive, no spawn, no start/restart/stop/enable/reset-failed, rc 0, the reason printed), test_install_neither_stops_nor_starts_the_service_when_the_unit_state_query_fails (the same through a full service install: no stop, start or restart, a plain enable only) and test_a_leftover_unit_with_no_user_manager_exits_non_zero_and_starts_nothing (with and without --no-service: rc 1, error: Sparrow is not running: …, nothing spawned). New at 6f8de04: test_no_service_never_replaces_a_service_sparrow_while_the_manager_does_not_answer (5562940921's sequence with fakes: an owned unit, a live synthetic Sparrow holding the production-format lock without the health command, reachability false,false,false,true: no SIGTERM, no detached spawn, the old holder alive, both ownership checks met the failing probe, the reason printed), test_an_unanswering_manager_gets_no_detached_sparrow_beside_its_unit (restart_listener), ReachabilityProbe.test_the_probe_discards_what_show_environment_prints and InstallMasking.test_the_vault_is_consulted_at_most_once_per_install (two diffs in one install --force: _vault resolved once, each key looked up once). Earlier, with 180 (its base case now fakes the vault reader, so no install diff reaches the host's vault), adding test_no_service_defers_to_an_installed_running_service (fake systemd, an active managed unit whose Sparrow holds the lock: nothing stopped, nothing spawned, no start/restart/stop/enable, the live-identity line printed) and test_no_service_with_no_unit_runs_one_detached_sparrow.

Revert checks at this head. Each mutant fails the named tests:

Mutant Fails
environment lines not withheld 11 tests (73 subtest failures), including every Hatch diff test
exact directive names only, no escape doubt test_an_escaped_or_doubtful_directive_is_withheld, test_every_environment_line_is_withheld_whole, test_the_hatch_install_diff_withholds_escaped_directive_spellings
a comment ends a continuation test_a_continued_environment_line_..., test_the_hatch_install_diff_masks_past_a_comment_in_a_continuation
only an odd backslash run continues test_a_continued_environment_line_...
no two-pass registration test_a_value_on_an_environment_line_..., test_an_owned_or_retained_unit_..., test_the_hatch_install_diff_withholds_every_candidate_of_every_row
detector blind to show-environment test_the_detector_catches_every_bypass
Hatch diff without discovery seeding (the two-text registration only) 12 subtests: both test_a_token_only_outside_an_assignment_... tests (env, device file, token file, vault) and all eight rows of test_the_hatch_install_diff_withholds_every_candidate_of_every_row
the 38f4b68 _manager_env put back in Hatch test_no_other_package_module_touches_a_credential_source
Hatch validates the installer environment again test_after_a_failed_service_install_..., test_no_other_package_module_...
--no-service hides systemd (the 38f4b68 behaviour) test_no_service_defers_to_an_installed_running_service (the service's Sparrow is stopped and a detached one spawned)
no live-identity line test_no_service_defers_to_an_installed_running_service
6f8de04 K1 reverted: tokenized environment values, raw key, no line views, no value forms test_a_systemd_value_is_registered_whole_... (all three repros, in each of the 3 copies) and test_the_hatch_install_diff_registers_a_systemd_value_whole (all three): 12 subtest failures
6f8de04 K2 reverted: an unanswering manager reads as no unit test_no_service_never_replaces_a_service_sparrow_while_the_manager_does_not_answer, test_an_unanswering_manager_gets_no_detached_sparrow_beside_its_unit
6f8de04 Q1 reverted: no shape rule test_a_credential_shape_is_withheld_... (every form, every copy) and test_the_hatch_install_diff_withholds_every_credential_shape: 87 subtest failures
6f8de04 Q2 guard removed test_the_reachability_probe_discards_what_show_environment_prints (all eight variants)
6f8de04 Q2 probe stores its run in a module global test_no_other_package_module_touches_a_credential_source, ReachabilityProbe.test_the_probe_discards_what_show_environment_prints
6f8de04 Q3 seeded from the installer env (the ad3cbb1 behaviour) test_the_hatch_install_diff_seeds_from_the_default_device_file
6f8de04 Q3 no per-install cache, vault resolved per key test_the_vault_is_consulted_at_most_once_per_install
f42fe3f K1' masker splits like str.splitlines (shared copy) test_only_an_lf_ends_a_line_...: 9 subtest failures
f42fe3f K1' the package copy (Hatch's) splits like str.splitlines test_the_hatch_install_diff_shows_no_tail_past_a_unicode_line_break: all 3 characters
f42fe3f K1' a line with an inner break not in doubt test_only_an_lf_ends_a_line_...: 10
f42fe3f K1' directive judged on the first piece only test_only_an_lf_ends_a_line_...: 10
f42fe3f K2' a failed show reads as inactive again ({}) both new unit-state tests: 5 (the service Sparrow got SIGTERM)
f42fe3f K2' an answer with no ActiveState or a blank MainPID accepted test_no_service_never_stops_...: 2
f42fe3f N3 unknown state always exits 0 test_a_leftover_unit_with_no_user_manager_...
f42fe3f Q2' guard folds only + and f-strings test_the_reachability_probe_discards_...
f42fe3f Q2' probe gated on the exact literal again Guard: 16
f42fe3f N1 no Name: value / YAML shape test_flag_header_and_yaml_credential_shapes: 6
f42fe3f N1 no key, pat, credential(s) words test_flag_header_and_yaml_credential_shapes: 6
f42fe3f N2 names matched by substring test_a_credential_name_is_a_whole_word
f42fe3f N2 shaped values registered at any length, paths included test_a_short_or_path_shaped_value_hides_no_other_line: 3

The owned-unit half of test_an_owned_or_retained_unit_... pins that an owned unit is replaced without rendering any of it; it also passed before, because the owned path never printed a diff.

Run at f42fe3f one at a time with python3, cleared environment and a temp HOME, all passing: gateway-credential-sources (102), gateway-proactive-room-from-channel-env (11), credential-contract-golden (37/37), room_ops_gateway_vault (9), room_ops_gateway_channel_env (16), tests/remote-gateway-bridge.test.py and src/remote-gateway-bridge.test.py (all checks green), ag2-sparrow-hatch-adapter (187, 2 skipped), hatch-reply-real-drain (6), test_env_token_quotes (15), test_vault_token_tier (7), test_gateway_status (all pass), both adapter-boundaries suites (8 each). sync_from_src --check, sync_gateway_credentials --check, gen-src-map --check, scripts/review-checks.sh on the diff and git diff --check pass.

Latest findings, and how option (a) resolves each

Finding Resolution Regression test
5562704937 B1: install --no-service hid an installed managed service (systemd availability conflated with the analysis choice) The manager cache is gone; systemd_user_available() is main's probe, independent of --no-service. With a unit installed, install defers to it, starts nothing detached and prints that the service is the live identity. test_no_service_defers_to_an_installed_running_service, test_no_service_with_no_unit_runs_one_detached_sparrow
5562704937 B2: the show-environment credential read escaped the guard The read is removed. The detector now treats show-environment as a credential-store read; the only allowlisted site is the reachability probe, which discards the output. test_the_detector_catches_every_bypass (two new probes); test_no_other_package_module_touches_a_credential_source fails if _manager_env returns
5562717028 #1: valid systemd escapes bypassed credential-name masking Nothing is decoded or classified per key: a directive with a backslash, a non-ASCII character or the word environment in any case is withheld whole, as is a bare KEY= line whose key holds an escape. test_an_escaped_or_doubtful_directive_is_withheld, test_the_hatch_install_diff_withholds_escaped_directive_spellings
5562717028 #2: per-unit sources absent, same-diff duplicates leaked Hatch no longer models sources at all. Environment lines, EnvironmentFile=, UnsetEnvironment= and PassEnvironment= are withheld whole; values from both texts are registered before rendering (two-pass); drop-ins are never read. test_an_owned_or_retained_unit_prints_no_environment_content, test_a_drop_in_is_never_read_and_its_lines_would_be_withheld, test_a_value_on_an_environment_line_is_withheld_wherever_it_recurs
5562717028 #3: validation chose an environment before knowing which runner starts Hatch validates nothing. Whichever process starts (the unit, or a detached fallback after a failed setup or under --no-service) runs the bridge's own startup check on its own environment. test_after_a_failed_service_install_the_detached_bridge_checks_its_own_environment
5562717028 (nonblocking): stale test count in the body Counts above are from this head. n/a
5562940921 #1 / K1: two-pass registration split systemd values on quotes and whitespace Values are split as systemd splits them and registered raw, unescaped and quote-stripped; lines are matched in four views, never by tokenizing them. test_a_systemd_value_is_registered_whole_whatever_its_quotes_or_escapes, test_the_hatch_install_diff_registers_a_systemd_value_whole
5562940921 #2 / K2: --no-service replaced a service-owned Sparrow while the manager probe was briefly down An installed unit with an unanswering manager is UNIT_UNREACHABLE: no stop, no detached spawn, the reason printed. test_no_service_never_replaces_a_service_sparrow_while_the_manager_does_not_answer, test_an_unanswering_manager_gets_no_detached_sparrow_beside_its_unit
5562941475 B1 / Q1: masking by registry only Shape rule, applied whatever the registry holds; shaped values registered at any length. The bare-token limit is stated above. test_a_credential_shape_is_withheld_whatever_the_registry_holds, test_the_hatch_install_diff_withholds_every_credential_shape
5562941475 B2 / Q2: the probe's discard was not pinned Structural guard plus behavioural test. test_the_reachability_probe_discards_what_show_environment_prints (Guard), ReachabilityProbe.test_the_probe_discards_what_show_environment_prints
5562941475 B3 / Q3: seeding missed Hatch's default device file; the vault was read per diff Seeded from what the unit receives; once per install. test_the_hatch_install_diff_seeds_from_the_default_device_file, test_the_vault_is_consulted_at_most_once_per_install
5563096096 #1: splitlines(True) broke at U+2028/U+2029/U+0085, so the real _diff showed a credential's tail Lines end at LF only in the masker and the diff; a break inside a line is doubt (directive judged per piece; other lines withheld whole). test_only_an_lf_ends_a_line_and_no_other_break_carries_a_tail, test_the_hatch_install_diff_shows_no_tail_past_a_unicode_line_break
5563096096 #2: a failed unit-property query after a good show-environment SIGTERMed the service-owned Sparrow _unit_props returns None for a failed or unusable answer; every decider treats it as unknown: no SIGTERM, spawn, start or restart, with the reason. test_no_service_never_stops_a_service_sparrow_when_the_unit_state_query_fails, test_install_neither_stops_nor_starts_the_service_when_the_unit_state_query_fails
5563096096 #3: the Q2 guard missed constant-built show-environment The guard folds constant string expressions (+, join, f-string, .format, %) in the probe gate and the store check. test_the_reachability_probe_discards_what_show_environment_prints (eight built forms, flagged and discarded controls)
5563059400 N1: --key/--access-key/--credentials/--pat, X-Api-Key/X-Auth-Token, YAML token: printed Added as shapes; the rest is named in the stated limit. test_flag_header_and_yaml_credential_shapes, test_the_hatch_install_diff_keeps_harmless_lines_and_names
5563059400 N2: a short shaped value poisoned unrelated lines; --author, --tokenizer matched Whole-word credential_name; shaped values registered only at 8+ and not as a plain path; Bearer needs 8+. test_a_credential_name_is_a_whole_word, test_a_short_or_path_shaped_value_hides_no_other_line, test_the_hatch_install_diff_keeps_harmless_lines_and_names
5563059400 N3: a leftover unit with no user manager exited 0 with no Sparrow Exits 1 with error: Sparrow is not running: …; exits 0 only when a Sparrow holds the lock. test_a_leftover_unit_with_no_user_manager_exits_non_zero_and_starts_nothing
5563059400 N4 (carried over): the aliased discover import, **kwargs readers, allowlisted readers' insides, obfuscated keys Unchanged; stays a known limit of the structural guard. n/a

Nits N1 (which environment the diff analysed) and N3 ($'…' parsing in _manager_env) no longer apply: there is no analysis environment and no manager parse. 5562717028 #4 (the final-head live witness) stays open below.

§15 live witness plan (pending the owner; not run)

At this PR's head, restart one live bridge from the merged wheel, once per source tier, with the other tiers absent. For each, confirm from the bridge's own startup line and a delivered task:

  1. env: REMOTE_TASK_TOKEN set in the launcher environment. The bridge names the env tier, authenticates, and delivers a test task end to end.

  2. file: the token only in AG2_DEVICE_ENV (then again only in the channel .env under a non-default REMOTE_TASK_CHANNEL_DIR). The bridge names that file and authenticates.

  3. vault (macOS): the token only in the Keychain. The bridge names the vault tier and authenticates.

  4. token file: REMOTE_TASK_TOKEN_FILE set. Rewrite the file to a new token; the bridge rotates without a restart and keeps authenticating.

  5. Fail-closed rows: a refused AG2_DEVICE_ENV exits FATAL:; a refused REMOTE_TASK_TOKEN_FILE exits FATAL:.

  6. Hatch VM: install --no-service on a host whose managed unit already runs leaves that unit's Sparrow in charge (same PID, no detached Sparrow); install over a hand-written unit prints only Environment=<withheld> for its environment lines; a default-path install (no AG2_DEVICE_ENV exported) over a hand-written unit holding the live token in ExecStart shows it withheld.

  7. Hatch VM, manager stopped: install --no-service while the user manager is stopped prints the "did not answer" line, and the service's Sparrow PID is unchanged (with no Sparrow running, install exits 1 with error: Sparrow is not running).

For each restart, record the new process id (it is a new process, not the old one) and that no startup or status output contains the token. Evidence is by function only (a test room, the owner's agent); nothing identifying is posted.

Hardcoded paths

None. Test fixtures use temp directories and placeholder paths.

🤖 Generated with Claude Code

Deferred follow-ups (non-blocking, filed as one issue at merge)

Masking residuals (stated limits in #5698)

  • A token after a short flag (-t, -k), a bare base64 value, or a value under a name with no credential stem (TKN, CERT, PEM, DSN, lookalike letters, names assembled across variables) still prints in the Hatch install preview. Consider crt/pem/dsn/tkn as stems.
  • An unquoted --pa ss v is two shell words, so the value prints.
  • Over-withholding: a short shaped value such as AUTH_MODE=1 can hide an unrelated line like RestartSec=10.

Guard coverage

  • Credential-bearing reads the structural guard doesn't model: systemctl show --property=Environment, /proc/<pid>/environ.
  • Reader-guard gaps: an aliased discover import, **kwargs readers, the internals of allowlisted readers, and a helper name assembled at runtime.

Review 5563772408 (non-blocking)

  • A leading empty quoted shell fragment (run ""--pa" "ss value) is one --pa ss word but stays visible with an empty registry.
  • The suffix scan is quadratic on a long p...p=value line (16k characters: about 3.6 s).
  • The structural guard accepts a same-name imported _systemctl, or _systemctl(...) on an arbitrary receiver.

Docs

  • Wording: credential_name() is "the only name verdict" everywhere it is described.

Review 5564171259 (non-blocking)

  • Structural check: an alias snapshot taken before isolation (E = os.environ; dict(E)) is not flagged.
  • (at 5949fcf) PYTHONUSERBASE pass-through re-enables the host user site (.pth, usercustomize) in children: pass it only under coverage, or set PYTHONNOUSERSITE=1.

Update at 934e6ed (review 5564617995; merges #5704 at c621a05)

  • RealSinks.bridge() no longer copies the coverage bootstrap privately. It builds the child env with hermetic_env(), which exposes only the coverage package.
  • A synthetic coverage install with a sitecustomize.py beside it never runs in the real bridge child. In the killed control, the same plant runs once its directory is on PYTHONPATH, and restoring the removed lines fails the test.
  • Under env -i with a temp HOME and USER: gateway-credential-sources 122/122, structural 13/13, sentinels 14/14 (the credential-owner case included).
  • The §15 owner-run witness is still outstanding.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 18, 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/adapters/hatch/cli.py (92.7%): Missing lines 424-425,525,589,592,608,610
  • packages/ag2-sparrow/ag2_sparrow/adapters/hatch/keepalive.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/agent_identity.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/bounded_file_read.py (84.6%): Missing lines 28-30,45,49-50
  • packages/ag2-sparrow/ag2_sparrow/connect_common.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/gateway_credential_io.py (86.1%): Missing lines 33,42,46-47,52
  • packages/ag2-sparrow/ag2_sparrow/gateway_credential_sources.py (98.9%): Missing lines 24-25
  • packages/ag2-sparrow/ag2_sparrow/gateway_credentials.py (98.9%): Missing lines 187-188,454,492
  • packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py (94.1%): Missing lines 560-561,563
  • packages/ag2-sparrow/ag2_sparrow/service_manager.py (100%)
  • packages/ag2-sparrow/tests/test_claude_code_adapter.py (100%)
  • packages/ag2-sparrow/tests/test_hatch_adapter.py (99.3%): Missing lines 2078,2459
  • skills/agent-room-ops/gateway_credentials.py (98.6%): Missing lines 186-188,454,492
  • src/bounded_file_read.py (82.1%): Missing lines 28-30,45,49-50,57
  • src/gateway_credential_sources.py (99.5%): Missing lines 38

Summary

  • Total: 1783 lines
  • Missing: 42 lines
  • Coverage: 97%

packages/ag2-sparrow/ag2_sparrow/adapters/hatch/cli.py

Lines 420-429

  420     r = _systemctl(("--user", "show", "ag2-sparrow.service", "--property=ActiveState",
  421                     "--property=MainPID", "--property=ExecMainStartTimestampMonotonic"))
  422     try:
  423         return _parse_unit_props(r.stdout) if _ok(r) else None
! 424     except Exception:  # noqa: BLE001 - any surprise in the answer is an unknown state
! 425         return None
  426 
  427 
  428 def _parse_unit_props(stdout: str) -> "dict | None":
  429     if not isinstance(stdout, str):

Lines 521-529

  521     exec_start = _find_console_script(SPARROW_COMMAND)
  522     if not exec_start:
  523         note(f"{SPARROW_COMMAND} was not found beside {sys.executable} or on PATH; no service "
  524              "was set up. Reinstall ag2-sparrow for this Python and run install again")
! 525         return False
  526     device_env = device_env_path()
  527     try:
  528         st = device_env.stat()
  529     except OSError:

Lines 585-596

  585               f"systemctl --user show {SERVICE_NAME} then did not give a well-formed state; it stays "
  586               f"stopped until you run: systemctl --user start {SERVICE_NAME}", file=sys.stderr)
  587         return True
  588     if props is None:
! 589         note(f"systemctl --user show {SERVICE_NAME} did not give a well-formed state, so whether it runs "
  590              "is unknown; it was neither enabled, started nor restarted; run ag2-sparrow-hatch install "
  591              "again once the user manager answers")
! 592         return False
  593     state = props["ActiveState"]
  594     active = state in ("active", "reloading")
  595     main_pid = props["MainPID"]
  596     ran = props["ExecMainStartTimestampMonotonic"] != 0

Lines 604-614

  604         _systemctl(("--user", "enable", "ag2-sparrow.service"))
  605     if not _ok(r):
  606         note(f"systemctl --user enable {SERVICE_NAME} failed ({_why(r)}); fix that and run "
  607              f"systemctl --user enable --now {SERVICE_NAME}")
! 608         return False
  609     if active and changed:
! 610         r = _systemctl(("--user", "restart", "ag2-sparrow.service"))
  611         print(f"restarted: {SERVICE_NAME} with the new unit" if _ok(r) else
  612               f"NOTE service: restart failed ({_why(r)}); run systemctl --user restart "
  613               f"{SERVICE_NAME}")
  614     print(f"enabled: {SERVICE_NAME}" + (" (running)" if start or active else " (not started)"))

packages/ag2-sparrow/ag2_sparrow/bounded_file_read.py

Lines 24-34

  24         fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
  25     except OSError as e:
  26         if e.errno in _ABSENT:
  27             return None, ""
! 28         return None, f"unreadable ({errno.errorcode.get(e.errno, 'error')})"
! 29     except (ValueError, TypeError):
! 30         return None, "not a usable path"
  31     try:
  32         info = os.fstat(fd)
  33         if not stat.S_ISREG(info.st_mode):
  34             return None, "not a regular file"

Lines 41-54

  41                 break
  42             chunks.append(chunk)
  43             size += len(chunk)
  44         if size > limit:
! 45             return None, f"over {limit} bytes"  # grew past the bound after the fstat
  46         return b"".join(chunks).decode("utf-8").replace("\r\n", "\n").replace("\r", "\n"), ""
  47     except UnicodeDecodeError:
  48         return None, "not UTF-8"
! 49     except OSError as e:
! 50         return None, f"unreadable ({errno.errorcode.get(e.errno, 'error')})"
  51     finally:
  52         os.close(fd)
  53 

packages/ag2-sparrow/ag2_sparrow/gateway_credential_io.py

Lines 29-37

  29         if os.path.isfile(os.path.join(cur, "src", marker)):
  30             return os.path.join(cur, "src")
  31         parent = os.path.dirname(cur)
  32         if parent == cur:
! 33             return ""
  34         cur = parent
  35 
  36 
  37 def vault_lookup(src: str, vault_get=None) -> "Optional[Callable[[str], str]]":

Lines 38-56

  38     """The vault read for the ag2space token through the shared core `channel_token` in `src`
  39     (monorepo_src's answer): total-failure-safe, never surfacing the value; None without one."""
  40     try:
  41         if not src:
! 42             return None
  43         if src not in sys.path:
  44             sys.path.insert(0, src)
  45         from channel_token import token_from_vault
! 46     except Exception:
! 47         return None
  48     return lambda key: token_from_vault(key, vault_get=vault_get)
  49 
  50 
  51 def _vault() -> "Optional[Callable[[str], str]]":
! 52     return vault_lookup(monorepo_src("channel_token.py"))
  53 
  54 
  55 def register_candidates(env: Mapping[str, str], registry: "gateway_credentials.SecretRegistry") -> None:
  56     """Register every gateway credential candidate `env` leads to (the environment, the channel

packages/ag2-sparrow/ag2_sparrow/gateway_credential_sources.py

Lines 20-29

  20 from typing import Callable, Mapping, Optional, Sequence
  21 
  22 try:
  23     from . import gateway_credentials as contract  # the package's generated copy
! 24 except ImportError:
! 25     import ag2_gateway_credentials as contract  # the canonical shared/ module, when on the path
  26 
  27 TOKEN_KEYS = ("REMOTE_TASK_TOKEN", "AG2_REMOTE_TOKEN")
  28 URL_KEYS = ("REMOTE_TASK_URL", "AG2_REMOTE_URL")
  29 TOKEN_FILE_KEY = "REMOTE_TASK_TOKEN_FILE"

packages/ag2-sparrow/ag2_sparrow/gateway_credentials.py

Lines 183-192

  183         if len(e) > 1 and e[0] in "xuU":
  184             return chr(int(e[1:], 16))
  185         if len(e) == 3:
  186             return chr(int(e, 8))
! 187     except (ValueError, OverflowError):
! 188         return m.group(0)
  189     return _SYSTEMD_SIMPLE.get(e, e)
  190 
  191 
  192 def systemd_unescape(text: str) -> str:

Lines 450-458

  450     url, _secret = parse_onboarding_token(value)
  451     if credential_name(key) or url or (registry is not None and registry.contains(value)):
  452         return WITHHELD
  453     if "://" in value:
! 454         return redact_url(value)
  455     return value
  456 
  457 
  458 def _is_comment(line: str) -> bool:

Lines 488-496

  488             continue
  489         groups.append(current)
  490         current = []
  491     if current:
! 492         groups.append(current)
  493     return groups
  494 
  495 
  496 def _join(group: "list[str]") -> str:

packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py

Lines 556-567

  556 
  557 def _token_from_vault_ag2space(vault_get=None):
  558     """The vault tier (gateway_credential_sources.token_from_vault): the current name, then the
  559     legacy AG2_REMOTE_TOKEN alias; '' on any failure."""
! 560     lookup = _vault_lookup(vault_get)
! 561     tok = gateway_credential_sources.token_from_vault(lookup)[0] if lookup else ""
  562     if tok:
! 563         _announce_vault_token()
  564     return tok
  565 
  566 
  567 def _team_guard_fns():

packages/ag2-sparrow/tests/test_hatch_adapter.py

Lines 2074-2082

  2074         real_proc = keepalive._proc
  2075 
  2076         def read(pid, name):
  2077             if name == "cmdline" and not Path("/proc").is_dir() and pid == proc.pid:
! 2078                 return b"\0".join(a.encode() for a in self.old_argv) + b"\0"
  2079             return real_proc(pid, name)
  2080         return mock.patch.object(keepalive, "_proc", side_effect=read)
  2081 
  2082     def wait_for(self, path):

Lines 2455-2463

  2455         def systemd(cmd):
  2456             if cmd[1:] == ["--user", "show-environment"] and not next(answers, True):
  2457                 refused.append(cmd)
  2458                 return subprocess.CompletedProcess(cmd, 1, "", "Failed to connect to bus")
! 2459             return fake(cmd)
  2460         real_which, stopped, spawned = shutil.which, [], []
  2461         with mock.patch.object(hatch_cli.shutil, "which", side_effect=lambda name, *a, **k:
  2462                                "/usr/bin/systemctl" if name == "systemctl" else real_which(name, *a, **k)), \
  2463                 mock.patch.object(sm, "_run", side_effect=systemd_seam(systemd)), \

skills/agent-room-ops/gateway_credentials.py

Lines 182-192

  182     try:
  183         if len(e) > 1 and e[0] in "xuU":
  184             return chr(int(e[1:], 16))
  185         if len(e) == 3:
! 186             return chr(int(e, 8))
! 187     except (ValueError, OverflowError):
! 188         return m.group(0)
  189     return _SYSTEMD_SIMPLE.get(e, e)
  190 
  191 
  192 def systemd_unescape(text: str) -> str:

Lines 450-458

  450     url, _secret = parse_onboarding_token(value)
  451     if credential_name(key) or url or (registry is not None and registry.contains(value)):
  452         return WITHHELD
  453     if "://" in value:
! 454         return redact_url(value)
  455     return value
  456 
  457 
  458 def _is_comment(line: str) -> bool:

Lines 488-496

  488             continue
  489         groups.append(current)
  490         current = []
  491     if current:
! 492         groups.append(current)
  493     return groups
  494 
  495 
  496 def _join(group: "list[str]") -> str:

src/bounded_file_read.py

Lines 24-34

  24         fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
  25     except OSError as e:
  26         if e.errno in _ABSENT:
  27             return None, ""
! 28         return None, f"unreadable ({errno.errorcode.get(e.errno, 'error')})"
! 29     except (ValueError, TypeError):
! 30         return None, "not a usable path"
  31     try:
  32         info = os.fstat(fd)
  33         if not stat.S_ISREG(info.st_mode):
  34             return None, "not a regular file"

Lines 41-54

  41                 break
  42             chunks.append(chunk)
  43             size += len(chunk)
  44         if size > limit:
! 45             return None, f"over {limit} bytes"  # grew past the bound after the fstat
  46         return b"".join(chunks).decode("utf-8").replace("\r\n", "\n").replace("\r", "\n"), ""
  47     except UnicodeDecodeError:
  48         return None, "not UTF-8"
! 49     except OSError as e:
! 50         return None, f"unreadable ({errno.errorcode.get(e.errno, 'error')})"
  51     finally:
  52         os.close(fd)
  53 

Lines 53-58

  53 
  54 
  55 def read_regular_text(path: str, limit: int = MAX_BYTES) -> Optional[str]:
  56     """`path`'s UTF-8 text, or None when it is missing or refused (read_regular says which)."""
! 57     return read_regular(path, limit)[0]

src/gateway_credential_sources.py

Lines 34-42

  34 
  35 def token_env_name() -> str:
  36     """The token's environment variable by name, for a record that says where the token goes; another
  37     module may name it only through this call, never read the variable with it."""
! 38     return TOKEN_KEYS[0]
  39 
  40 # The precedence, stated once; the first source carrying a value wins (see discover() for each name:
  41 # "file" is the first listed .env file carrying a token, "token" the URL inside the winning one).
  42 SOURCES = (

⚠️ Not measured

Changed, but absent from coverage.xml — outside [run] source in
.coveragerc, so diff-cover never examined these lines:

  • shared/ag2_gateway_credentials.py

@sonichi
sonichi marked this pull request as ready for review October 18, 2026 08:40
@sonichi
sonichi requested a review from keweichen October 18, 2026 08:40

@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.

Review from sutando-qingyun-001 (shared qingyun-wu login), at 1c20ee4

Verdict: REQUEST_CHANGES. The split is right. The pure contract stays I/O-free, the owner opens nothing, and condition 2 (drift checks) is met. The finding-1 class is fixed, and a test fails when only the winner is registered. Not ready to merge, for four reasons:

  • condition 1 (the structural guard) is not met;
  • condition 3 is met only at the bridge;
  • the live bearer's tail leaks through a systemd continuation line;
  • the bridge's startup path has harness proof only.

The changes that would flip this to APPROVE are B1–B4 below.

Method: I verified the head with gh pr view 5698 --json headRefOid. The merge-base is the current origin/main tip, 5e632bf. I worked in a scratch worktree, now removed, with synthetic fixtures only and no .env, keychain or vault reads. review-preflight ran: no decisive review is on record, and all CI is green, including license/cla. The CI log shows tests/gateway-credential-sources.test.py was collected, in shard 5/9.

Blockers

B1. Condition 1 is not met: the guard matches literal key names, and its self-test cannot fail

Guard.test_no_other_package_module_reads_a_credential_source flags a .get/getenv/pop/setdefault call or a subscript only when the first argument is a string constant equal to a credential key. That is a name match done through the AST. It catches aliases of os.environ only because it ignores the receiver.

I dropped each probe below into ag2_sparrow/_rr_probe.py and ran the guard:

Probe Result
os.environ.get("REMOTE_TASK_TOKEN") (control) CAUGHT
e = os.environ; e.get("REMOTE_TASK_TOKEN") CAUGHT
from os import environ; environ["REMOTE_TASK_TOKEN"] CAUGHT
getattr(os, "environ")["REMOTE_TASK_TOKEN"] CAUGHT
K = "REMOTE_TASK_TOKEN"; os.environ.get(K) MISSED
os.environ.get("REMOTE_TASK_" + "TOKEN") / f-string key MISSED
os.environ.get(g.TOKEN_KEYS[0]) (the owner's own constant) MISSED
[v for k, v in os.environ.items() if k.endswith("TASK_TOKEN")] MISSED
d = dict(os.environ); d[k] MISSED
os.environ.__getitem__("REMOTE_TASK_TOKEN") MISSED
Path("~/.config/ag2-sparrow/relay.env").expanduser().read_text() MISSED
open(os.environ.get("AG2_DEVICE_ENV")).read() MISSED
subprocess.run(["security", "find-generic-password", ...]) MISSED
from channel_token import token_from_vault; token_from_vault("REMOTE_TASK_TOKEN") MISSED

So the file reads and the keychain/vault calls that condition 1 named are not covered at all.

The self-test is vacuous. test_the_guard_sees_each_read_shape parses four snippets and asserts that each one's key is in SOURCE_KEYS. It never runs the detector on them. Mutation M1 replaced the detector's condition with if False:, and the suite still reported 35/35 OK. That is lesson 9: the instrument was never shown able to fire.

Ask:

  • Factor the detector into a function, and have the self-test assert CAUGHT for each bypass row above.
  • Flag every string constant equal to a credential key outside the owner, wherever it appears (including inside BinOp and JoinedStr), and every use of the owner's key tuples outside the owner.
  • Allowlist by (module, function) the places that may do credential I/O: open, read_text and read_bytes on credential paths, import channel_token, and security subprocess calls. Today those are the bridge's _channel_env_candidates, _read_text and _vault_lookup, and Hatch's _read_text.

The design keeps I/O at the adapter edge, so the guard should pin where that I/O may happen, not ban it.

B2. The live bearer's tail leaks through a systemd continuation line

systemd joins a line that ends in \ with the next line. mask_env_assignments masks per physical line. The test used a registered (live) synthetic token Synth9Tok-en-Q7zZ-live:

'Environment=REMOTE_TASK_TOKEN=Synth9Tok-\\\nen-Q7zZ-live\n'
  -> 'Environment=<withheld>\nen-Q7zZ-live\n'

The first line is withheld because shlex fails on it. The second line holds only a piece of the token, so registry.contains misses it, and 12 of the 22 characters print in the install diff. The same happens with an unregistered (retired) token whose "REMOTE_TASK_TOKEN=…" word sits on the continuation line: it prints whole.

Ask: fold \-continued lines into one logical line before masking, withhold every physical line of a withheld logical line, and add both cases to test_install_diff_masking_….

B3. Condition 3 is met only at the bridge, and SOURCES drives nothing

The same ROWS drive both real sinks, which is good. But the Hatch test asserts only that candidates are withheld.

  • _credential_registry() calls discover() and throws the result away, so no test shows that Hatch resolves a row to the same winner as the bridge.
  • Hatch also builds its own file list. It calls channel_env_paths(env) with the default "ag2space" channel dir, while the bridge passes CHANNEL_DIR, which honours REMOTE_TASK_CHANNEL_DIR. So the two sinks' source sets can already differ. That is the drift condition 3 exists to stop.

SOURCES is a data pin, not the precedence. discover() hard-codes the order.

  • Mutation M2 reordered SOURCES to ("vault", "env", "file") and changed nothing that runs. Only test_the_table_is_stated_once (the literal) and the drift check failed.
  • Mutation M7 put the file tier before env, and the ROWS tests caught it.
  • So the effective spec is ROWS plus the body of discover(), and SOURCES can go stale beside them. That is two statements of the precedence, not one.

Ask:

  • Make discover() iterate SOURCES, or derive each row's expected winner from SOURCES in the test.
  • Have Hatch build its files through one owner helper that takes an injected reader and the channel dir, so both sinks share one candidate set.
  • Assert in the Hatch sink test that Hatch's discovery equals each row's expectation, as the bridge test does.

"Same masked status" cannot be checked yet, because the bridge's own sinks are not routed through the contract here (the narrowed claim). Say in the body that this half of condition 3 lands in #5665.

B4. Live path: startup has harness proof only

This rewrites the bridge's import-time credential resolution: startup, auth header and token file. The child-process import test is good harness proof, but REVIEW.md lesson 15 and CONTRIBUTING require a real post-restart round trip. Please paste one: restart a Sparrow on a file-tier token, plus one on an env token, and show a task delivered end to end.

Checked and fine

  • Condition 2 (drift): met.

    • cmp src/gateway_credential_sources.py packages/.../gateway_credential_sources.py shows the two are identical, and the test asserts it.
    • The package and skill contract copies differ from shared/ only by the 3-line GENERATED header.
    • tools/sync_gateway_credentials.py --check returned "ok: 2 generated copies match canonical", and sync_from_src.py --check returned "in sync".
    • Mutation M6 (one character changed in the skill copy) fails the drift test.
  • Finding 1 class: fixed, and the test discriminates.

    • Main's adapters/hatch/cli.py dropped into the head tree fails the Hatch sink test on 6 of 6 rows, because env-token-…, device-token-…, channel-token-… and retired-token-… print in the diff.
    • Mutation M3 (register only the winner) fails 16 tests, including both real-sink tests.
    • Mutation M4 (Hatch registers files only, not env) fails 3.
    • The wake-context half is #5665's, as the body says.
  • A losing source's secret is masked: a losing env, device, channel or token-file token is registered and withheld (rows 1, 2, 5 and 6). The vault is the exception: it is read only when it wins, so a losing vault value is never held. That is acceptable, but see N6.

  • Purity: the contract and the owner have no environ, open, read_text, getenv, subprocess, print or global. The module-level assignments are constants, regexes and the _EAI table built from socket constants. The registry is per instance and lock-guarded.

  • Masking that works. These fuzz cases all came out withheld:

    • case variants, percent-encoding in upper and lower hex, and double encoding;
    • repr and unicode_escape renderings;
    • systemd Environment="A=x" "REMOTE_TASK_TOKEN=…" B=y (shown as A=x REMOTE_TASK_TOKEN=<withheld> B=y) and the single-quoted form;
    • URL userinfo, query and fragment, both under a key and inside ExecStart;
    • https://gw|tok and %7C onboarding strings under a neutral key.

    Mutation M5b (words after the first shown raw) and mutation M8 (credential-name masking dropped) are caught.

  • Bridge strings: "token not in env; loaded from {path}", "token not in env or .env; loaded from vault" and the deprecation line are byte-identical to main, and print in the same order.

  • Architecture: the policy is in src/, vendored, with I/O injected, and the adapters keep their reads. This matches "Shared adapter policy".

  • Tests at head:

    Suite Result
    tests/gateway-credential-sources.test.py 35/35
    test_hatch_adapter 176 passed, 2 skipped
    test_env_token_quotes 15
    test_vault_token_tier 7
    test_gateway_status 4
    test_wheel_contents + test_pool_advertisement_push 31
    channel-env-order-contract 6
    gateway-channel-dir 2
    gateway-auto-reenroll 39
    credential-contract-golden 37/37
    gateway-proactive-room-from-channel-env 11
    gateway-channel-skip-owner rc 0
    src/remote-gateway-bridge.test.py FAILED (8) on this host, but the same 8 fail on origin/main (the proactive/nudge delivery cases), and CI is green
  • review-checks.sh passes on the diff; the control /Users/someone/foo is flagged. /home/x/relay.env is a fixture placeholder. Every new comment is at most 2 lines.

Nits (non-blocking)

  • N1. Masking gaps that need an unregistered (retired) secret, or an unusual escape.

    • KEY = v with spaces is accepted by the owner's own parse_env_text, so the parser and the masker disagree on what counts as an assignment.
    • Environment = "REMOTE_TASK_TOKEN=…" (systemd accepts spaces around the =).
    • Credential names the regex misses: PRIVATE_KEY, DB_PASS, BEARER, SESSION_COOKIE.
    • JSON and YAML lines, --token <v> / --token=<v> in ExecStart, and scheme-less u:<secret>@host.
    • A registered token rendered with a JSON-escaped \/ or &#47; also leaks.
  • N2. Undeclared bridge behaviour changes. The body says nothing changes beyond finding 1, but three things do:

    • _read_token_file now swallows UnicodeDecodeError. On main it propagated out of _reload_rotated_token; now that read means no rotation.
    • REMOTE_TASK_TOKEN_FILE is now read at import, to register its contents.
    • With REMOTE_TASK_TOKEN unset and AG2_REMOTE_TOKEN='""', main printed the deprecation warning and this head does not.

    All three are defensible; name them in the body.

  • N3. The guard covers the package only. src/ still resolves REMOTE_TASK_TOKEN itself in src/health-check.py:4091 (its own REMOTE_TASK_TOKEN or AG2_REMOTE_TOKEN precedence), src/runtime-health.py:236 and src/runtime-api/dispatcher.py:54. List them as follow-ups on this owner.

  • N4. The purity test checks one file three times. test_the_contract_is_pure always parses shared/, whatever self.c is. It is safe because of the drift check, but it reads as covering three copies.

  • N5. The kept wrappers test a path production no longer takes. _token_from_ag2space_env and _token_from_vault_ag2space are kept for the legacy tests, but the import path now calls discover() directly. That is fine, as long as the real-sink test stays the production pin.

  • N6. Hatch never registers the vault. In the "vault is last" row, the vault token under a neutral key still prints in the Hatch diff, and the test skips that case deliberately. This is acceptable where the vault is macOS-only; say so.

  • N7. A comment references an issue. The _vault_lookup docstring carries "(#2638)", which CLAUDE.md's comment rule says belongs in the commit message. It was carried over from main.

Worst-case disruption

The bridge's precedence is unchanged:

  • all six ROWS hold through the real bridge;
  • the legacy env, vault, quote and channel-order suites pass;
  • the only behaviour shifts are the ones listed in N2.

Worst case: a Sparrow starts with a different token than before, if some input falls outside ROWS. B4's live round trip is the mitigation.

Hatch now reads the installer's environment and device file during a diff. It only reads them, and a failed read gives "".

Evidence scripts are in the scratchpad: rr5698-fuzz.py, rr5698-mut.sh, rr5698-bridge-{head,main}.out.

@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.

Review from sutando-qingyun-001 (shared qingyun-wu login), at ef5ad0d

Verdict: REQUEST_CHANGES, narrowed. The code is close to approvable. All three of my conditions are now substantially met, and every blocker from the 1c20ee4 review moved except one: the live witness has not been run. REVIEW.md lesson 15 does not let me approve a startup-path rewrite on harness proof alone. I also found one small guard hole that I would like closed in the same push.

Two changes would flip this to APPROVE:

  • R1: run the §15 witness at this head, with the plan amended as described below.
  • R2: two one-line guard fixes.

Method:

  • I verified the head with gh pr view 5698 --json headRefOid. The merge-base is still 5e632bf (the origin/main tip). The two new commits are 76c3885 and ef5ad0d.
  • I used a scratch worktree (now removed) and synthetic fixtures only. Nothing read .env, the keychain or the vault.
  • CI: 36 checks pass and 3 are skipped. review-checks.sh passes on the diff.
  • The thread already holds one CHANGES_REQUESTED review from this shared login at 1c20ee4. When this goes to APPROVE, replace that review rather than stacking a second one.

Blockers

R1. The §15 witness is planned but not run, and the plan needs three amendments

The body's plan is one restart per tier (env, file, vault, token file), plus a new PID and no token in the output. The shape is right, but it is marked "to be scheduled, not yet run". Lesson 15 asks for the run itself at this head before approval. Amend the plan so each run proves what it claims:

  1. Every tier needs a delivered task. As written, only tier 1 (env) delivers a task end to end. Tiers 2 to 4 only "authenticate". The file tier through AG2_DEVICE_ENV is the desktop-spawned case the old code comment called "the case that matters". It needs a task flowing through the restarted bridge, not just a 200 on the first request.
  2. The env tier has no startup line to name. The bridge prints a source line only for the file and vault tiers, so "the bridge names the env tier" cannot be observed. Say instead that no "loaded from" line is printed and the task is delivered. Do not add a new string to the bridge.
  3. Name the build. The plan says "at this PR's head … from the merged wheel". Say it is a wheel built from ef5ad0d, or from the final head if it moves. Record that sha with each run.

R2. The guard still misses a key built from a partly dynamic expression, and a reader handed to the owner as a lambda

I re-ran every bypass from my last review, plus some new shapes. I put each one in ag2_sparrow/_rr_probe.py and ran Guard.test_no_other_package_module_touches_a_credential_source.

Bypasses from the last review:

Probe Result
a key held in a variable (K = "REMOTE_TASK_TOKEN"; os.environ.get(K)) CAUGHT
a key built with + from constants CAUGHT
g.TOKEN_KEYS[0] CAUGHT
iterating os.environ.items() with an endswith("TASK_TOKEN") filter CAUGHT
d = dict(os.environ); d[k] CAUGHT
os.environ.__getitem__(...) CAUGHT
Path("…/relay.env").read_text() CAUGHT
open(os.environ.get("AG2_DEVICE_ENV")) CAUGHT
a security find-generic-password subprocess, including when security is built in a variable CAUGHT
from channel_token import token_from_vault CAUGHT
the f-string from my last list: n = "TOKEN"; os.environ.get(f"REMOTE_TASK_{n}") MISSED

The author's self-test covers f"REMOTE_TASK_{'TOKEN'}", which is an all-constant f-string. My shape interpolates a variable, and that is the one still missed.

The same root cause misses these too:

  • "REMOTE_TASK_" + n;
  • "REMOTE_TASK_" + os.environ.get("SUFFIX", "TOKEN").

Cause: credential_access skips any constant whose parent is a BinOp, JoinedStr or FormattedValue. It then relies on _constant() to fold the whole expression, and _constant() returns None as soon as one part is not a constant. So the 12-character piece "REMOTE_TASK_" is never checked on its own.

Fix: skip a child only when its parent folds to a string, for example _constant(parent) is not None.

Also missed: a lambda reader handed to the owner. For example: g.discover(os.environ, [], read_token_file=lambda p: open(p).read()).

  • The reader check only looks at Name nodes starting with _read.
  • The scope check never marks open(p) here, because the lambda's argument p carries no credential marker.
  • That is exactly the seam the injected-I/O design creates.

Fix: flag any Lambda passed as read_token_file or vault_lookup (or as env_files's reader) whose body is not a call to an allowlisted name. The bridge's own vault_lookup=lambda key: (_vault_lookup() or …)(key) stays allowed.

Add both shapes to test_the_detector_catches_every_bypass.

Still missed, and acceptable for an AST guard: I record these but do not ask for them.

  • Keys built as "_".join([...]), b"…".decode(), "%s" % formatting or [::-1].
  • io.FileIO.
  • The keyring module, importlib.import_module("channel_" + "token") and osascript.
  • Path(p).read_text() on an untainted argument.

None of these appears in the package today.

The other checks

The if False: detector mutant is now caught. Three disabling mutants each fail Guard.test_the_detector_catches_every_bypass:

  • the key check set to if False:;
  • credential_access returning [] straight away;
  • the I/O check set to if False:.

The vacuous self-test from the last review is fixed.

Reordering SOURCES changes the winner at both sinks.

  • On row 1 (env, device file and channel file), the unmodified table gives env-token-AAAA1111 from env at both the bridge and Hatch.
  • With the package SOURCES token tier reordered to ("file", "env", "vault"), both give device-token-BBBB2222 from the file tier.
  • The full suite under that mutant fails 12 tests, including both real-sink tests and the row test.
  • discover() now walks SOURCES, so the table is the precedence. Script: rr5698-reorder.py.

The continuation leak (B2) is gone. Each of these is now fully withheld:

  • the split credential-named value Environment=REMOTE_TASK_TOKEN=Synth9Tok-\ + en-Q7zZ-live, which prints REMOTE_TASK_TOKEN=<withheld> <withheld>;
  • a credential word on the continuation line, registered or retired;
  • the unregistered split value.

Mutation M9 (folding disabled) fails the masking test. One residual is left (N1).

Other mutants (rr5698-mut.sh; the guard mutants were run by hand):

Mutant Tests failing
register only the winner 23
Hatch registers files only 6
only the first Environment= word masked (shared) caught by the drift check
one character changed in the skill copy 1 (drift)
credential-name masking dropped 10
whole-value registration dropped 23, including test_a_url_shaped_bearer_is_registered_whole
env_files ignores the channel directory 3, at both sinks

Fuzz (rr5698-fuzz.py). These leaked at 1c20ee4 and are now masked:

  • KEY = v;
  • Environment = …;
  • both continuation forms;
  • PRIVATE_KEY, DB_PASS, BEARER and SESSION_COOKIE.

The remaining leaks are listed under N2.

Conditions 2 and 3.

  • Drift: the owner copies are byte-identical (cmp), both --check tools pass, and a one-character change to the skill copy is caught.
  • Hatch now builds its files through the shared env_files / channel_dir.
  • The Hatch sink test asserts the same row resolution as the bridge, except where the vault wins (Hatch has no vault, which the body documents).
  • The masked-status half of condition 3 is explicitly moved to #5665 in the body. I accept that narrowing.

Finding 1 is still fixed, and the test still discriminates (23 failures when only the winner is registered).

Purity still holds. test_the_contract_is_pure now parses each copy's own file (Path(self.c.__file__)), which fixes the old N4.

Bridge behaviour.

  • A file value is unquoted once again; the 1c20ee4 double unquote is fixed and pinned by test_a_file_value_is_unquoted_once.
  • The deprecation warning prints whenever the old name is set and the new one is not, which matches main's _env_compat.
  • The startup strings are unchanged.
  • The body now lists the three behaviour changes: a non-UTF-8 token file means no rotation, the token file is read at import, and the warning keeps main's behaviour. The non-UTF-8 case has a real-bridge test.

Test runs at this head:

Suite Result
tests/gateway-credential-sources.test.py 42/42
test_hatch_adapter 176 passed, 2 skipped
env-quotes, vault-tier, gateway-status, wheel-contents, pool-advertisement 57
channel-env-order-contract 6
gateway-channel-dir 2
gateway-auto-reenroll 39
credential-contract-golden 37/37
gateway-proactive-room-from-channel-env 11
gateway-channel-skip-owner rc 0
census-d1-anchors 4
src/remote-gateway-bridge.test.py FAILED (8) on this host

The 8 bridge-test failures are the same 8 that fail on origin/main (diffed), and CI is green.

Nits from the last review, rechecked:

Nit Status
N1 masking gaps mostly fixed; residual in N2
N2 undeclared behaviour changes fixed (listed in the body)
N3 src/ readers listed as a linked follow-up
N4 purity test reads one copy fixed
N5 legacy wrappers unchanged, fine
N6 Hatch and the vault documented
N7 (#2638) in a docstring removed

All added comments are within 2 lines (the prose-cap check passes).

Nits (non-blocking)

N1. A fragment of a registered token survives on a continued line whose key is neutral or which is not an assignment.

Input Output
Environment=X=Synth9Tok-\ + en-Q7zZ-live Environment=X=Synth9Tok- <withheld>
ExecStart=/x Synth9Tok-\ + en-Q7zZ-live printed raw: the joined text has a space between the pieces, so contains misses it

The same happens with CRLF line endings, a # comment inside the continuation, and an empty continued line.

systemd replaces the backslash with a space, so in every one of these cases the effective configuration does not hold the full token. That is why this is a nit and not a blocker.

Cheap fix: also test the joined logical line with all whitespace removed against the registry, and withhold the whole logical line on a hit.

N2. Masking leaks that remain (from my last list), for a retired, unregistered secret:

  • JSON or YAML lines;
  • --token v / --token=v in ExecStart;
  • a scheme-less u:<secret>@host;
  • a credential assignment inside a quoted value (Environment="NOTE=see REMOTE_TASK_TOKEN=…");
  • a percent-encoded key name.

For a registered secret: \/ and &#47; renderings, and a secret split across two separate (non-continued) lines.

An install diff over a unit or .env file rarely contains these. Fine as follow-ups.

Worst-case disruption

The precedence is unchanged on every row, now including REMOTE_TASK_CHANNEL_DIR and a URL-shaped bearer. The double unquote that 1c20ee4 introduced is gone.

The remaining risk is an input outside the rows that starts a Sparrow with a different token. The R1 witness is the mitigation. Hatch's new reads are read-only and fail to an empty value.

Evidence (scratchpad): rr5698-fuzz.py, rr5698-mut.sh, rr5698-guard-probes.py, rr5698-reorder.py, rr5698-v2-bridge-fails.out (versus rr5698-bridge-main.out).

@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.

Review from sutando-qingyun-001 (shared qingyun-wu login), at e92c073

Verdict: COMMENT. The code is approvable at this head. The APPROVE follows the per-tier §15 witness, run at this exact head (REVIEW.md lesson 15). No code blocker remains. Nothing is posted.

When the APPROVE is posted, it replaces the two CHANGES_REQUESTED reviews from this shared login, at 1c20ee4 and ef5ad0d. It does not stack on top of them.

Method

  • Head. I checked it with gh pr view 5698 --json headRefOid. The merge-base is still 5e632bf.
  • What changed. One commit since ef5ad0d (e92c073), and it touches only tests/gateway-credential-sources.test.py. git diff --quiet ef5ad0d7 HEAD -- src shared skills packages is clean, so my ef5ad0d findings on the code still stand.
  • Checks.
    • CI: 36 checks pass and 3 are skipped.
    • review-checks.sh passes.
    • Both --check sync tools pass.
    • The suite passes, 42/42.
  • Data. A scratch worktree, now removed. Synthetic fixtures only; no .env, keychain or vault reads.

The guard (R2 from v2): fixed

Both of the new rules work:

  • A constant inside a + or f-string is skipped only when the whole expression folds to a constant.
  • A reader handed to discover() or env_files(), including a lambda's body, may name only allowlisted readers or its own parameters.

The self-test now runs 18 bypass shapes.

Old probes (rr5698-guard-probes.py) are all caught now. These include:

  • the partly dynamic f-string n = "TOKEN"; os.environ.get(f"REMOTE_TASK_{n}"), which is the one still missed at ef5ad0d;
  • "REMOTE_TASK_" + n;
  • "REMOTE_TASK_" + os.environ.get("SUFFIX", "TOKEN");
  • the lambda reader discover(..., read_token_file=lambda p: open(p).read());
  • every other probe from the 1c20ee4 list.

New reader variants (rr5698-guard-probes-v3.py), each placed as a package module:

Variant Result
a lambda wrapping an allowlisted reader with extra I/O (lambda p: _read_text(p) or open(p).read()) CAUGHT
the same lambda with a subprocess.run side effect CAUGHT
functools.partial around a lambda that opens the file CAUGHT
functools.partial(_read_text, x=1) CAUGHT
a nested def rd(p) passed by name CAUGHT
a nested def _read_text that shadows the allowlisted name, inside an allowlisted module CAUGHT (by its enclosing function's I/O scope)
a reader bound to a variable first CAUGHT
self._read_text method reader CAUGHT
env_files(env, read_text=lambda p: open(p).read()) (the reader passed as a keyword) MISSED
from .gateway_credential_sources import discover as d; d(..., read_token_file=lambda p: open(p).read()) MISSED
discover(env, [], **{"read_token_file": lambda p: open(p).read()}) MISSED

The guard's own mutants. Each mutant below fails Guard.test_the_detector_catches_every_bypass:

  • G1: the key check set to if False:;
  • G2: the detector returning [] straight away;
  • G3: the I/O check set to if False:;
  • G4: the fold rule reverted;
  • G5: the reader rule reverted to _read* names only;
  • G6: the reader rule disabled.

Code mutants still fail as at ef5ad0d:

Mutant Tests failing
SOURCES reordered 12, and the winner changes at both the bridge and Hatch
registering only the winner 23
continuation joining disabled 2
one character changed in the skill copy 1 (the drift check)

Fuzz. rr5698-fuzz.py gives the same 11 residual leaks as at ef5ad0d, all of them nits listed in v2. The code is unchanged since then, and the continuation fix still holds.

Before the APPROVE: the §15 witness at e92c073

The body's plan is still marked "to be scheduled, not yet run". It is also unchanged from ef5ad0d, so the three amendments from v2 still apply:

  1. Every tier delivers a task end to end, not only env. The file tier through AG2_DEVICE_ENV (the desktop-spawned case), the non-default REMOTE_TASK_CHANNEL_DIR run, the vault and the token-file rotation each show a task flowing through the restarted bridge.
  2. The env tier is shown by its absence. No "loaded from" line prints, and the task is delivered. The bridge prints no env-tier line, and none should be added.
  3. The build is named. Use a wheel built from e92c073, or from the final head if it moves, with the sha recorded per run and a new PID per restart. Also show that the token appears in no startup or status output.

If the head moves before the witness, the witness moves with it.

Nits (non-blocking, follow-up is fine)

N1. Three dispatch shapes slip past the reader rule (the three MISSED rows above):

  • env_files with its reader as a keyword. handed reads only args[1:2] for env_files. Also read call.keywords with arg == "read_text".
  • The owner imported under an alias. The callee name is matched literally. Resolve ImportFrom aliases of discover and env_files.
  • **kwargs. Flag any ** argument to the owner.

No module does any of these today.

N2. The insides of an allowlisted reader are not inspected. An allowlisted _read_text that also calls the security command, or opens os.environ["AG2_DEVICE_ENV"] directly, passes. Allowlisting by (module, function) means exactly this, so it is acceptable. Keep those readers to one line, as they are now.

N3. Obfuscated keys still pass. "_".join([...]), b"…".decode(), % formatting, [::-1], io.FileIO, keyring, importlib.import_module("channel_" + "token") and osascript are not caught. That is fine for an AST guard.

N4. v2's N1 and N2 still stand.

  • N1: a fragment of a registered token can print on a continued line whose key is neutral, or which is not an assignment. systemd never sees the full token there.
  • N2: these renderings still leak:
    • JSON or YAML lines;
    • --token flags;
    • u:<secret>@host with no scheme;
    • an assignment inside a quoted value;
    • a percent-encoded key name;
    • a registered token written with \/ or &#47;, or split across two ordinary lines.

Worst-case disruption

Unchanged from v2. The precedence is pinned by eight rows at both real sinks, and the non-default channel directory is covered. The remaining risk is an input outside those rows that starts a Sparrow with a different token. The per-tier witness above is the mitigation, and it gates the APPROVE.

Evidence (scratchpad): rr5698-guard-probes.py, rr5698-guard-probes-v3.py, rr5698-mut.sh, rr5698-fuzz.py, rr5698-reorder.py.

@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.

Review of exact head e92c073cb5934dbbaaedf2394e663cf2d7f7e8f5 (merge-base 5e632bf7267338db1c47a080879f99d5ae57e741).

Verdict: REQUEST_CHANGES. CI and CLA are green, and the intended precedence/unquoting/non-UTF-8 improvements largely work, but the credential-masking contract is still incomplete and the startup rewrite still lacks the required live witness.

Merge blockers

  1. A systemd comment block inside a continued credential assignment exposes the suffix.

    shared/ag2_gateway_credentials.py:374-383 ends the logical group at every physical line that does not itself end in \. That does not match systemd: comments following a continuation are ignored and continuation resumes at the next non-comment line (systemd.syntax). The generated copies inherit the same defect.

    Synthetic input (the first line ends in a literal backslash):

    Environment="REMOTE_TASK_TOKEN=retired-first-9382\
    # comment
    retired-last-8372"
    

    Exact-head output from mask_env_assignments, also reproduced through Hatch's real _install_file diff path:

    Environment=<withheld>
    retired-last-8372"
    

    The suffix must not survive. Preserve the continuation across intervening comment blocks, regenerate both copies, and pin the real sink with a discriminating regression.

  2. Explicit token-file discovery does not register every materialized candidate.

    src/gateway_credential_sources.py:117-145,209-214 reduces token-file assignments to the winning alias (and last duplicate) before registration. With:

    REMOTE_TASK_TOKEN=current-file-9821
    AG2_REMOTE_TOKEN=legacy-file-7364
    

    and an environment token winning overall, exact-head output is:

    registry.contains("current-file-9821") -> True
    registry.contains("legacy-file-7364")  -> False
    mask_env_assignments("Environment=NOTE=legacy-file-7364\n", registry)
      -> "Environment=NOTE=legacy-file-7364\n"
    

    Thus a discarded credential that was actually read can still print in the Hatch diff. This contradicts the PR's “register every materialized candidate before it picks the winners” contract. Register all parsed token/URL assignments, including losing aliases and overwritten duplicates, before selecting winners.

  3. The R2 structural guard still misses valid injected-reader forms.

    tests/gateway-credential-sources.test.py:513-522 does not collect env_files(..., read_text=...), and it exempts every lambda parameter name even when a default binds that name to forbidden I/O. Both probes return []:

    env_files(env, read_text=lambda p: open(p).read())
    discover(env, files,
             read_token_file=lambda p, open=open, read=None: open(p).read())

    The self-test is non-vacuous, but it does not exercise either form. Cover the keyword reader and make lambda binding analysis scope-aware so the guard enforces the ownership boundary it claims.

  4. The new eager read can hang bridge startup on a non-regular credential path.

    remote_gateway_bridge.py:1659-1671 uses unbounded Path.read_text() during import, even when a valid environment token already wins. With REMOTE_TASK_TOKEN_FILE naming a synthetic FIFO with no writer and a valid env token/URL, the same sanitized import probe produced:

    base: completed, rc=0, stdout="imported"
    head: timed out importing bridge after 1.0s
    

    Eager registration of regular files is the declared behavior; blocking forever on a FIFO/device is not. Reject or safely bound non-regular/blocking sources before the read and add an import regression.

  5. The required real post-restart witness is still absent.

    The PR body still says “to be scheduled, not yet run.” Before approval, run it at the final exact head/build: each env, device/channel file (including non-default channel dir), vault, and token-file rotation tier must deliver a task end to end after a real restart; record the build SHA and new PID; for the env tier prove delivery plus the expected absence of a “loaded from” line; and show no credential appears in startup/status output. Harness results do not substitute for this live-path evidence under REVIEW.md lesson 15.

Verified

  • python3 tests/gateway-credential-sources.test.py: 42/42 pass.
  • Remote bridge, credential golden, vault, env-quote, and Hatch suites pass in a sanitized synthetic environment (Hatch: 176 pass, 2 skip).
  • Both sync checks, review-checks.sh, and git diff --check pass; the worktree stayed clean.
  • Whole URL-shaped bearer registration, SOURCES-driven precedence, exactly-once unquoting, and non-UTF-8 no-rotation behavior pass independent probes.
  • Current GitHub CI and CLA are green.

Worst case is a live or retired credential printed in an install diff, or every bridge restart hanging on an existing non-regular path. Fixing the four code/test blockers and supplying the exact-final-head live witness would change my verdict. All review probes used synthetic values; I did not restart a service or touch live credentials.

@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.

Review from sutando-qingyun-001 (shared qingyun-wu login), at 7811176

Review action: COMMENT (the §15 witness still waits for the owner's go). Merge-readiness: not approvable at this head. Two code blockers are open.

All four claimed fixes work, and each fails its test when reverted. The FIFO hang is gone. But the two things I was asked to probe both turned out to be real:

  • B1: the "known gap" about a repeated .env key is a leak.
  • B2: a refused credential file silently changes which credential wins.

Method

  • Head. I checked it with gh pr view 5698 --json headRefOid. The merge-base is still 5e632bf. The only commit since e92c073 is 7811176.
  • Workspace. A scratch worktree, now removed. Synthetic fixtures only; no .env, keychain or vault reads.
  • Checks.
    • CI: 36 checks pass and 3 are skipped.
    • review-checks.sh: pass.
    • Both --check sync tools: pass.
    • src/bounded_file_read.py matches its vendored copy byte for byte.
  • Prior reviews on the thread.

Blockers

B1. A key repeated inside one channel .env leaves its earlier value unregistered, and the Hatch install diff prints it

parse_env_text builds a dict, so the last assignment wins. discover() registers only values.get(key) from each file. The earlier value was read from the host's own credential file, yet it is never registered.

I wrote a device .env holding:

REMOTE_TASK_TOKEN=early-dup-token-1111
REMOTE_TASK_URL=…
REMOTE_TASK_TOKEN=late-dup-token-2222

The installed unit carried both tokens under neutral keys. The real _install_file diff showed:

-Environment=NOTE=early-dup-token-1111     <- printed
-Environment=NOTE2=<withheld>
-ExecStart=<withheld>
File holding the duplicate Early value in the diff Late value in the diff Bridge registry (early / late)
device .env printed withheld False / True
channel .env printed withheld False / True

This is the same defect class this PR fixed for the token file (fix 2) and for finding 1: a credential the host read but never registered. The install diff is the one production sink so far.

The other sinks:

  • safe_status, code_field, redact_text and scrub have no production callers at this head. describe never renders exception text.
  • So the install diff is the only exposure today. Once #5665 routes the bridge's sinks through _SECRETS, those sinks inherit the same hole.

So this is a leak, not a gap.

Fix: register every assignment of a credential key in each channel .env, the same way _assignment_pairs now does for the token file. Then add the duplicate-key fixture to both real-sink tests.

The fix could have env_files carry the duplicate-preserving pairs, or the raw text, next to values. Precedence is unchanged by this: the last value still wins.

Probe: rr5698-dupkey.py.

B2. A refused credential file is treated as absent without a word, so the winner changes silently

read_regular_text returns None for every outcome:

  • missing;
  • not a regular file;
  • over 1 MiB;
  • not UTF-8;
  • an OSError.

env_files then skips the file, and discover() moves on to the next tier.

I wrote a device file holding device-refused-tok-3131 and a channel .env holding channel-fallback-tok-4242, then ran the real bridge in a child process:

Device file Winner Does stderr name the refused file? Hatch diff prints the device token?
over 1 MiB channel token no yes
not UTF-8 channel token no yes
FIFO channel token no (not run)

In each case the only line is token not in env; loaded from <channel .env>.

Compared with main:

  • Over 1 MiB: main read the file, so the device token won. This head silently connects with a different token, which may be a different identity. That is the wrong-identity hazard the bridge's own AG2_DEVICE_ENV comment warns about.
  • FIFO: main hung. Not hanging is right, but falling through without a word is not.
  • Not UTF-8: main also skipped silently. So this one is pre-existing, but it is the same path and should get the same report.
  • Hatch: a refused file's tokens are never registered, so the install diff prints them. The table above shows this for the over-1 MiB and not-UTF-8 cases.

Fix:

  • Have the reader report why it refused (for example, return a reason next to None, or raise a typed refusal).
  • In the bridge, print one stderr line naming the path and the reason class. Never print the content.
  • In Hatch, print a matching NOTE.

Recommended (REVIEW.md lesson 4, fail closed):

  • When the refused file is the explicitly named AG2_DEVICE_ENV, do not fall through to another file's token. Stop the file tier with that message.
  • At minimum, mention the refusal next to the fallback line, so "loaded from X" is never the only clue.

Add a real-bridge test that asserts the message and the chosen behaviour for the over-1 MiB and FIFO cases.

Probe: rr5698-refused.py.

The four claimed fixes: verified

Fix Test(s) that fail when it is reverted
1. A comment line inside a continuation does not end it Reverted in the package contract: test_a_comment_inside_a_continuation_does_not_end_it, test_the_hatch_install_diff_masks_past_a_comment_in_a_continuation, plus the drift check (3 failures). Reverted in shared/: the shared copy's test plus the drift check.
2. Every token-file assignment is registered test_every_token_file_assignment_is_registered, test_the_hatch_install_diff_withholds_a_losing_token_file_alias, plus the drift check
3a. The guard catches read_text= Guard.test_the_detector_catches_every_bypass
3b. The guard catches I/O bound in lambda defaults Guard.test_the_detector_catches_every_bypass
4. One bounded, non-blocking reader Fix reverted (open without O_NONBLOCK): test_a_fifo_credential_path_never_hangs_the_bridge_import errors on both REMOTE_TASK_TOKEN_FILE and AG2_DEVICE_ENV, and BoundedRead fails

FIFO at head: read_regular_text on a FIFO with no writer returns None in 0.001 s.

The reader's newline handling matches the old text-mode reads. \r\n and \r become \n, which is what Path.read_text and open() already did. No value changes.

Probes and mutants re-run

  • The suite passes, 51/51.

  • rr5698-guard-probes.py: all 15 targeted probes are caught, including every one from the 1c20ee4 list, the partly dynamic f-string and concatenation, and the lambda reader. The other 11 misses are the obfuscation and residual shapes already accepted in v3.

  • rr5698-guard-probes-v3.py: env_files(env, read_text=lambda …) is now caught. Still missed:

    • discover imported under an alias;
    • a reader passed through **kwargs.
    • These are v3 N1 and remain nits.
  • Unchanged from v3:

    • the guard and code mutants (detector disabled, SOURCES reordered at both sinks, only-winner registration, continuation join, skill-copy drift);
    • the fuzz, which still shows the same 11 residual nits.
  • Existing suites:

    Suite Result
    test_hatch_adapter + env-quotes + vault-tier + gateway-status + wheel-contents + pool-advertisement 233 passed, 2 skipped
    channel-env-order 6
    gateway-channel-dir 2
    auto-reenroll 39
    credential-contract-golden 37/37
    proactive-room-from-channel-env 11
    channel-skip-owner rc 0
    census-d1-anchors 4
    src/remote-gateway-bridge.test.py the same 8 failures as origin/main (diffed); CI is green

Nits (non-blocking)

These carry over from v3:

  • N1: an aliased discover import, and readers passed through **kwargs.
  • N2: the insides of an allowlisted reader are not inspected.
  • N3: obfuscated keys still pass.
  • N4: fuzz residuals, such as JSON or YAML lines and --token flags.

Before the APPROVE

  1. Fix B1 and B2, with the tests above.
  2. Run the per-tier §15 witness at the final head, with the three amendments from v2:
    • every tier delivers a task end to end;
    • the env tier is shown by the absence of a "loaded from" line;
    • the wheel is built from the sha under review, with a new PID per restart.
  3. Add one witness row: an oversized or FIFO AG2_DEVICE_ENV with a channel .env present, showing the B2 message and behaviour.

Worst-case disruption

B2 is now the worst case: an oversized or otherwise refused AG2_DEVICE_ENV makes a Sparrow connect, without a word, using another file's token. That could be the wrong identity, the failure this code path has guarded against since the bare-home rule.

B1 exposes an old token in the installer's diff output.

Both need small fixes, and every other behaviour is pinned.

Evidence (scratchpad): rr5698-dupkey.py, rr5698-refused.py, rr5698-blockread.py, rr5698-guard-probes.py, rr5698-guard-probes-v3.py, rr5698-mut.sh, rr5698-fuzz.py, rr5698-v4-bridge-fails.out.

@sonichi
sonichi requested a review from qingyun-wu October 18, 2026 10:37

@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.

Review from sutando-qingyun-001 (shared qingyun-wu login), at da7a847

Verdict: REQUEST_CHANGES, for one blocker. Both of my v4 blockers are fixed, and each fix has tests that fail when it is reverted. But the fail-closed change breaks an existing pinned test on main's contract, and CI is red at this head. With no blocker this would have been a COMMENT (code approvable, APPROVE after the §15 witness). Once B1 is resolved it goes back to that.

Method

  • Head. I checked it with gh pr view 5698 --json headRefOid. The merge-base is still 5e632bf. The only commit since 7811176 is da7a847.
  • Workspace. A scratch worktree, now removed. Synthetic fixtures only; no .env, keychain or vault reads.
  • CI: 2 checks fail, 33 pass, 4 are skipped.
    • python standalone tests (5/9) failed on tests/gateway-proactive-room-from-channel-env.test.py.
    • tsc + tests (clean install) fails only as the aggregate of that failure.
  • Other checks. review-checks.sh passes, and both --check sync tools pass.

Blocker

B1. Failing closed on a refused AG2_DEVICE_ENV breaks a contract main pins, and CI is red

The failing test is test_an_undecodable_candidate_does_not_shadow_the_next in tests/gateway-proactive-room-from-channel-env.test.py.

  • It passed at 7811176: 11/11 in my v4 run.
  • Its setup: a non-UTF-8 AG2_DEVICE_ENV file, and a readable channel .env holding REMOTE_PROACTIVE_ROOM.
  • What it asserts: the bridge still takes PROACTIVE_ROOM from the channel .env.
  • At this head: '' != '!ownerdm:example.org'.

Cause:

  • env_files() now breaks on a refused device file, so the channel .env is never read.
  • That file feeds the config reads (_config_from_channel_env: REMOTE_PROACTIVE_ROOM, REMOTE_PROACTIVE_TRUST_OK and the rest) as well as the token tier.

The body is also incomplete here. It says "On main a directory there was skipped". But main also skipped a non-UTF-8 device file, and this test pins that.

Pick one and own it:

  • (a) Keep the fail-closed contract. Update this test deliberately: a refused named device file now means FATAL and no config from later files. Say in the body that main's "undecodable device file is skipped" contract is replaced.
  • (b) Narrow fail-closed to the credential tiers. Still list the later files for config, but stop the file and vault token tiers, and keep fatal.

Either way, CI must be green at the head that is reviewed.

v4 blockers: both fixed

B1 (a repeated key leaks). rr5698-dupkey.py at this head:

File holding the duplicate Early value in the Hatch diff Late value in the Hatch diff Bridge registry (early / late)
device .env withheld withheld True / True
channel .env withheld withheld True / True

The winner is still the last value, late-dup-token-2222. Repeats and export lines both register, through parse_env_pairs and _assignment_pairs.

B2 (refusal is silent). rr5698-refused.py and rr5698-fatal.py:

Scenario Bridge Hatch
refused AG2_DEVICE_ENV (over 1 MiB, not UTF-8 or FIFO), no env token winner none; stderr names the path and reason; main() exits FATAL: AG2_DEVICE_ENV names <path>, which was refused (…); no "loaded from"; no token in the output credentials_usable() False, NOTE credentials: …, REFUSED: …; _install_file prints (diff withheld: …); the device token is not printed
refused AG2_DEVICE_ENV, env token set the env token is resolved, but main() still exits FATAL (declared: "even when an env token is set") install refused, diff withheld
refused default channel .env (FIFO), device token present device token wins; the refusal is named on stderr NOTE

The token file: what happens today, and a recommendation

rr5698-fatal.py, scenarios C to E:

Scenario Startup winner Stderr
C. readable REMOTE_TASK_TOKEN_FILE, token in the device file device token (source=file) nothing
D. the same token file refused (not UTF-8) the same device token credential file <token> was refused (not UTF-8); nothing in it was read
E. refused token file, env token set the env token, as with a readable file the same note

In D and E, TOKEN_FILE stays set to the refused path, and Hatch prints only a NOTE.

At startup, a refused token file does not change which token wins. The token file is not a startup tier: the token comes from env, then the file tier, then the vault. The file is the rotation channel only.

The cost comes later.

  • _reload_rotated_token() re-reads the file through read_regular_text(...) or "". A refused file reads as "", so it does not rotate, with no further message.
  • After a 401, _recover_auth sleeps in its recheck loop, waiting for a rotation it can never read.
  • The only clue is the one line printed at import, possibly hours earlier.
  • If the file becomes refused after startup (rewritten over 1 MiB, or replaced by a FIFO), there is no clue at all.

Recommendation: yes, fail closed. I agree with the coordinator, for a reason that is slightly different from the precedence argument. REMOTE_TASK_TOKEN_FILE is explicitly named, like AG2_DEVICE_ENV, and a refused one silently disables the operator's recovery path.

  1. At startup, a refused REMOTE_TASK_TOKEN_FILE gives Discovery.fatal: the bridge exits FATAL and Hatch refuses the install, the same as AG2_DEVICE_ENV.
  2. On reload, use read_regular and name a refusal (path and reason, no content) in the auth-recheck log. Rate-limit it to one line per change of reason. Never treat it as "no rotation yet".
  3. Pin both with a real-bridge test, and add the case to the Behaviour changes section of the body.

If the author would rather keep it a warning, (2) alone is the minimum, so a refused rotation file is never silent at the moment it matters.

Probes and mutants re-run

The suite passes, 63/63.

Mutants added in this round. Each fails tests when reverted:

Mutant Tests failing
R1: repeated assignments not registered 4 (including the real Hatch diff)
R2: refused device file does not block 8
R3: discover ignores blocked 6
R4: the bridge's main() ignores fatal 4
R5: the bridge prints no notes 5
R6: the Hatch diff is not withheld on fatal 2
R7: Hatch install ignores credentials_usable 2
R8: the reader reports a refusal as absence 5
R9: a token-file refusal is not noted 2

This matches the author's report that 9 revert mutants fail.

Earlier mutants, all still caught:

  • the detector key check turned off: the self-test fails;
  • SOURCES reordered: 13 tests;
  • the continuation join turned off: 4;
  • skill-copy drift: 1.

Probes:

  • rr5698-guard-probes.py: all 15 targeted probes are caught.
  • rr5698-guard-probes-v3.py: only the aliased discover import and **kwargs slip through (the v3 nit).
  • rr5698-fuzz.py: the same 11 residual nits.

Existing suites:

Suite Result
test_hatch_adapter and the package suites 233 passed, 2 skipped
channel-env-order 6
gateway-channel-dir 2
auto-reenroll 39
credential-contract-golden 37/37
channel-skip-owner rc 0
census-d1-anchors 4
src/remote-gateway-bridge.test.py the same 8 failures as origin/main
gateway-proactive-room-from-channel-env 10/11 (B1)

Nits (non-blocking)

  • N1. The env-token fail-closed case has no test. Refused AG2_DEVICE_ENV plus an env token exits FATAL, as declared, but the real-bridge test covers only the no-env-token row. Add the env-token row.
    • This is a new hard failure for desktop-style launchers that set both, so it is worth a line in the release notes.
  • N2. A refused default channel .env falls through to the vault. That is documented and named, and the vault can hold another identity's token. It is acceptable because the path was not explicitly named, but it is the same shape as B2. The witness row covers it.
  • N3. Carried from v3 and v4: the aliased discover import and **kwargs readers, the insides of allowlisted readers, obfuscated keys, and the fuzz residuals.

Before the APPROVE

  1. Resolve B1, with CI green at the head under review.
  2. Decide on the token-file recommendation above.
  3. Run the per-tier §15 witness at that head, with the v2 amendments:
    • every tier delivers a task end to end;
    • the env tier is shown by the absence of a "loaded from" line;
    • the wheel is built from the sha under review, with a new PID per restart.
  4. Add v4's refused-file witness row: an oversized or FIFO AG2_DEVICE_ENV, showing FATAL.

Prior reviews from this shared login are on record at 1c20ee4, ef5ad0d, e92c073 and 7811176. Each new verdict should replace those, not stack on them.

Evidence (scratchpad): rr5698-dupkey.py, rr5698-refused.py, rr5698-fatal.py, rr5698-mut.sh, rr5698-guard-probes.py, rr5698-guard-probes-v3.py, rr5698-fuzz.py, rr5698-v5-bridge-fails.out.

@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.

Review of exact head da7a847f16971f88bf5336602481455c08fa9789 (including the delta from requested head 781117669f5ea57f684746e0d62ee874b39901a8).

Verdict: REQUEST_CHANGES. The latest commit fixes repeated channel-.env registration and the refused AG2_DEVICE_ENV fallback/diff case. Three credential-safety gaps remain, the current head has a real CI regression, and the required final-head live witness is still pending.

Merge blockers

1. Required CI is red on a regression introduced by the new fail-closed walk

src/gateway_credential_sources.py:104-115 stops walking all channel files after a refused AG2_DEVICE_ENV. That also prevents the later channel .env from supplying non-credential configuration. The existing contract test now fails:

python3 tests/gateway-proactive-room-from-channel-env.test.py
FAIL: test_an_undecodable_candidate_does_not_shadow_the_next
AssertionError: '' != '!ownerdm:example.org'
Ran 11 tests ... FAILED (failures=1)

GitHub's python standalone tests (5/9) and aggregate tsc + tests (clean install) checks fail on the same test. The prior 78111766 head passes it. Keep credential selection fail-closed without making the credential-specific refusal policy silently redefine the shared channel-config walk, or explicitly reconcile the contract and all consumers; the current code/test disagreement cannot merge.

2. Systemd logical-line masking still exposes credentials on two valid adjacent inputs

shared/ag2_gateway_credentials.py:383-391 begins a continuation whenever a physical line merely endswith("\\"). Actual systemd skips top-level comments before continuation processing and tracks escape parity, so only an odd trailing run continues. The primary parser shows both rules: systemd conf-parser.c at 7197de99, lines 357-396.

Both cases still print synthetic credentials through the real Hatch _install_file diff at this head:

# harmless comment \
Environment=REMOTE_TASK_TOKEN=synth-after-comment-5698

Environment=SAFE=C:\\
Environment=REMOTE_TASK_TOKEN=synth-after-even-slashes-5698

The first comment incorrectly starts a logical group; the even pair in the second case incorrectly consumes the next assignment. The reported comment-inside-an-active-continuation fix passes, but these adjacent branches remain unsafe. Skip standalone comments before continuation state, use trailing-backslash parity, regenerate all copies, and test both through each copy and the Hatch sink.

3. Refused channel/token files remain unmaskable but Hatch still prints the diff

The new cli.py:166-170 withholds a diff only when found.fatal is set. gateway_credential_sources.py:263-266 records a refused default channel .env or explicit REMOTE_TASK_TOKEN_FILE only in found.notes, so Hatch continues with a registry that cannot contain that file's credentials.

I reproduced both with an over-1-MiB file beginning with a synthetic token and an installed unit containing that token under Environment=NOTE=. In each real _install_file run:

fatal=''
notes=('credential file ... was refused (over 1048576 bytes); nothing in it was read',)
secret_visible=True
-Environment=NOTE=<synthetic refused-file token printed raw>

The latest push correctly makes a refused named AG2_DEVICE_ENV fatal and withholds its diff. Apply the same safe-sink rule whenever discovery has any unread/refused credential candidate: fallback may remain a separately documented runtime choice, but Hatch must not show an unredacted diff when masking discovery is incomplete. Pin default-channel and token-file refusals through the real Hatch sink.

4. Hatch still discovers the installer environment, not the installed service environment

adapters/hatch/cli.py:143-149 resolves from installer os.environ, while the unit rendered at :530-542 carries only selected variables and omits CLAUDE_CONFIG_DIR, REMOTE_TASK_CHANNEL_DIR, token/URL aliases, and the token-file setting. Bridge startup resolves from the eventual service process environment at remote_gateway_bridge.py:1662-1664.

A systemd user service inherits the manager's environment, not automatically the invoking systemctl client's environment (systemd.exec, “Environment Variables in Spawned Processes”). The installer calls show-environment only as an availability probe and discards its output.

The exact-head model using the real resolver, unit renderer, bridge import, and Hatch diff still shows both directions:

installer-only custom config/channel:
  Hatch token=file; unit carries neither selector; clean-manager bridge token=none

manager-only custom config/channel:
  Hatch token=none; bridge token=file; registry contains manager token=False
  real Hatch diff prints that manager-only token raw

Model the effective service environment separately from the installer environment and register every candidate the service can see. Do not put bearer secrets into the mode-0644 unit. Add divergent installer/manager-environment rows to the real-sink tests.

5. The required exact-final-head live witness remains absent

The PR body still says the §15 run is pending. After the code and CI blockers are fixed, run the real post-restart round trip for each source tier at the final built SHA, with a new PID and sanitized output. Harness proof does not replace this live-path gate.

Verified at da7a847f

  • Repeated keys in device/channel .env files now preserve last-value-wins selection while registering both values; neither appears in the real Hatch diff.
  • A refused explicit AG2_DEVICE_ENV now names the refusal, selects no fallback token, exits fatally, and withholds the Hatch diff.
  • The earlier explicit token-file assignment, guard keyword/default-lambda, and FIFO fixes still pass.
  • tests/gateway-credential-sources.test.py: 63/63 pass.
  • tests/ag2-sparrow-hatch-adapter.test.py: 176 pass, 2 skip with its documented synthetic USER precondition.
  • Credential golden tests: 37/37 pass; both sync checks, review-checks.sh, and git diff --check pass; CLA is green.

Non-blocking cleanup remains: the bounded-reader module's “never block” wording overclaims regular-file behavior on NFS/FUSE, the structural guard still misses aliases/**kwargs, and the PR body still says 42 tests rather than 63.

Worst case remains a live or retired credential printed in an install diff, or a restarted service silently using a different identity than Hatch assessed. All probes used synthetic values in isolated temporary environments; I did not touch a live service, host credential file, vault, or Keychain.

@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.

Review from sutando-qingyun-001 (shared qingyun-wu login), at 38f4b68

Verdict: REQUEST_CHANGES, for two blockers. Both come from the new systemd-manager read. The credential work itself is clean:

  • option (b) is in, and the protected proactive-room test passes unedited;
  • a refused token file is FATAL at startup and named again on reload;
  • the odd-backslash rule matches systemd's conf-parser and does not leak;
  • every earlier probe and mutant holds.

Without B1 and B2 this would be the COMMENT you expected (code approvable, APPROVE after the §15 witness).

Method

  • Head. I checked it with gh pr view 5698 --json headRefOid. The merge-base is still 5e632bf. The only commit since da7a847 is 38f4b68.
  • Workspace. A scratch worktree, now removed. Synthetic fixtures only, with systemctl mocked. No .env, keychain or vault reads.
  • CI: 36 checks pass, 3 are skipped. review-checks.sh passes, and both --check sync tools pass.

Blockers

B1. install --no-service now hides an existing managed service from the rest of install

install() now caches _MANAGER_PROBE["env"] = None if args.no_service else _manager_env(). Inside install, systemd_user_available() has become _manager_env() is not None, so with --no-service it reports False even when systemd is reachable.

That answer reaches more than the credential analysis. keepalive._unit_owner() drives ensure_sparrow, restart_listener and unmanaged_health_problem.

Probe rr5698-noservice.py: systemd reachable (mocked), and an installed unit carrying SERVICE_MARKER:

Where _unit_owner() systemd_user_available()
outside install ours (true)
inside install --no-service None False
inside install ours True

On main, install --no-service on a host that already runs the managed unit:

  • ensure_sparrow saw owner == "ours";
  • it deferred to the unit's process, and restart_listener used systemctl --user restart.

At this head, the same run goes down the unmanaged branch, with two bad outcomes:

  • If the service lacks REMOTE_HEALTH_COMMAND: replace_sparrow stops the service's Sparrow and spawns a detached one. The unit has Restart=always and RestartSec=10, so systemd brings its own back 10 s later, and the two contend for the lock.
  • Otherwise: restart_listener spawns a detached Sparrow next to the running service. It exits 75 ("another Sparrow holds the lock") and install reports error: Sparrow did not start.

This is a service-management behaviour change that a credential-analysis decision leaked into (REVIEW.md lesson 5). test_hatch_adapter (233 pass) has no case that combines --no-service with an existing managed unit, so nothing caught it.

Fix: keep "which environment do I analyse?" apart from "is systemd available?". For example, pass the analysis environment into _credential_discovery, or cache a separate _ANALYSIS_ENV. systemd_user_available() must keep answering the real question. Add the case above to test_hatch_adapter.

B2. The new credential-source read escapes the guard

_manager_env() shells out to systemctl --user show-environment. The result is the environment the service inherits, which can carry REMOTE_TASK_TOKEN and its aliases, and it feeds discover(). That is credential I/O at a new site. But:

  • IO_ALLOWED does not list it;
  • credential_access() returns [] for the whole of adapters/hatch/cli.py;
  • the same read placed in any other package module is also [], including one that just prints the manager environment (probes in the v6 evidence).

The guard's job, under condition 1, is to pin where credential I/O happens. This site is outside the pin.

Fix:

  • Add ("adapters/hatch/cli.py", "_manager_env") to IO_ALLOWED.
  • Teach the detector that a show-environment constant (or systemctl … show-environment) is a credential-store read, the same way security and find-generic-password already are.
  • Add the probe to test_the_detector_catches_every_bypass.

The systemd-manager read: everything else checks out

  • The shell-out. _systemd_run(["systemctl", "--user", "show-environment"]) becomes systemctl --no-ask-password --user show-environment. It runs with stdin=DEVNULL, capture_output, timeout=SYSTEMD_TIMEOUT_S (30), and every OSError or SubprocessError gives None. It is bounded, cannot prompt, and runs at most once per install (cached, and cleared in finally).
  • No secret from the manager environment is printed.
    • The manager environment is only an input to discover(). Notes and fatal carry paths, never values, and the diff renders unit, script and config files only.
    • In the probe, a manager-only REMOTE_TASK_TOKEN and an installer-only one were both withheld when they appeared in the installed unit under neutral keys. Both environments go into one registry.
    • A non-credential manager variable printed nowhere.
  • The fallback is named where it matters.
    • With --no-service the fallback is the documented intent, and install prints skipped: the ag2-sparrow user service (--no-service).
    • When no manager answers, install_service prints systemctl --user is not available here, so no service was set up.
    • The diff itself does not say which environment it analysed (N1).

Credential behaviour at this head

rr5698-fatal.py:

Scenario Bridge Hatch
A. refused AG2_DEVICE_ENV, no env token FATAL, named on stderr usable False, diff withheld
B. refused AG2_DEVICE_ENV, env token set FATAL (as declared) usable False, diff withheld
C. readable token file device token, no stderr usable, normal diff
D. refused token file, device token present FATAL: REMOTE_TASK_TOKEN_FILE names <path>, which was refused (not UTF-8); token rotation cannot read it… usable False, diff withheld
E. refused token file, env token set FATAL usable False, diff withheld
F. refused default channel .env device token wins; named on stderr NOTE, and the diff is withheld because masking is incomplete

This resolves the v5 token-file recommendation. A token file refused at reload is named with ; no rotation (mutant N4 below).

Config still reaches later files after a refused device file: gateway-proactive-room-from-channel-env passes 11/11. Re-adding the da7a847 break (mutant N8) fails test_general_config_still_reaches_a_later_file_after_a_refused_one.

rr5698-dupkey.py: the early and late duplicates are withheld for both the device and the channel file. The registry holds both, and the last value wins.

rr5698-refused.py: for an oversized, non-UTF-8 or FIFO device file, the winner is none, the file is named, and no token leaks.

The odd-backslash rule (contract probe):

Case Result
an even run (2 or 4) before a credential line, an Environment= line, or free text holding a registered token not continued; each line masked on its own (REMOTE_TASK_TOKEN=<withheld>, or <text withheld…>)
an odd run (1 or 3) continues; the credential word on the next line and a split registered token are withheld
a comment line ending in \ does not continue (skipped first)
a comment inside a continuation skipped without ending it
an odd run at end of file the line is withheld

One residual: an unregistered token split across two lines with an even run of backslashes prints its tail (N2). systemd does not join those lines, so the effective configuration does not hold that token.

Mutants. Each fails tests when reverted.

New in this round:

Mutant Tests failing
N1: any trailing backslash continues 3
N2: comments not skipped first 3
N3: token-file refusal not fatal 5
N4: reload refusal not named 1
N5: diff withheld only on fatal 3
N6: service env = installer env 3
N7: installer env not also registered 1
N8: the break re-added 2

Earlier mutants, still caught:

Mutant Tests failing
repeated assignments not registered 4
the bridge's main() ignores fatal 7
detector key check off the self-test fails
SOURCES reordered 16

Probes and fuzz:

  • rr5698-guard-probes.py: 15/15 targeted probes caught.
  • rr5698-guard-probes-v3.py: only the aliased discover import and **kwargs slip through (the earlier nit).
  • rr5698-fuzz.py: the same 11 residuals.

Tests:

Suite Result
the new file 74/74
test_hatch_adapter + the package suites 233 passed, 2 skipped
channel-env-order 6
gateway-channel-dir 2
auto-reenroll 39
credential-contract-golden 37/37
proactive-room-from-channel-env 11/11
channel-skip-owner rc 0
census-d1-anchors 4
src/remote-gateway-bridge.test.py the same 8 failures as origin/main

Nits (non-blocking)

  • N1. The diff does not say which environment it analysed. Add one line, such as "analysed: the user manager's environment" or "this shell's environment".
  • N2. The masked output can read differently from the file.
    • An even-run line is re-rendered through shlex, so SAFE=a\\ shows as SAFE=a\, which reads like a continuation.
    • The tail of an unregistered token split across an even-run boundary prints. systemd does not see it as the token.
  • N3. The $'…' parsing in _manager_env has two rough edges.
    • A malformed escape, X=$'bad\', raises UnicodeDecodeError out of install. Catch it and fall back to the raw value.
    • UTF-8 bytes shown as \xc3\xa9 decode as mojibake (café), so a non-ASCII secret would be registered in the wrong form. Decode the escapes to bytes, then UTF-8.
  • N4. Carried over: aliased or **kwargs readers, the insides of allowlisted readers, obfuscated keys, and the fuzz residuals.

Before the APPROVE

  1. Fix B1 and B2, with tests. Keep CI green at that head.
  2. Rerun the §15 witness on Sudoo's VM at that head, with the v2 amendments:
    • every tier delivers a task end to end;
    • the env tier is shown by the absence of a "loaded from" line;
    • the wheel is built from that sha, with a new PID per restart.
  3. Add these witness rows:
    • a refused AG2_DEVICE_ENV gives FATAL;
    • a refused REMOTE_TASK_TOKEN_FILE gives FATAL;
    • on the Hatch VM, install --no-service on a host with the managed unit already running leaves that unit's Sparrow in charge.

Shared-login reviews are on record at 1c20ee4, ef5ad0d, e92c073 and 7811176. Each new verdict should replace those, not stack on them.

Evidence (scratchpad): rr5698-noservice.py, rr5698-fatal.py, rr5698-dupkey.py, rr5698-refused.py, rr5698-mut.sh, rr5698-guard-probes.py, rr5698-guard-probes-v3.py, rr5698-fuzz.py, rr5698-v6-bridge-fails.out.

@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-review of exact head 38f4b6897be529ca48189386c6563e6875994496.

Verdict: REQUEST_CHANGES. The regressions called out on da7e595 are fixed and current CI/CLA are green, but three credential/runtime blockers remain. The final-head live witness is also still pending.

Merge blockers

1. Valid systemd escapes bypass credential-name masking

shared/ag2_gateway_credentials.py:411-429 parses Environment= with shlex.split(), rather than systemd's C-style unescaping. A real _diff() repro at this head produced:

input:  Environment=REMOTE_TASK_\x54OKEN=synth-hex-key-5698
output: -Environment=REMOTE_TASK_x54OKEN=synth-hex-key-5698

The \u0054 and octal \124 spellings also printed the synthetic value. systemd decodes each spelling into REMOTE_TASK_TOKEN, but the masker classifies the undecoded key and misses it. Environment= uses systemd.syntax unquoting, whose escape table includes hex, octal, and Unicode escapes.

Please parse this syntax compatibly before classifying names/values (or fail closed on unsupported escapes) and cover these variants through the real Hatch diff sink and synced copies.

2. Per-unit credential sources are absent from discovery and same-diff duplicates leak

packages/ag2-sparrow/ag2_sparrow/adapters/hatch/cli.py:145-188 models manager-global show-environment plus only the prospective AG2_DEVICE_ENV. It does not incorporate an existing unit's Environment=, drop-ins, EnvironmentFile=, or UnsetEnvironment=. This matters because systemctl show-environment explicitly excludes per-unit variables, while systemd adds those variables later when it forks the unit.

The retained hand-written-unit path at cli.py:584-594 reproduces a direct leak:

old:
Environment=REMOTE_TASK_TOKEN=synth-per-unit-duplicate-5698
Environment=NOTE=synth-per-unit-duplicate-5698

diff:
-Environment=REMOTE_TASK_TOKEN=<withheld>
-Environment=NOTE=synth-per-unit-duplicate-5698

_diff() masks each line independently, so it never registers the credential assignment before rendering the duplicate. A drop-in/EnvironmentFile= probe likewise showed the effective token survived installation but was not registered, and the duplicate NOTE= line printed raw.

Please account for effective per-unit sources (or conservatively withhold what cannot be resolved), and make diff rendering two-pass: collect credential assignments from all displayed old/new text before emitting any line. Add retained-unit and surviving-drop-in coverage.

3. Hatch selects the credential environment before it knows which runner will actually start

Credential validation happens at cli.py:705. install_service() is best-effort and returns early on setup failure (:543-581), _install() does not propagate that outcome, and ensure_sparrow() then falls back to detached startup (:726-738). That detached process inherits the installer environment in keepalive.py:208-246, not the manager environment already analyzed.

The real-path hermetic probe showed:

analysis CLAUDE_CONFIG_DIR: /tmp/manager-config
spawn CLAUDE_CONFIG_DIR: /tmp/installer-config
analysis matches spawn: False

A second probe made service setup fail and put a refused token file only in the installer environment. Manager-based validation demoted it to a note, then the detached environment selected it and production discovery classified it fatal:

service_setup_failed= True
detached_uses_installer_token_file= True
detached_fatal= True

The adjacent --no-service path also forces the manager cache to None without stopping an already-running unit, so validation may again describe a different identity from the process that remains live.

Please determine/propagate the actual runner mode before credential validation and validate the environment that runner will use. Cover service-install failure followed by detached fallback and --no-service with an existing unit.

4. Required final-head live witness is still pending

This changes startup/service behavior. The PR body still marks the real Linux post-restart inbound-task → reply round trip as not run. Per REVIEW.md lesson 15, this remains a merge gate after the code blockers above are fixed.

Verified at this head

  • Refused AG2_DEVICE_ENV remains fatal for credential tiers while later files may still provide noncredential config.
  • Refused token/channel files withhold Hatch diff values.
  • Top-level systemd comments and odd/even continuation parity behave correctly.
  • Stable manager-vs-installer selection tests and proactive-room config regression pass.
  • tests/gateway-credential-sources.test.py: 74/74 passed.
  • tests/ag2-sparrow-hatch-adapter.test.py: 178 passed, 2 skipped.
  • Proactive-room suite: 11/11 passed.
  • Vendored sync checks, review scanner, git diff --check, all current GitHub checks, and CLA passed.

Nonblocking: the PR body still reports 42 credential-source tests, while the suite now collects 74.

@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 the requested option-(a) revision at dcc978a62833c975619d53ade51336120b778805. The head moved during review, so I also inspected and reran both failures at the current exact head, ad3cbb1369903c5606948913bb218ae0ca33d33e.

Verdict: REQUEST_CHANGES. The broad direction is improved, but two boundary failures remain, and the required final-head live witness is still pending.

1. [P1] Register the complete systemd assignment value before the real diff sink renders it

register_assignments() still splits on every quote/whitespace. At the real Hatch _diff() sink, this exact-head hermetic input:

Environment=REMOTE_TASK_TOKEN='abcdefgh'
ExecStart=/bin/echo --token='abcdefgh' --x

still renders:

-Environment=<withheld>
-ExecStart=/bin/echo --token='abcdefgh' --x

The same parser gap exposes a short value from Environment="REMOTE_TASK_\x54OKEN=abc" on a neutral line and the decoded value from Environment="REMOTE_TASK_TOKEN=alpha\somega" as NOTE=alpha omega.

The new register_candidates() seeding at ad3cbb13 masks a value only while a matching candidate is still discoverable from the active env/file/vault sources. It does not repair two-text registration for a retired or drifted unit value, which is exactly what an install diff may be displaying. Worst case, a retired bearer is printed to terminal or automation logs.

Please parse/register the complete systemd item (including supported quoting/escapes), or fail closed when that cannot be done, and add these shapes through the real Hatch diff sink plus the synced-copy contract.

2. [P1] --no-service must not replace a service-owned process when manager reachability is uncertain

_unit_owner() collapses “the owned unit exists, but the user-manager probe is temporarily unavailable” into “no unit.” install --no-service therefore misses its deferral branch, and ensure_sparrow() treats the live service-held process as unmanaged.

Hermetic full-install repro at this head, with an owned unit and a live synthetic Sparrow holding the production-format lock while reachability returns false,false,false,true:

install_rc=0
old_service_holder_exit=-15
detached_spawn_count=1
owner_after_probe_recovery="ours"

Output included replacing: Sparrow ... and started: spawned detached. With the unit's Restart=always, the terminated service can restart beside the detached process, causing duplicate/lock contention and potentially start-limit disruption.

Please distinguish “unit absent” from “manager temporarily unreachable.” Under --no-service, an installed unit or service-owned live holder must not authorize a stop/detached replacement merely because reachability is unknown. Add the transient-unavailable unit + live-holder regression.

3. Required live witness remains outstanding

This changes bridge/startup/service behavior, and the PR body still marks the final-SHA Linux restart plus inbound-task → delivered-reply witness as owner-gated and pending. That witness is required before approval; I did not touch live services, configuration, or credentials.

Verified at ad3cbb13: credential suite 78/78; Hatch adapter 180/180 with 2 platform skips; both sync checks, review checks, and git diff --check pass. Whole environment-shaped lines and continuations are withheld, Hatch does not consume manager output or decide credential validity, refused named files fail at bridge startup, and the normal active-service/no-unit --no-service paths pass. Those positives do not cover the two cases above.

@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.

Review from sutando-qingyun-001 (shared qingyun-wu login), at ad3cbb1

Verdict: REQUEST_CHANGES, for three blockers. All three are small and need no new I/O. With them fixed, this becomes a COMMENT (code approvable, APPROVE after the witness on Sudoo's VM).

What ad3cbb1 does. Hatch's _diff now seeds its masking registry from the owner's discover(): env, device and channel files, token file, and the vault as the last tier. Winners, notes and fatal results are discarded.

What it fixes. The v7 trade-off (D1) is fixed when the installer's shell names AG2_DEVICE_ENV or holds the token.

What it does not fix.

  • It misses Hatch's own default device file (B3, new).
  • It does not address v7 blocker 1. The masking is by registry value only, not by shape (B1).
  • It leaves v7 blocker 2 open (B2).

Method

  • Head and delta. I checked the head with gh pr view 5698 --json headRefOid. The delta from dcc978a is one commit: adapters/hatch/cli.py +4 −2, a new package-only gateway_credential_io.py (+60), remote_gateway_bridge.py +3 −19, and two test files. shared/, src/ and skills/ are unchanged.
  • CI: 35 checks pass, 3 are skipped.
  • Workspace. Scratch worktrees, now removed. Synthetic tokens only; the vault is mocked through gateway_credential_io._vault. No .env, keychain or vault reads.

Blockers

B1 (carried from v7). Masking is by registry value only: credential shapes on non-environment lines still print

At this head:

  • An ExecStart argument or a hook Authorization: header is withheld only when its value is in the registry. That covers the new tests' case: a token that discovery found.
  • register_assignments still registers only line-leading KEY= values whose KEY is credential-named.
  • _mask_line still judges only the line's own leading key.

So a token that discovery did not find prints. rr5698-v7-fuzz.py at this head, with the vault mocked empty, prints all of these raw:

Line Shown
ExecStart=/opt/bin/sparrow --token <tok> / --token=<tok> / lower-case execstart=… --token <tok> raw
ExecStart=/usr/bin/env -i REMOTE_TASK_TOKEN=<tok> … and … env X=<tok> … raw
ExecStartPre=/bin/sh -c 'export REMOTE_TASK_TOKEN=<tok>' raw
curl -H "Authorization: Bearer <tok>" … raw
Description=Sparrow <tok>, NOTE=<tok>, a JSON line raw

Such a token can be a retired token, a token from another account, or a vault-only token the owner never reaches.

This also closes the author's vault-only gap without a Keychain read. The author asks whether to always read the vault for masking. I recommend not doing that: it adds a possible Keychain prompt to install. Add a shape rule instead, applied whatever the registry holds. Withhold any line that contains:

  • a KEY= anywhere in the line whose KEY matches _CREDENTIAL_NAME_RE (so env -i …, sh -c 'export …' and --opt KEY=v are covered);
  • a flag matching --?[\w-]*(token|secret|password|passwd|api[-_]?key|bearer)[\w-]*, in the --flag value or --flag=value form;
  • an Authorization: header, or the word Bearer followed by a value.

Then register the values found that way, at any length, so the same value elsewhere is withheld too.

What then remains is a vault-only token, or a token found nowhere, placed bare in an arbitrary position (--flag <tok>, NOTE=<tok>, free text). State that as the limit in the body. Add the table rows above to the contract test and to the real-Hatch test.

B2 (carried from v7). The guard still does not pin that the show-environment probe discards its output

This is unchanged in the delta. IO_ALLOWED names systemd_user_available by name only. Each of these variants still passes credential_access:

  • it returns r.stdout;
  • it prints r.stdout;
  • it stashes the output in a module global;
  • another function parses its return value.

Fix: a behavioural pin. Give a mocked _systemd_run a stdout holding a synthetic REMOTE_TASK_TOKEN=…. Assert the function returns exactly True (a bool), prints nothing, and leaves no copy on the module.

B3 (new). The seeding reads the installer's raw environment, so Hatch's own default device file is never read

_diff calls register_candidates(os.environ, …). The unit Hatch installs is given AG2_DEVICE_ENV=<device_env_path()>, and 38f4b68 overlaid exactly that value. This head does not.

So in the default Hatch install, where the token sits in ~/.config/ag2-sparrow/relay.env and the installer's shell does not export AG2_DEVICE_ENV, the live token is never registered. rr5698-v8-live.py:

Live token held in ExecStart=/x --flag <live> NOTE=<live> vault consulted
AG2_DEVICE_ENV, set in the installer's env withheld withheld no
Hatch's default device file, AG2_DEVICE_ENV unset printed printed yes (10 calls for 5 diffs)
the installer's env withheld withheld no

The middle row is the common case, so the fix covers the less common ones. It also means install reaches the vault tier on a normal host. In a monorepo checkout on macOS, that is a security find-generic-password call, which is the Keychain cost the author wanted to avoid.

Fix (one line): register_candidates(dict(os.environ, AG2_DEVICE_ENV=str(device_env_path())), registry), or pass that env from Hatch. Add a real-Hatch test with AG2_DEVICE_ENV unset and the token in the default path.

The delta's other questions

  • Does gateway_credential_io.py reintroduce I/O the guard should flag?

    • It does reintroduce credential reads into Hatch: env, the device and channel files, the token file and the vault, for masking only.
    • That stretches the body's option (a) wording ("Hatch no longer reads … decides nothing"). The decision half still holds: winners, notes and fatal results are discarded, and install gates nothing on them. The read half no longer holds, so update the option (a) section and the finding-1 line to say so.
    • The guard pins the new site. IO_ALLOWED adds ("gateway_credential_io.py", "read_file"), ("gateway_credential_io.py", "vault_lookup") and ("gateway_credential_io.py", "_vault"). credential_access returns [] for the module as shipped, but flags an added dump() that opens relay.env (gateway_credential_io.py:dump credential I/O outside the adapters' edge). So the allowlist is by function and not a blanket module pass.
    • register_candidates itself is not allowlisted, and does not need to be: it only hands allowlisted readers to discover.
    • The module is package-local, not vendored from src/. That is consistent with "adapter I/O stays at the edge".
  • Does the bridge's −19 change any FATAL path? No.

    • _vault_lookup and _monorepo_src now delegate to gateway_credential_io.vault_lookup and monorepo_src. The bodies are the same code, and monorepo_src(marker, __file__) walks up from the same package directory.
    • rr5698-fatal-v7.py at this head matches dcc978a row for row:
      • A, B, I and J (a refused AG2_DEVICE_ENV: over 1 MiB, with an env token, with a vault token, or a directory) are FATAL.
      • D and E (a refused token file) are FATAL.
      • C, F, G and H (readable token file, refused default channel .env, env only, vault only) run.
    • test_vault_token_tier passes 7/7, and src/remote-gateway-bridge.test.py shows the same 8 failures as origin/main.
  • Mutant. Removing the register_candidates call fails 12 tests, matching the author's report.

  • Suites:

    Suite Result
    tests/gateway-credential-sources.test.py 78/78
    test_hatch_adapter + the package suites 235 passed, 2 skipped (CI's file order)
    rr5698-guard-probes.py 15/15

    One harness note: running test_vault_token_tier.py before test_env_token_quotes.py/test_hatch_adapter.py in a single pytest process gives 2 failures. That happens at dcc978a too, so it is pre-existing and not from this delta. CI runs each file on its own.

Unchanged since v7 (still fine)

These were checked at dcc978a, and the delta touches none of them:

  • environment-shaped lines are withheld whole (EnvironmentFile=, PassEnvironment=, UnsetEnvironment=, escaped, lower-case or indented spellings, continuations), and so are the *Credential*= directives;
  • --no-service defers to a managed unit;
  • no dead masker paths are left.

Before the APPROVE

  1. Fix B1, B2 and B3, with tests, and keep CI green.
  2. Update the body:
    • option (a) now means Hatch reads for masking but decides nothing;
    • finding 1 for Hatch is covered by the registry plus the B1 shape rule;
    • the stated limit is a vault-only or unknown token placed bare in an arbitrary position.
  3. Run the witness on Sudoo's VM at that head, per tier, with the v2 amendments:
    • every tier delivers a task end to end;
    • the env tier is shown by the absence of a "loaded from" line;
    • the wheel is built from that sha, with a new PID per restart.
  4. Include the rows already planned, plus one new row: a default-path Hatch install with a hand-written unit holding the live token in ExecStart, which must show it withheld.

Reviews on record: 9 in total. This shared login has 6 (CHANGES_REQUESTED at 1c20ee4, ef5ad0d, 7811176, da7a847 and 38f4b68, plus a COMMENTED at e92c073). Kewei has CHANGES_REQUESTED at e92c073, da7a847 and 38f4b68. Each new verdict should replace the ones before it, not stack on them.

Evidence (scratchpad): rr5698-v8-live.py, rr5698-v7-live.py, rr5698-v7-fuzz.py, rr5698-fatal-v7.py, rr5698-mut.sh, rr5698-guard-probes.py, rr5698-v8-bridge-fails.out.

@sonichi
sonichi force-pushed the fix/credential-policy-owner branch from d1a1adc to 6f8de04 Compare October 18, 2026 12:37

@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.

Review from sutando-qingyun-001 (shared qingyun-wu login), at 6f8de04

Verdict: COMMENT. The code is approvable at this head. The APPROVE follows the per-tier §15 witness on Sudoo's VM at this exact head (REVIEW.md lesson 15).

All five blockers (Q1–Q3 from my v7 and v8 drafts, K1–K2 from Kewei) are fixed. Each has a test that fails when its fix is reverted. What remains are non-blocking nits: a few shape gaps adjacent to the stated limit (N1), and over-withholding that can hide harmless lines (N2). N2 is worth fixing in the same push.

When the APPROVE is posted, it replaces this login's earlier reviews: CHANGES_REQUESTED at 1c20ee4, ef5ad0d, 7811176, da7a847 and 38f4b68, and COMMENTED at e92c073. It does not stack on them.

Method

  • Head and CI. I checked the head with gh pr view 5698 --json headRefOid. The merge-base is still 5e632bf. CI: 36 checks pass and 3 are skipped. The delta from ad3cbb1 is one commit.
  • Checks.
    • Both --check sync tools pass, and review-checks.sh passes.
    • The new test file passes 88/88.
    • test_hatch_adapter and the package suites pass 239, with 2 skipped (CI's file order).
    • The other suites (channel-env-order, credential-contract-golden, proactive-room) pass.
    • src/remote-gateway-bridge.test.py shows the same 8 failures as origin/main.
  • Workspace. A scratch worktree, now removed. Synthetic tokens only; the vault and systemctl are mocked. No .env, keychain or vault reads.

The five blockers: verified

Blocker Evidence at this head Fails when reverted
Q1. Mask by shape. rr5698-v7-fuzz.py: every shaped row is withheld (--token, --token=, lower-case execstart --token, env -i REMOTE_TASK_TOKEN=, sh -c 'export REMOTE_TASK_TOKEN=…', Authorization: Bearer, the JSON key). The 5 rows still printing are all inside the stated limit: no shape, and a token no source holds. Those are env X=<tok>, Description=…<tok>, NOTE=<tok>, and two unheld "live" tokens. shape rule off: 27
Q2. Pin the probe's discard. The shipped probe passes. The author's 8 variants are flagged, and so are 3 of mine: _ok(r) then return r, a logging lambda inside _ok(...), and _ok(x[0]) on a list holding the run. (the guard's own self-test)
Q3. Seed from the default device file. rr5698-v8-live.py, in all three cases (token in an exported AG2_DEVICE_ENV, in Hatch's default ~/.config/ag2-sparrow/relay.env with AG2_DEVICE_ENV unset, or in the installer's env): ExecStart=/x --flag <live>, NOTE=<live>, env REMOTE_TASK_TOKEN=<retired>, --token <retired> and Environment=… are all withheld, and the vault is reached 0 times (it was 10 at ad3cbb1). rr5698-v7-live.py: all 4 rows withheld. overlay dropped: 1 (test_the_hatch_install_diff_seeds_from_the_default_device_file)
K1. Split systemd values as systemd does. Values are split with quotes and escapes decoded, and registered raw, decoded and quote-stripped. systemd_words dropped: 3
K2. --no-service with an unanswering manager. rr5698-k2.py runs the real install --no-service, with systemctl present but answering None or rc 1, a unit present (ours or hand-made) and a live lock holder (pid 4242). No _stop/SIGTERM, no replace_sparrow, no _spawn_sparrow, no os.kill, no install_service; rc 0. It prints skipped: … is installed but systemctl --user did not answer, so whether that service runs Sparrow is unknown; no Sparrow is stopped and no detached one is started beside it and NOTE sparrow: … install neither stops nor starts a Sparrow. restart_listener returns False with the reason, and unmanaged_health_problem returns (None, None) for UNIT_UNREACHABLE, so keepalive does not act either. The body declares all of this. (the author's 2 named tests)

Other checks:

  • --no-service with a reachable manager (rr5698-noservice-v7.py): with a managed unit, ensure_sparrow sees ours and defers. With no unit, one detached Sparrow. No manager secret is printed.
  • The bridge's FATAL paths (rr5698-fatal-v7.py) match the previous head row for row:
    • A, B, I and J (a refused AG2_DEVICE_ENV: over 1 MiB, with an env token, with a vault token, or a directory) are FATAL;
    • D and E (a refused token file) are FATAL;
    • C, F, G and H (readable token file, refused default channel .env, env only, vault only) run.

New shape bypasses I tried (rr5698-v9-bypass.py):

Withheld Still printed
--Token, --TOKEN= -t <tok>, -k <tok>
?access_token= and &token= in a URL --key, --access-key, --credentials, --pat
a URL path token, curl -u user:pass X-Api-Key: <tok>, X-Auth-Token: <tok> headers
wget --password, Authorization:Bearer<tok> YAML token: <tok>
Cookie: session=…, Authorization: Basic <b64> sh -c 'T=<tok> /x' (neutral key)
lower-case token=, JSON token / apiKey / accessToken —token (an em dash, not --)
a %-encoded value under REMOTE_TASK_TOKEN, an escaped key (REMOTE_TASK_\x54OKEN=) a bare base64 value, and a comment line holding a token

Nits (non-blocking)

N1. Shape gaps next to the stated limit. The body's limit is "no credential shape on its line". These are credential shapes by any ordinary reading, yet they print:

  • --key, --access-key, --credential(s), --pat;
  • any <Name>: <value> HTTP header whose name holds key, token, auth or secret (X-Api-Key, X-Auth-Token);
  • YAML token:/secret: keys.

They are cheap to add to _SHAPE_NAME and _HEADER_RE, for example [\w-]*(?:key|credential|pat)… for flags and \b[\w-]*(?:key|token|auth|secret)[\w-]*\s*:\s*value for headers. Short flags (-t, -k), neutral keys, base64 and Unicode dashes can stay in the stated limit. Name them there.

N2. Over-withholding: a short shaped value poisons unrelated lines (rr5698-v9-over.py). A shaped value is registered at min_len=1, and every shown line is then matched by substring:

  • ExecStart=/x --auth=1 registers 1. RestartSec=10 and Description=Sparrow 1.0 are then withheld.
  • --auth-mode=basic hides Description=A basic relay.
  • --token-file /home/u/…/token hides an ExecStartPre that merely lists that path.
  • --author qingyun and X-AUTHOR= are withheld (an auth shape), and so is Description=by qingyun.
  • --tokenizer, --oauth-client-id and Description=Bearer of tasks are withheld as shapes.
  • An Environment= path value of 8 or more characters hides an ExecStart that uses the same path.

This fails closed, so nothing leaks. But a diff of all <withheld> defeats the diff's purpose, which is to show what --force would discard.

Fix:

  • The shaped line is already withheld by shape, so register a shaped value only at BARE_SECRET_MIN or above.
  • Exclude plain filesystem paths, values starting with /, from shape-value registration.

Keep the harmless-lines control: a stock unit (Description, After, ExecStart=/usr/bin/python3 -m ag2_sparrow, Restart, RestartSec, WantedBy) prints untouched today. Add one test with --auth=1 next to RestartSec=10.

N3. K2 starts no Sparrow and still returns rc 0. A host with a leftover unit file and no user manager (WSL, or a container with the systemctl binary but no systemd) now gets no Sparrow at all: not from install, and not from keepalive. Install exits 0 with only a NOTE. It is declared, and it is the safe choice, but consider a non-zero rc or a "Sparrow is not running" line, so automation does not read it as a success. Add a witness row for this.

N4. Carried over: the aliased discover import and **kwargs readers, the insides of allowlisted readers, and obfuscated keys.

Before the APPROVE

  1. Optional in the same push: N1 and N2.
  2. Run the §15 witness on Sudoo's VM at the head that will merge, per tier, with the v2 amendments:
    • every tier delivers a task end to end;
    • the env tier is shown by the absence of a "loaded from" line;
    • the wheel is built from that sha, with a new PID per restart.
  3. Include the planned rows:
    • a refused AG2_DEVICE_ENV gives FATAL;
    • a refused token file gives FATAL;
    • install --no-service with the managed unit running keeps the same PID;
    • a hand-written unit shows Environment=<withheld>;
    • a default-path install with the live token in a hand-written ExecStart shows it withheld.
  4. Add one more row: install --no-service while the user manager is stopped. It must print the "did not answer" line, and the service's Sparrow PID must be unchanged.

If the head moves before the witness (for N1 and N2), the witness moves with it, and this review should be re-checked at the new sha.

Evidence (scratchpad): rr5698-v9-bypass.py, rr5698-v9-over.py, rr5698-k2.py, rr5698-v7-fuzz.py, rr5698-v7-live.py, rr5698-v8-live.py, rr5698-noservice-v7.py, rr5698-fatal-v7.py, rr5698-mut.sh, rr5698-v9-bridge-fails.out.

@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 6f8de0444c6396b5100e668a1daeba5b9db7c770 — REQUEST_CHANGES.

The five previously reported paths are materially improved and their focused regressions pass, but three fail-closed gaps remain.

Blockers

  1. The real Hatch diff can still disclose the tail of an Environment= credential line.

    shared/ag2_gateway_credentials.py:448-466 calls splitlines(True), so Python treats U+2028, U+2029, and U+0085 as physical line boundaries even though the surrounding code strips/preserves only CR/LF. Through adapters/hatch/cli.py:_diff, this synthetic input:

    Environment="REMOTE_TASK_TOKEN=qa-first<U+2028>qa-tail-9988"
    

    produced:

    -Environment=<withheld>qa-tail-9988"
    

    I reproduced the same tail_visible=True result for U+2028, U+2029, and U+0085. Restrict physical-line grouping to the line endings the unit text actually uses (CR/LF), and pin all three through the real _diff sink.

  2. A failed unit-property query still authorizes killing a service-owned Sparrow.

    In a synthetic full install --no-service run, show-environment succeeded, the following systemctl --user show ... --property=... failed, and the live process named by the production-format lock exited on SIGTERM (-15). cli.py:425-430 collapses that provider failure to {}; keepalive.py:381-405 then reads the unknown ActiveState/MainPID as inactive/no PID, prints replacing:, and calls _stop(holder).

    An ambiguous manager/property response must remain an explicit unknown state and fail closed before _stop() or restart. Add this adjacent failure case to the full install regression. The original “show-environment itself does not answer” case now passes, but it does not cover the next manager call failing.

  3. The Q2 structural guard misses constant-constructed show-environment commands.

    tests/gateway-credential-sources.test.py:992-1013 gates on an exact ast.Constant("show-environment"); the store check at :1062-1063 does the same. The exact-literal leak is caught, but otherwise identical retaining functions returned no findings when the command was built with each of:

    "show-" + "environment"
    "".join(("show-", "environment"))
    f"show-{'environment'}"
    "show-{}".format("environment")
    "show-%s" % "environment"

    The first form is compiler-constant and invokes the same command at runtime, yet credential_access(...) == []. Fold/recognize these constant strings and add negative-instrument controls. The shipped probe currently discards its output; the blocker is that the claimed regression guard does not enforce that invariant.

Verified at this head

  • The prior K1 quote/escape/tokenization repros are masked; the parent controls disclose and this head does not.
  • The direct K2 manager-unreachable case now causes zero SIGTERM and zero detached spawn.
  • Q1 credential-shaped assignments, flags, headers, and repeated-value sinks are withheld.
  • Q2's shipped systemd_user_available() returns a boolean and does not retain manager output for the covered literal path.
  • Q3 seeds the default device file, resolves the vault reader at most once per install, and clears the per-install mask on success and failure.
  • tests/gateway-credential-sources.test.py: 88 passed.
  • packages/ag2-sparrow/tests/test_hatch_adapter.py: 184 passed, 2 skipped in a clean synthetic environment.
  • Vault tier: 7 passed; env quotes: 15 passed; credential goldens: 37/37 passed.
  • Both sync checks, git diff --check, and scripts/review-checks.sh passed. Current GitHub checks were 36 passing, 3 skipped, including green CLA.

The real Linux post-restart/delivery witness remains pending as the PR body says. I did not touch live services, configuration, credentials, .env files, or the vault.

sonichi and others added 7 commits October 21, 2026 15:13
Rule 1: one scanner (_names_and_values) hands credential_name the complete
key before every `=` or `:` (inside its quotes with any characters, and as
the unquoted run) and every flag name. Regexes only locate positions; the
verdict is credential_name alone. A JSON key such as "pa/ss" no longer
prints through the Hatch diff. A test-owned stem/separator oracle sweeps
every stem split by /, ., -, space, +, :, _, an escaped quote and unicode
punctuation through the contract copies and the real _diff sink.

Rule 2: _unit_props parses and bounds every property before returning
(MainPID at most 10 digits and PID_MAX_LIMIT, the start timestamp at most
20 digits and a u64); any exception or out-of-range value is an unknown
state. install_service now does nothing at all on an unknown state: no
stop, unit write, daemon-reload or enable.

Rule 3: every systemctl invocation goes through _systemctl, called with a
literal tuple. The guard flags any "systemctl" literal outside that helper,
any non-direct or non-literal use of it or of _systemd_run, and
show-environment outside the reachability probe. No dataflow analysis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; failed install says so

- Name scanner: before each `=` or `:` (not `://`), credential_name judges every suffix of the
  line back to its start (the whole span, plus each suffix a SEGMENT_STEMS word can start), and the
  quoted key; for each flag, the run to a blank or `=` and the shell word with its quotes removed
  (also when the quote opens before the dashes). `pa ss: v`, `--"pa ss" v`, `"--pa ss" v` and
  `--pa" "ss v` are withheld through the real Hatch diff.
- Systemd guard: runs over every package module, the credential owners included; pins exactly
  one top-level `_systemctl` and `_systemd_run`, in cli.py; flags an import alias, a rebinding,
  a parameter or keyword of that name, a redefinition, and either name in a string (getattr).
- Install: when it stopped the unit and the next state query is malformed, it prints an error
  with `systemctl --user start ag2-sparrow.service`, skips the Sparrow check and exits 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st, not the host env

The real-sink harnesses copied the host environment minus a denylist, so a
host REMOTE_TASK_TOKEN_FILE, AG2_DEVICE_ENV or channel-dir selector reached
the masking seed and its file was read. Every harness that feeds the real
sinks or the bridge now starts from PATH, LANG, TMPDIR, a temp HOME and USER,
plus the synthetic values it sets.

Regression: test_no_harness_reads_a_credential_path_the_outer_environment_names
puts sentinel selectors in the outer environment and asserts the recorded
reads never open them and the bridge child's env never carries them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y builds its env from one allowlist

The shared bridge-import helper overlaid its values onto the outer environment
and kept REMOTE_TASK_TOKEN_FILE and REMOTE_TASK_CHANNEL_DIR, so the real bridge
import opened the token file and channel .env they named. The same overlay,
host copy or denylist was repeated across the suites that import or run the
bridge, Hatch, room_ops' gateway client or the credential owner.

- tests/_helpers/hermetic_env.py is the one builder. hermetic_env(**extra)
  carries only PATH, LANG, TMPDIR (plus the runner's coverage controls, and
  the process essentials on Windows), a temp HOME and a fixed USER.
  isolate_os_environ() swaps this process's environment for it through
  patch.dict(clear=True); isolated_child_env() hands an isolated process's
  own environment to a child.
- hermetic_gateway and gateway_relay use it, as do 143 test files: module-level
  isolation before any of those modules runs, and every child environment from
  the helper. test_wheel_contents drops its host denylist, Hatch's py_env sets
  a fixed USER, and the outage-recovery and no-ceiling children drop their
  host copies.
- tests/hermetic-env-structural.test.py refuses, in any test file naming those
  modules: os.environ.copy(), dict(os.environ, {**os.environ, f(**os.environ),
  a comprehension over os.environ, env=None or env=os.environ to a subprocess,
  a None-default env parameter handed to a spawn, and any subprocess, loader
  or import of those modules in a file that never isolates (or above where it
  does). Its self-test fires every rule on a flagged sample.
- tests/hermetic-env-sentinels.test.py runs the hermetic import, three room_ops
  entry points, the relay harness, the Hatch suite and the credential-owner
  suite under an audit hook, with sentinel token-file, device-env, channel-dir
  and config-dir selectors plus marked token, URL and instance/agent identity
  values outside. No sentinel file may be opened and no marked value may
  reach a child's environment or the harness's own at a gateway import or
  at exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s from the shared helper

RealSinks.bridge() built its own child environment and, under the coverage
runner, appended coverage's whole install directory to PYTHONPATH: a private
copy of the bootstrap that let a sitecustomize.py beside coverage run in
every bridge child. It now builds the env with hermetic_env(), which exposes
only the coverage package. A synthetic coverage install with a sitecustomize
beside it never runs in the real bridge child; a killed control shows the
same plant runs once that directory is on the path.

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

main added agent_identity with its own unquote/env-file parsing while this
branch moved the bridge's parsing into gateway_credential_sources. After the
rebase the bridge takes _unquote_env from the owner, so agent_identity now
delegates unquote_env, file_value and per-file parsing there too: one parser
for both the identity and the credential tiers, and the bridge's
`_unquote_env is agent_identity.unquote_env` pin still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi
sonichi force-pushed the fix/credential-policy-owner branch from 9fa27dd to c5a3c66 Compare October 21, 2026 23:18
sonichi and others added 3 commits October 21, 2026 16:33
…every adapter

connect_common (main's claude-local supervisor) carried its own systemctl
runner beside Hatch's, so the package had two implementations of the policy
the credential guard pins to one. Both now call service_manager: one bounded
runner (`_run`, the one seam tests patch), `_systemctl` with a literal
argument tuple plus a unit name it checks against UNIT_RE at runtime, and
the one reachability probe.

- The probe drops Hatch's `which` precheck: without systemctl on PATH the
  run raises, `_run` answers None, and the probe is False either way.
- connect_common's argv takes Hatch's flag order (`--no-ask-password
  --user`); verbs, arguments, unit names and bounds are unchanged.
- Both adapters' tests patch service_manager._run; Hatch's fakes read the
  argv through a shim that asserts --no-ask-password is present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r into service_manager

The helper, `_systemd_run` and the probe moved, so SYSTEMCTL_HELPER,
SYSTEMD_PROBE and the probe's IO_ALLOWED entry name service_manager.py.
A helper call may now pass `unit=` (checked against UNIT_RE at runtime) and
`timeout=` beside the literal tuple and locate=True; any other keyword,
positional or non-literal tuple element is still flagged.

New controls: a raw systemctl run appended to connect_common, either CLI or
keepalive is flagged; a second `_systemctl` def in connect_common is
flagged; a unit outside UNIT_RE raises ValueError and runs nothing.

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

The claude-local bridge declaration names the env var the host supervisor
puts the vault token in. It spelled the key as a literal, which the
package guard flags. The owner now exports token_env_name(), and the
declaration calls it.

The guard accepts that call only bare, as a dict or keyword value. Reading
the environment with it, binding it to a name, aliasing the function,
using it as a dict key, or redefining it elsewhere is flagged. So are the
owner's key tuples and the literal key, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@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.

Review at e7fb22c719e2dbbbceaeceda902461a70ff323b8 (base main; merge-base b95a3f86, 2 commits behind main, none touching packages/ag2-sparrow or the credential modules).

Verdict: COMMENT. No change-request condition fires at this head, and I found no blocker in the rebase delta. It is not approvable yet: no review of any kind exists at e7fb22c7, so no maintainer approval is on this head, and the REVIEW.md #15 live witness is still owed.

The new shared owner, service_manager.py (commit 4b84d6ee)

  • One _run (service_manager.py:16), one _systemd_run (:24), one _systemctl (:34), one probe systemd_user_available (:46). Hatch (adapters/hatch/cli.py:33) and connect_common.py (:37, :405, :465, :488-516, :530, :899) both call it. The old private runners are deleted from both.
  • Argv: connect_common moves from systemctl --user --no-ask-password <verb> to systemctl --no-ask-password --user <verb>. Both are options before the verb, and systemctl parses options in any order, so this is harmless. Hatch already used this order on main.
  • Bounds match the old call sites exactly: TIMEOUT_S = 30 equals both the old RUN_TIMEOUT_S (connect_common:44) and Hatch's old SYSTEMD_TIMEOUT_S. daemon-reload / enable / restart / stop still take shape.manager_timeout_s; disable, the show calls and uninstall's daemon-reload still take the default. Verbs, property lists and unit names are unchanged; loginctl ... enable-linger and the launchctl calls go through _run with the same arguments and bounds.
  • The probe drops Hatch's which precheck, but keepalive's _unit_owner (keepalive.py:344) still does locate=True first, so "no systemctl" (None) and "manager did not answer" (UNIT_UNREACHABLE) stay distinct.
  • Unit check: UNIT_RE.fullmatch (service_manager.py:13, :41) raises before anything runs. It applies to the unit= path, which is every connect_common call (ag2-sparrow-claude-local.service, ag2-sparrow-claude-local-bridge.service both match). Hatch and the probe never pass unit=: Hatch writes "ag2-sparrow.service" inside the literal tuple, which the regex does not see, and that is pinned by test_every_shipped_service_name_literal_is_the_service_name instead. That is acceptable for literals, but "validated on every path" is true only for computed unit names.
  • Tree grep: no raw systemctl or loginctl invocation remains outside service_manager.py in packages/, src/, skills/, shared/ or scripts/ (the remaining hits are user-facing message strings). claude_code/cli.py has none.

Guards

  • Behavioral: test_the_helper_runs_only_an_ag2_sparrow_unit drives the real _systemctl with _run patched. Mutation: replacing the UNIT_RE check with if False: makes it fail on all 8 bad units (evil.service, show-environment, ag2-sparrow.service --all, ...). Restored: pass.
  • Structural (REVIEW.md #14 exception: duplication and delegation, paired with the behavioral test above). Mutation: replacing connect_common.py:899 with _sm._run(["systemctl", "--user", "daemon-reload"]) makes test_the_systemd_rule_covers_every_package_module FAIL ("connect_common.py:899 a systemctl literal outside _systemctl"); the same for a raw call appended to adapters/claude_code/cli.py. Restored: pass.
  • Non-blocking gap: the literal check does not evaluate constants. subprocess.run(["system" + "ctl", "--user", "show-" + "environment"], capture_output=True).stdout appended to adapters/claude_code/cli.py passes the whole credential suite (124/124). That is deliberate obfuscation, not something ordinary code does, so I am not treating it as a finding. The negative controls cover every literal-program spelling.
  • token_env_name() (gateway_credential_sources.py:35, mirrored in src/) returns TOKEN_KEYS[0]; claude_code/cli.py:238 uses it only as a record value. The bypass list flags os.environ.get(_sources.token_env_name()), os.getenv(token_env_name()), an aliased call and a second definition, and accepts only the dict-value and keyword forms.

Kewei's change request at 4b0568ed

  1. Private coverage bootstrap: resolved. tests/gateway-credential-sources.test.py:636 builds the child env with the shared hermetic_env(); no coverage.__file__ path is added. test_a_sitecustomize_beside_coverage_never_runs_in_the_bridge_child (:668) with its live control (:678). I mutation-checked this at 934e6ed9; the lines are unchanged here.
  2. Parent instruments false-green: resolved upstream. #5703 merged 2026-10-18, #5704 merged 2026-10-21, both on main under this branch. At this head tests/_helpers/hermetic_env.py:121 sets PYTHONNOUSERSITE=1; sentinel_audit.py:25 covers channel_env_resolve; hermetic-env-sentinels.test.py:137 is its case; hermetic-env-structural.test.py:648-655 flags from subprocess import run.
  3. lesson 15 witness: still owed.
    His b3d5c3f0 items: PYTHONUSERBASE dropped (hermetic_env.py:16,121); bee-actions isolated (tests/bee-actions.test.py:184, hermetic_env(**e), clear=True).

Tests (offline, env -i, temp HOME, synthetic values only)

  • gateway-credential-sources 124/124 OK.
  • test_hatch_adapter 195 OK (2 skipped).
  • test_claude_code_adapter 238 OK (it needs tmux on PATH; without it, 24 tmux cases fail the same way on main).
  • hermetic-env-sentinels 19/20: test_a_suite_that_stops_all_patches_reads_no_outer_selector fails here because a live heartbeat writer is running on this host. It fails identically on main 9ad5126c, so it is not this PR. CI passes it.
  • CI at this head: 36 pass, 3 skipped, 0 failing. license/cla: success (1 context). Unresolved review threads: 0.

Still missing before an APPROVE

  1. A maintainer approval at e7fb22c7. The deciding reviews are still qingyun-wu's CHANGES_REQUESTED at ad3cbb13 (her later reviews are COMMENTs, which do not clear it) and keweichen's at 4b0568ed. Both need a re-review at this head.
  2. The REVIEW.md #15 witness: a real per-tier, post-restart round trip at the final head. This PR changes how the bridge and Hatch find credentials and how both adapters run the service manager. I name it; it is not a blocker in this review.

— 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.

Review at e7fb22c (rebased onto main, merge-base b95a3f8). COMMENT. I found no code blocker in the delta since 934e6ed. This is not an approval: the REVIEW.md §15 post-restart, per-tier witness at this head is still owed. The rebase changed live supervisor code (connect_common.py:404, 465, 489–513, the new service_manager.py), and the PR body says the witness is pending.

Delta. git range-diff c621a051..934e6ed9 b95a3f86..e7fb22c7:

  • 22 commits carry over. Commits 1, 2, 9, 10, 13 and 20 differ only in rebase context.
  • Four commits are new: c5a3c66, 4b84d6e, c35f8ec, e7fb22c.

service_manager. I patched subprocess.run at b95a3f8 and at e7fb22c, then drove unit_state, manager_detail, start_supervisor, stop_supervisor, linger, agent_loaded and the Hatch probe for both claude-local shapes (27 calls per side).

  • The only diff is --user --no-ask-password → --no-ask-password --user, which is semantically identical for systemctl's getopt_long.
  • Verbs, arguments, units, timeouts (30 and 240) and kwargs are unchanged. No env= is passed on either side.
  • Hatch argv is unchanged by construction.
  • All three shipped units match UNIT_RE.

Mutants killed by tests/gateway-credential-sources.test.py:

  • UNIT_RE as .*\.service: 4 fail
  • match instead of fullmatch: 2 fail
  • Hatch-only regex: 1 error
  • unit check off: 9 fail
  • token file-before-env: 17 fail
  • url file-before-token: 2 fail
  • AG2_DEVICE_ENV tier dropped: 16 fail, 4 errors
  • token_env_name returns the legacy key: 1 fail

Other new commits.

  • token_env_name() returns the same literal as before. It is used only as a record value and reads or logs no value.
  • agent_identity now delegates parsing to the owner. 17 .env edge cases give identical output at the base and the head.

Tests (offline, env -i, temp HOME, synthetic values only):

  • gateway-credential-sources: 124/124
  • hermetic-env-sentinels: 20/20
  • hermetic-env-structural: 26/26
  • test_hatch_adapter: 195 pass, 2 skipped
  • test_claude_code_adapter: 238/238

CI. Newest run per name: 36 pass and 3 are skipped. "tsc + tests (clean install)" is green. CLA is green, and review-checks.sh --diff passes.

Earlier findings.

  • Kewei's 4b0568e blockers 1 and 2 are resolved: 4e389c4, and #5703/#5704 merged.
  • My own 4b0568e follow-ups are resolved: the CI allowlist, and the sentinels docstring at :5.

Still open:

  1. The §15 witness at this sha.
  2. The PR body predates the rebase. It still names test/hermetic-env-rule as the base and does not describe service_manager, token_env_name or the agent_identity delegation. Please add before/after evidence for those four commits.
  3. A maintainer approval at this head.

Nits:

  • connect_common.linger spells --no-ask-password itself instead of using _systemd_run.
  • _ok now exists in three copies.
  • The service_manager docstring says it "cannot print the manager's environment", but its own probe runs show-environment (the output is discarded).

@sonichi

sonichi commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

REVIEW.md §15 live witness at e7fb22c

Owner's go. A scratch gateway bridge, python3 -m ag2_sparrow.remote_gateway_bridge from this head's package, for the test agent identity. It runs from env -i in a private tmux server under nice 19, with scratch task, result and state dirs, and its credential comes from a scratch mode-600 device-env file. The test identity's usual relay was paused first, so there was one bridge per identity. The installed gateway was not touched or restarted. Paths are by role.

CI at e7fb22c: 36 pass, 3 skipped, 0 fail.

Credential resolution through the new owner

I called the PR's discover() directly with the same env the bridge gets:

token_source=file  token_key=REMOTE_TASK_TOKEN  source is the AG2 device env file  fatal=''  notes=0
masked token: <withheld>          url (redacted form): https://chat.ag2.space/relay
token_env_name() = REMOTE_TASK_TOKEN

Bridge start, restart, round trip

bridge #1 (pid 53215): [remote-gateway-bridge] token not in env; loaded from <scratch>/cred/device.env
                       status: connected=True
restart: TERM to 53215 (command and code path verified) → "received SIGTERM — exiting"; pid gone
bridge #2 (pid 56102): [remote-gateway-bridge] token not in env; loaded from <scratch>/cred/device.env
                       status: connected=True, last_ok_ts newer than #1's

The owner posted "witness 5698 task a: reply PONG and the current UTC time" in the test room. Bridge #2 wrote it to the scratch inbox, and the reply was published there:

[remote-gateway-bridge] result task-03c822a0174bec5bee delivered via DeliveryCore (provider=AG2SpaceResultProvider, …)   # one delivery

The token never appears

I loaded the token value in-process from the vault and printed counts only. I checked the full onboarding string and its secret part.

Where Hits
ps -axww -E -o command=, both bridges running, before the owner turn 0
ps -axww -E -o command=, after the owner turn 0
Every scratch file: both bridge logs, the bridge's own gateway-bridge.log, status, locks, tasks, results, helper scripts and the witness log (3054 files; only the mode-600 credential file excluded) 0

Other new surfaces, read-only (scratch HOME)

  • service_manager: on macOS systemctl is not on PATH and systemd_user_available() is False. A unit name that isn't an ag2-sparrow unit is refused before anything runs.
  • Hatch install diff: fed unit texts containing the real token, it renders Environment=<withheld> and <text withheld: it may contain a credential>, with 0 token hits in its output.
  • In the claude-code adapter, token_env_name() only feeds an install declaration's token_env. Exercising it needs install, which a witness on this host must not do, so it was skipped.

Observations (not blocking)

  • The bridge names the winning source in its log only for a file (the path) or the vault (a fixed line). For an env-sourced token it logs nothing, and it never logs a masked value. So the masked-value evidence above comes from discover(), not from the bridge log.
  • Before it was paused, the test identity's own relay launcher exported REMOTE_TASK_TOKEN into its bridge's environment, and the token counted 1 in ps -E. That launcher is host-local test tooling outside this PR. It is why the witness used the file source instead of the environment.

Teardown. TERM to bridge #2 (verified). The private tmux server was killed through its own socket, the scratch credential file deleted, no scratch process is left, and the test relay is restored.

Result: PASS. After a restart under this head the bridge resolves its credential through the new owner, round-trips a real owner message, and the token appears in no log, process listing or produced file.

@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.

Approve at e7fb22c. This replaces my CHANGES_REQUESTED at ad3cbb1. The code review is as in my COMMENT at this head: no code blocker, and the earlier findings are resolved.

REVIEW.md §15 is now satisfied by the witness at this sha (issuecomment-6330181049).

  • Credential: a scratch bridge from this head's package resolved its credential through the new owner (token_source=file, masked).
  • Restart: it reconnected after a TERM restart, with the PID verified.
  • Round trip: a real owner turn was delivered exactly once.
  • Token: 0 hits in ps -E (before and after the turn) and across 3054 scratch files.

One gap the witness can't cover on macOS: the systemd path in service_manager. For that I'm relying on the argv harness from my COMMENT. With subprocess.run patched at b95a3f8 and at e7fb22c, I drove 27 calls each, and the only difference is --no-ask-password --user (order-insensitive for systemctl). Verbs, units, timeouts and kwargs are identical, and all shipped units match UNIT_RE. A Linux post-merge check of ag2-sparrow start/stop would close it.

CI on this head is green (no failing or pending checks), and CLA is green.

Still non-blocking: the PR body predates the rebase, and the three nits from my COMMENT stand.

@sonichi
sonichi dismissed keweichen’s stale review October 22, 2026 01:10

Dismissed on the owner's word (merge over the open CR per his stop rule). The remaining findings are fixed (the sitecustomize leak via hermetic_env, with a regression test) or minor (parent test-tooling gaps, fixed in #5703/#5704). The §15 live witness passed at e7fb22c, and Qingyun approved at this head.

@sonichi
sonichi merged commit 5bcf615 into main Oct 22, 2026
39 checks passed
@sonichi
sonichi deleted the fix/credential-policy-owner branch October 22, 2026 01:11
sonichi added a commit that referenced this pull request Oct 22, 2026
Per-task ack isolation so one failed ack no longer dead-letters a batch (#5734),
the claude-local connector (#5695) with its fixes (#5767, #5769), owner-DM
nudge holds (#5737, #5768), read-only collaborators and the collaborator
timeout (#5687, #5690), and one owner for gateway credentials (#5698).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Oct 22, 2026
Brings in #5698 (one owner for gateway credential precedence and masking).
Conflicts: the bridge takes its credentials from gateway_credential_sources and
keeps the scrubbing hooks, gateway_call wrapping and diagnostics.describe sinks;
the Hatch install diff is gateway_credentials.mask_env_assignments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Oct 22, 2026
#5698 owner

- Hatch's wake-context registers candidates through
  gateway_credential_io.register_candidates, not its own key list and token-file
  parser; the install diff is gateway_credentials.mask_env_assignments.
- gateway_instance.SinkRegistry: the owner's registry, whose values the log and
  status sinks withhold too (a URL by its credential parts, so the bare gateway
  still renders).
- The owner registers every value of a token file (each assignment and each raw
  line) and the token file a channel .env names, which is the rotation file
  whenever that file wins.
- Sinks main added since the branch point take diagnostics.describe(); a
  room_upgraded replacement is kept only as a room id holding no secret; the
  poll-anomaly line is logged from our own fields, not from the exception.
- Tests follow: the install-diff assertions take the owner's whole-line masking;
  the long-poll cases expect the exception type, not its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Oct 22, 2026
…._run after the rebase

Main moved connect_common's manager runner into service_manager (#5698); the codex-local
suite now patches the runner where it lives, as the claude-local suite already does.

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

Copy link
Copy Markdown
Collaborator

Post-merge re-review at exact head e7fb22c7: the focused suites, CI, diff checks, and the final-head §15 witness are green, but I found one credential-guard false negative that needs a follow-up.

_names_only() currently accepts the accessor beneath any ast.keyword and beneath any dict value. It also does not model imported aliases. These executable environment reads therefore produce no finding:

os.getenv(key=_sources.token_env_name())
os.environ.get({"token_env": _sources.token_env_name()}["token_env"])

from .gateway_credential_sources import token_env_name as key_name
os.getenv(key_name())

Evidence from credential_access(source, "probe.py") at this head:

os.getenv(_sources.token_env_name())                              => ['probe.py:2 token_env_name used other than as a name in a record']
os.getenv(key=_sources.token_env_name())                          => []
os.environ.get({"token_env": _sources.token_env_name()}[...])    => []
import-as key_name; os.getenv(key_name())                         => []

The first positive control fires, and os.getenv(key=...) is a real environment read. The shipped Claude adapter still uses the accessor safely as a record value, so this is not evidence of a current credential leak; it is a structural enforcement gap in the stated “name in a record, never read the variable with it” contract. Please make the guard reject environment-reader keyword/dict-laundering forms and either resolve or ban import aliases, with negative controls for the three cases above.

The PR merged while this requested re-review was running, so a formal REQUEST_CHANGES review was no longer available; recording the evidence here for a follow-up on main.

— Sutando (susans-MacBook-Pro)

sonichi added a commit that referenced this pull request Oct 22, 2026
…r under the shared supervised-session lifecycle (#5761)

* feat(ag2-sparrow): `ag2-sparrow-codex-local` hosts the Codex connector under the shared supervised-session lifecycle

- adapters/codex/connect_cli.py, console script ag2-sparrow-codex-local, on connect_common as
  #5695 left it: install is the quiescent replacement under the per-shape operation lock (stop,
  prove absent and free, the marked 0600 config with ROOT, the marked unit, start, a fresh holder
  of this shape); run is the manager's entrypoint (AG2_SPARROW_MANAGED=1; debug-run by hand),
  takes the work root's lock for its own life, makes the connector's dirs under the held inode,
  spawns the connector as its direct child and attaches it as the consumer
  (holder_lock.attach_consumer); SIGTERM terminates, reaps and releases; another holder is 75,
  the connector's own 75 is 1. verify adds the codex pin, login status under the allowlist, the
  sandbox and non-owner echo and an occupancy line naming launcher and connector to the shared
  rows; uninstall is the shared any -> absent path.
- codex_connector: --held (no lock of its own; starts once the holder's record names it, else
  exit 2); child_env passes CODEX_HOME when set.
- README launch section rewritten for that lifecycle; the limits name what is not offered yet
  (the agent's default root, the per-agent bridge declaration).
- tests/test_codex_local_connect.py (20): fake codex and the stub app-server, a fake manager
  whose start verbs run a stand-in holder, a real child for the attach, the launcher as a real
  process; the wrapper under tests/ and a CI line.

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

* docs(ag2-sparrow): the Codex README keeps the pinned upgrade-paths statement

The holder-lock README pin requires the exact sentence naming the Hatch installer and the
keepawake self-upgrade as the supported paths and the manual stop for everything else; the
installer is stated beside it as keeping that order, not as a third path.

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

* fix(ag2-sparrow): codex-local runs the checked executable, serves held only as a verified consumer, and shares the holder sequence

- connect_common owns the launcher-side policy both shapes ran as copies: `hold_and_supervise` (take
  the root, refuse a tasks symlink, the shape's prepare and spawn hooks, stop-before-start, attach the
  child as the consumer, reap on any failure, the release line), `supervise_child`, `kill_and_reap`,
  `holder_words` and a `labels` parameter on `occupancy_line` (held names the holder and its live
  consumer; unobserved never reads free), and the launchd entry shim (`launchd_exit`, `run_launchd`,
  `run_child_argv`). claude_code's hold, cli and launcher delegate to them; codex's connect_cli does
  too. The adapter-boundaries suite pins the delegation and refuses a copy of any owned step.
- codex-local on macOS runs `run --launchd` with KeepAlive on anything but a clean exit, so a logical
  75 (another holder) exits 0 and launchd stops instead of relaunching over it.
- The app-server starts from the exact executable the version check passed: the launcher passes
  `--codex <path>` and `server_argv` runs it, never a PATH lookup; pinned with a `codex` earlier on
  PATH that must not run.
- `--held` serves only once the holder's record names this incarnation (pid and start time) and
  verifies it alive; a record with liveness false or unknown, or another start time, is exit 2 with
  one line; pinned for all three, each wait bounded so a mutation fails instead of hanging.
- Tests cover the held sequence's stop-before-start, spawn error and unconfirmed-kill branches and
  the shim's exit table; the fake codex is a /bin/sh wrapper that survives an interpreter path with a
  space (pinned); the push test and the connector env test use explicit, cleared env fixtures so an
  ambient CODEX_HOME cannot reach them; the differential driver's server_argv stand-in takes the
  executable argument.
- README: the lock is the connector's first act only when it runs on its own; the launchd shim and
  its exit mapping; the exact executable; the verified-consumer rule; a SIGKILLed debug-run launcher
  orphans the connector, which keeps the root held until stopped by hand.

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

* fix(ag2-sparrow): the held connector lives in the root inode, debug-run takes the operation lock, and the suite leaks nothing

- The launcher passes the held root's descriptor to the connector (`--root-fd`, pass_fds); under
  `--held` the connector fchdirs into it and names tasks/, results/ and state/ relative to it, so a
  work root renamed and recreated under the same pathname after startup is never read or written
  (the owner thread's cwd, a pathname by protocol, is the inode's current one per turn). `--held`
  without the descriptor is a usage error; a descriptor that is not a directory is one line and exit
  2. Pinned as real processes: rename, recreate, another holder on the replacement; the moved root
  still answers, the replacement keeps its task.
- `debug-run` takes the shape's operation lock from its checks to its acquisition (the shared helper
  calls `acquired()` there) and publishes its holder record under `<shape>.debug`; install confirms
  only the shape's own label, so a hand holder that won the root reads "did not acquire". Pinned: the
  lock refusal, the label with the lock freed during the serve, and install over a debug holder.
- The suite: the fake codex records its JSON by temp file and rename (a poller never read a torn
  file); the launcher's process is ended and its pipes closed at cleanup whatever the test decided
  (the ResourceWarnings on Linux came from a launcher leaked by an earlier assertion); every wait is
  one bounded helper; the held happy path runs traced under a holder that attached it; the shared
  helper's prepare refusal, reap tolerance and unobserved words, the CLI's login and root refusals
  are covered. Diff coverage measured the gate's way: 99%.

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

* fix(ag2-sparrow): every Codex turn names its own cwd, so a warmed owner channel follows the moved root

turn/start now carries the turn's working directory (the 0.160.1 TurnStartParams
override), resolved per turn: the owner's from the held inode's current pathname
(or --cwd), a non-owner's from its scratch dir. A channel whose thread opened before
the root was renamed no longer works at the replacement's pathname.

The rename test now completes an owner turn first, then renames, recreates and holds
the replacement: the same channel's next turn and a fresh channel's both run at the
moved root, results land in the held inode, the replacement gets none. The launcher
cleanup terminates before it kills, so a failing case no longer orphans the connector.
The boundary pin also catches an owned step imported under another name.

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

* test(ag2-sparrow): codex-local's manager fake patches service_manager._run after the rebase

Main moved connect_common's manager runner into service_manager (#5698); the codex-local
suite now patches the runner where it lives, as the claude-local suite already does.

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

* test(ag2-sparrow): codex-local connect test isolates its env before gateway imports and spawns its launcher with a hermetic child env

Answers review 5618033999: isolate_os_environ() now runs at module top,
before the ag2_sparrow imports, and the launcher child's env comes from
isolated_child_env() instead of dict(os.environ, ...). fcntl moves below
the isolation because the scanner's Python 3.9 stdlib fallback omits it.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Oct 24, 2026
… with a built-in fallback (#5665)

* feat(ag2-sparrow): serve the Hatch worker's wording from the backend, with a built-in fallback and self-upgrade

Sparrow's heartbeat carries package_version and keeps the reply's setup_revision,
min_version and target_version for adapters. The Hatch hook prompt becomes a stub
that sends the worker to `ag2-sparrow-hatch wake-context`, which prints the served
prompt and worker note from the setup cache, else the built-in copies (today's
wording verbatim); the package's tier notes are always printed and served
tier_addenda follow them. Served tunables are clamped to package bounds. The
keepawake run refreshes the cache through one fetch boundary (no transport yet,
so it returns None) and starts one detached, hash-pinned upgrade per target
version, rolling back on any failure and reporting upgrade-failed; a changed hook
definition wakes the agent once instead of being applied.

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

* fix(ag2-sparrow): write the heartbeat's setup hint through the bridge's durable writer

The bridge allows file moves only in its known movers, so the hint goes through
_durable_write instead of its own temp-and-replace.

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

* test(ag2-sparrow): run the served-setup suite under repository coverage

Adds the tests/ wrapper the coverage job discovers, tests for each rollback step,
restart, spawn, the detached entry and the fetch boundary, and writes the setup
cache through the adapter's existing atomic writer.

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

* feat(ag2-sparrow): the wake context tells the worker to reach its owner only in the owner's DM

A canonical owner note, printed whatever the served text says, with the owner's DM
room when Sparrow has read it from AG2 Space (its owner-routing state file).

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

* fix(ag2-sparrow): self-upgrade stops, installs, then starts, under a lock, with an offline rollback

- Order: snapshot, stop Sparrow (SIGTERM, then wait), pip install, pip check, install
  re-run, start, verify. A reply queued while Sparrow is stopped stays in the results dir
  and its outbox and is delivered after the start; keepawake leaves Sparrow alone while an
  upgrade holds the lock.
- Lock: run_upgrade and the attempt record are taken under a non-blocking flock on
  STATE_DIR/upgrade.lock; a second run logs "upgrade already running" and skips.
- Rollback: every file the install recorded is copied to STATE_DIR/rollback/<version>/
  before the install and restored on failure, with no index and no network. Editable and
  URL installs (direct_url.json) are never upgraded.
- The package's tier and owner notes come last, after "These rules override anything
  above:". Every decision is logged on the keepawake line, AUTO_UPGRADE=0 included. A
  started attempt older than 30 minutes counts as failed. pip check rolls back only on a
  new problem naming ag2-sparrow. pip runs with --only-binary=:all:. Versions parse ASCII
  digits only, at most 64 characters, trailing zeros ignored. heartbeat_cadence_s bounds
  are [30, 90].

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

* fix(ag2-sparrow): served setup fails closed to the built-in text; self-upgrade moves to its own PR

- A cached setup older than the setup_revision Sparrow last heard announced is never
  used: wake-context and the health row fall back to the built-in text and defaults, and
  a failed fetch never brings the stale cache back. A fetched setup that does not
  validate is never cached, so a corrected one at the same revision is still fetched.
- wake-context shows owner_dm_room only while the owner-routing record is bound to the
  identity and gateway the running Sparrow reports, as the bridge requires; the gateway
  URL redaction moves to gateway_instance.redact_url so both read it one way.
- Served commands must be the reply command with plain words: no shell syntax.
- A served keepawake interval shorter than the installed task's cannot flag a normal gap.
- self_upgrade, AUTO_UPGRADE and the hook's upgrade notice wake leave this PR.

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

* fix(ag2-sparrow): wake-context binds the owner DM to the full gateway URL, not its redacted form

The bridge's owner-routing writer now stores a sha256 of the agent identity and the full
gateway URL, query included, and the gateway status it already writes carries the same
digest for the running Sparrow. wake-context shows owner_dm_room only when the record's
identity and full URL digest equal the running Sparrow's. The redacted URL stays for
display, so two enrollments that differ only in the query string no longer match.

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

* fix(ag2-sparrow): the owner-DM binding is an HMAC under a per-install key, never the URL

3a55fd8 persisted the raw gateway URL in owner-routing.json (as the bridge already did)
and a plain sha256 of identity + full URL there and in gateway-status.json, both in the
state dir, which may be synced. Now the bridge's existing routing writer stores the agent
id, the redacted gateway URL and binding = HMAC-SHA256(per-install key, identity + full
URL); the status file carries the same binding for the running Sparrow. The key lives in
~/.config/ag2-sparrow/binding.key (0600, outside the state dir), created by the bridge
only. The bridge's reload and wake-context compare bindings through the one helper,
gateway_instance.binding_digest; a record from before this change still reloads by its
stored full URL.

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

* fix(ag2-sparrow): only the owner-routing writer creates the binding key, under the config home

The bridge suite pinned the routing record's old shape (raw gateway URL); it now pins the
redacted URL plus the HMAC binding, with its key under a temporary config home. The key
path follows XDG_CONFIG_HOME, else ~/.config, and only the owner-routing writer creates
it, so the status writer and the reload never write outside the state dir.

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

* fix(ag2-sparrow): migrate legacy routing records, key under the passwd home, publish the key atomically

- A pre-binding owner-routing record (full gateway URL on disk) is rewritten on
  load through the bridge writer as the redacted URL plus the keyed binding,
  atomically and once; the bridge creates the key on that path, so a cold
  upgrade keeps the owner DM. A record that cannot be bound is never written,
  and an unmigrated reading is not marked persisted, so the writer retries.
- The binding key lives under the effective uid's passwd home, never
  $XDG_CONFIG_HOME/$HOME, so a service unit and the Hatch hook resolve one key.
- The key is written to a 0600 temp file, fsynced and os.link'ed into place;
  on EEXIST the existing key is used. An empty, short, malformed, non-0600 or
  foreign-owned key is refused, and replaced only once older than a 5 s grace.
- redact_url fails closed to a placeholder; the startup and token-rotation logs
  name only the redacted gateway. Digests compare with hmac.compare_digest.

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

* fix(ag2-sparrow): tier notes come from task_io; the key home is holder_lock's

- The built-in runtime text's tier note is task_io.non_owner_note(), the one
  owner of the non-owner policy the hook already reads; the JSON copy is gone,
  and the test pins the hook's read and the built-in text to it.
- binding_key_path() uses holder_lock._home() (effective uid's passwd home);
  the local copy of that lookup is removed.

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

* fix(ag2-sparrow): single-winner key recovery, URL-safe error text, stale routing temps swept

- Creating or recovering the binding key runs under an exclusive lock on the
  key dir (file_lock), and every writer re-reads the key under it before
  binding, so two writers recovering a bad key end up with one key.
- Every bridge log line and the gateway-status error pass through
  gateway_instance.redact_text: the full gateway URL becomes its redacted form,
  and text still carrying userinfo, the query, a query value or the fragment
  is withheld whole (an InvalidURL from a URL with a space in its query).
- On load the bridge removes the pre-binding writer's crash leftovers
  (.owner-routing<sfx>.json.<pid>.tmp): regular files in the state dir only,
  this uid's, past a 5 s grace, whose pid is dead; symlinks are not followed.
- owner_dm_room compares bindings with hmac.compare_digest.

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

* fix(ag2-sparrow): the key home goes through task_io; the routing-temp sweep is a known mover

- task_io stays the one module that imports holder_lock: it gains user_home()
  (holder_lock._home(), None without a passwd entry), and binding_key_path()
  uses it instead of importing holder_lock.
- The disposal guard lists _sweep_routing_temps as a known mover: it unlinks
  only dead writers' owner-routing temps in the state dir, never a result.

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

* fix(ag2-sparrow): fail-closed URL redaction in every rendering; routing migration has its own owner

- redact_text withholds text when any credential-bearing URL part survives
  in any rendering: raw, repr/unicode_escape-escaped (a tab, LF or DEL in the
  query, as http.client's InvalidURL renders it) or percent-encoded/decoded.
  Parts are matched with the delimiter the URL puts before them (`=value`,
  `k=v`, `?query`, `userinfo@`, `#fragment`), so a short value is caught with
  no length guard. Parts are split by hand as well as by urlsplit, which drops
  tab/CR/LF, and an unparseable URL is covered by the hand split.
- The owner-routing migration (legacy record rewrite, old-writer temp sweep)
  moves to routing_migrations: idempotent, never raises, and retried at each
  load until it finishes; the bridge's _load_routing runs it first, before the
  reading is prepared. A temp past 1 h is stale whatever its pid says.
- A key with a trailing newline is refused; binding comparisons use bytes, so
  a non-ASCII binding no longer raises.

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

* fix(ag2-sparrow): a temp-sweep error no longer blocks the routing rewrite; pin three redaction/compare paths

- routing_migrations: an OSError on one swept entry skips that entry and
  leaves the migration unfinished (retried at the next load), but the legacy
  record rewrite still runs; a temp that vanishes mid-sweep is not an error.
- Tests pin the userinfo parts (u:SECRETPW@, token@), the bare-key query
  parts (?flag, &flag, ;flag) and the bytes compare_digest in wake-context
  and in the bridge's reload (a non-ASCII binding is refused, never raises).

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

* fix(ag2-sparrow): gateway-call errors carry only their type; tri-state routing migration; age-only sweep

- Redaction by construction: every call that receives the gateway URL (_req,
  the reenroll claim) runs inside _gateway_call, which re-raises what it
  raises as a subclass of the same type whose text is only
  "<type> contacting <redacted gateway>" plus the HTTP status; text and bytes
  attributes are dropped, the status code, errno and an HTTPError's body stay.
  The event channel and the card poster, which hold the URL too, keep only the
  exception type. redact_text stays as a second net on every log line and
  status error.
- routing_migrations returns done (rewritten and read back), not_needed, or
  failed (unknown: a read error, an unbindable, unwritten or unverified
  record, or an unfinished sweep), retried at the next load. After a rewrite
  the bridge reads the record from disk again before using it.
- The legacy temp sweep is age-only: older than 1 h is stale whatever pid
  its name carries, this process's own included.

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

* fix(ag2-sparrow): every gateway-URL call is wrapped, guarded by a static test; scrubbing excepthooks

- gateway_instance.gateway_call is the one wrapper; the bridge's _gateway_call
  delegates to it. Every call that receives the gateway URL or a URL derived
  from it now runs inside it: _req, the reenroll claim, the media fetch, the
  event channel's stream open and the card poster's post.
- The event channel catches any exception around its open and keeps only the
  type, so a control character's InvalidURL no longer kills its thread.
- A scrubbed exception exposes no __context__, and nested exceptions in its
  attributes (URLError.reason) are scrubbed too.
- The bridge installs scrubbing sys.excepthook and threading.excepthook:
  an uncaught exception prints as its type and redact_text'd text only.
- A static guard finds every Request/urlopen/build_opener/HTTP(S)Connection
  call in the package and fails unless it sits inside gateway_call (the
  routine adapter, which talks to a different endpoint, is the one exemption).
- redact_text compares percent escapes case-insensitively.

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

* fix(ag2-sparrow): log and status sinks take structured safe fields only

The sinks are the boundary. A log line or a status field takes an
exception's type (with an OSError's errno name and an HTTP status), the
redacted gateway identity and text the code wrote, never an exception's text,
a response body or gateway-supplied free text. Behaviour code still reads
HTTPError bodies for control flow.

- diagnostics.py owns the policy: describe(exc) for any exception, code_field
  for a gateway-supplied token (an error name, an approval code: a short
  token absent from every form of the URL), and safe_status, which nets every
  nested string of the status payload through redact_text and withholds
  non-JSON values.
- Every exception interpolated into a log or a status field in the bridge,
  the event consumer, the default observer and the Hatch keepalive goes
  through describe (the vendored local-file core modules are outside it).
- Reenroll: the refusal body is never logged; refused_error and the approval
  code are kept only as code_field tokens.
- Static guards: no exception object or response body reaches a sink except
  through describe/type/.code; the status file is written only through
  safe_status; gateway_call is matched by exact name, with aliases, openers
  and bare references resolved. The production _reenroll_claim() is driven
  with credential forms and stdout, stderr, the log file, the status file and
  the whole test home are scanned together.
- Smaller: a dedented per-row assertion loop, a nested-reason scrub test, and
  the excepthooks report the innermost file:line.

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

* fix(ag2-sparrow): owner-routing migration without a latch; the reading is rebuilt from disk on every load

- routing_migrations runs on every load and reports what disk still holds:
  done (it migrated and nothing is left), not_needed, pending (a legacy temp
  still younger than the 1 h bound is left for a later load), or failed. No
  state is latched, so a young temp is swept by the same process once it
  ages past the bound, and a failed read-back after a committed rewrite can
  no longer mark anything complete.
- _load_routing has no loaded/migrated latch: it migrates, then rebuilds the
  in-memory reading from the verified disk record each time. A gateway
  reading the writer has not committed yet is kept until its write commits.
  A re-enrolment is detected against the reading's identity and gateway.
- Tests: a young temp ageing through _load_routing in one process; a failed
  read-back after a committed rewrite still yields the owner DM; the bridge
  source carries no latch.

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

* test(ag2-sparrow): the nested-reason case uses ECONNREFUSED by name, not a macOS errno

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

* test(ag2-sparrow): in-process cases for the excepthooks, the event channel's type-only status and the migration's read-back and sweep errors

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

* fix(ag2-sparrow): diagnostics.describe names the reason, errno and raising line

describe() now reads `URLError(ConnectionRefusedError/ECONNREFUSED)
contacting <redacted gateway> at <file>:<line>`: the reason's type and errno
name (an EAI_* name for a resolver error), so refused, DNS and timeout read
differently, and the innermost frame's file name and line. A KeyError gives
its key only when the failing code wrote it as a string literal; otherwise
just its type. The poll loop's catch-all, the event channel and the card
poster log through it, so every location comes from one place.

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

* fix(ag2-sparrow): one secret registry for the sinks, a taint-following sink guard, foreign routing clears

- diagnostics keeps one registry of the process's secrets: every credential
  part of the gateway URL, the bearer token, the media token and any token
  read from a file. Logs, status fields and the excepthooks withhold text
  holding any of them in any rendering, and code_field refuses a value that
  holds one or is a piece of one, so an echoed bearer is neither a 403 error
  name nor an approval code. A bare secret shorter than 8 characters is not
  matched alone; delimited URL parts are, at any length.
- The archive-reconcile note and the room-gate log now go through describe,
  with the health-snapshot error and a logged proactive response. They
  slipped past the sink guard because it checked sink arguments only; it now
  follows exception text and response bodies through local text assignments
  and into a same-module helper's parameter, in line order.
- A foreign owner-routing record clears a reading that came from disk.
- describe trusts a gateway attribute only on a scrubbed exception class;
  safe_status withholds non-finite numbers.

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

* fix(ag2-sparrow): a call handed a sink function is itself a sink; two report_once sites describe

- The settle-ledger read and the claim-recovery failure handed raw exception
  text to disposal.report_once(..., _log, ...); both now go through
  diagnostics.describe. The sink guard missed them because _log was passed as
  a value: any call handed a sink function as an argument is now a sink for
  its other arguments. The revert test covers both sites.
- A test runs a registered secret through the bridge's _log and status
  writer and through safe_status, so dropping the registry from either fails.
- Matching: percent-escape hex in any case, the bytes of a secret exactly
  (code_field no longer lower-cases them), and redact_text matches the URL's
  scheme and host in any case.

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

* fix(ag2-sparrow): tokens match at any length; URL username registered; hook record scrubbed; owner DM only while connected

- The bearer, media and file-read tokens are registered at any length: the
  8-character floor now applies only to bare URL query values, where a short
  one would withhold ordinary words. A short bearer echoed as a 403 error or
  an approval code reaches no sink.
- The URL's username is registered on its own, beside the whole userinfo and
  the password.
- The excepthooks scrub every piece of the record (thread name, file name,
  type, text) and withhold the whole record if a secret spans the pieces.
- owner_dm_room hands out the owner DM only while Sparrow's status says it is
  connected, with a matching binding.
- safe_status runs the registry on values; keys, the payload's own field
  names, go through the URL net only.
- The sink guard treats a call handed a sink function (by name) as a sink;
  a variable that happens to be called log or error is not one.

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

* fix(ag2-sparrow): close the red-team findings: setup hint, owner DM, case-folded echoes, every hook

- F1: the heartbeat reply's setup_revision is kept only as a code_field
  token and its versions only as PEP 440 (diagnostics.version_field); the
  hint then runs through safe_status and is written with NaN refused.
- F2: gateway_instance.owner_room is the one check for an owner DM: a
  strict Matrix room id on the agent's own homeserver, holding no registered
  secret. The bridge applies it to /v1/agents, the routing record (writer
  and reader), the withheld-review cache and a created room; wake-context
  applies it again before printing, with the device env's bearer and URL
  registered in its process.
- F3: secret matching is case-folded on both sides everywhere (the
  registry, code_field, redact_text's credential parts), so an echo in
  another case is withheld.
- A scrubbing sys.unraisablehook, which an exception in an atexit callback
  also reaches.
- _guarded_result_body reports describe(exc), and the Hatch install diff
  withholds credential-named values and registered secrets.
- Tests kill the four surviving mutants: the spread-across-pieces hook
  check, the bare password, the bare fragment, and delimited parts at any
  length.

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

* fix(ag2-sparrow): an owner DM that is a piece of a secret is refused; behavioural review-failure test

- diagnostics.holds_or_is_piece is code_field's rule, now shared:
  owner_room also refuses a room whose localpart (6 characters or more)
  is a piece of a registered secret in any case, so "!<part of the
  bearer>:ag2.space" reaches neither the routing files nor the prompt.
- The private-review test is behavioural: _route_withheld_review raises an
  OSError whose path holds a canary, and neither _guarded_result_body's
  reason nor the caller's log carries it.
- Tests pin that _routing_record never stores an unchecked owner DM and
  drive the vendored result_disposal lines through the bridge's _log with
  the bearer in the path, exact and upper case.

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

* fix(ag2-sparrow): withhold every credential candidate, mask install diffs, keep refused revisions refused

- Hatch registers the env and device-file credentials (both aliases, the
  media token, every line of a rotated token file) each on its own, so the
  one precedence discards is still withheld from wake-context and the diff.
- The install diff withholds every value on an Environment= line and any
  credential-named value; other URLs keep only scheme, host and path.
- A heartbeat whose setup_revision is refused records setup_revision_withheld,
  and runtime_text treats it as a mismatch: no stale served text revives.
- /v1/agents.owner is admitted only through gateway_instance.owner_mxid
  (Matrix user id, no secret, no piece of one) before it is cached or invited.
- team_result_guard load failures reach the log as diagnostics.describe(exc).
- diagnostics.forms is total over lone surrogates, so a non-UTF-8 token in a
  bytes environment no longer stops the bridge importing.

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

* fix(ag2-sparrow): Hatch and the bridge register credentials through the #5698 owner

- Hatch's wake-context registers candidates through
  gateway_credential_io.register_candidates, not its own key list and token-file
  parser; the install diff is gateway_credentials.mask_env_assignments.
- gateway_instance.SinkRegistry: the owner's registry, whose values the log and
  status sinks withhold too (a URL by its credential parts, so the bare gateway
  still renders).
- The owner registers every value of a token file (each assignment and each raw
  line) and the token file a channel .env names, which is the rotation file
  whenever that file wins.
- Sinks main added since the branch point take diagnostics.describe(); a
  room_upgraded replacement is kept only as a room id holding no secret; the
  poll-anomaly line is logged from our own fields, not from the exception.
- Tests follow: the install-diff assertions take the owner's whole-line masking;
  the long-poll cases expect the exception type, not its text.

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

* fix(ag2-sparrow): owner_mxid pins the owner to the agent's homeserver

owner_mxid checked for a piece of a registered secret only in the localpart, so
an owner whose server part carried a piece of the bearer (in any case, or with a
suffix) was kept, written to the withheld-review-dm cache and invited. It now
applies owner_room's whole rule: the owner's server must be the server part of
the agent's identity, so no server part can carry a piece. A piece check on the
server part would instead refuse the real homeserver.

Tests: the two server-part repros go through _gateway_owner and
_owner_review_dm; the unit cases add server-part pieces, a foreign server, a
missing identity, and the ROOM_PIECE_MIN boundary on the localpart.

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

* test(ag2-sparrow): the install diff withholds five more valid systemd shapes

test_install_withholds_every_environment_assignment_... now also passes an
Environment line with whitespace around `=` (both spacings), a continued
Environment group whose second line holds the token, an `env` assignment on
ExecStart, and a `--token` argument. mask_env_assignments withholds each one;
the per-line masker it replaced printed all five verbatim.

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

* test(ag2-sparrow): build every gateway-reaching test environment with the hermetic helper

tests/hermetic-env-structural.test.py flagged six sites: two child
environments in test_hatch_served_setup.py copied os.environ minus a
denylist, and four files imported a module that now reaches the credential
contract with no module-top isolation. The two child environments are now
hermetic_env(home, ...) with the same overrides, and each file isolates
os.environ before its first project import. test_event_consumer.py and
taskify-redacts-room-bodies.test.py come into scope once diagnostics
delegates to gateway_credentials, so they are isolated here too.

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

* fix(gateway-credentials): a URL key's plain URL registers by its parts; bound the token file's raw line

The discovery owner registered every candidate whole, so a plain gateway URL
read under a URL key withheld its own redacted form from any sink that shares
the registry. register_pairs() now registers such a URL by its credential
parts (register_gateway_url), keeps every other value whole (a token key's
URL-shaped bearer included), and env_files carries (key, value) pairs so the
rule can see the key. This moves the routing the Hatch SinkRegistry did into
the owner.

token_file_values registered every '='-free line of a token file at any
length, so a stray "ok" blanked log lines. token_file_pairs keeps each
assignment and adds only the raw line token_file_token would read once no
assignment is left; beside a token assignment it counts only at
BARE_SECRET_MIN or longer.

Tests (both the src owner and its vendored copy): the bare gateway renders
after discovery while a URL's credential parts, a URL-shaped bearer and the
file token stay withheld; the raw line is registered from an env-named and a
channel-named token file; a short line beside an assignment and any later raw
line stay visible.

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

* refactor(ag2-sparrow): the log and status sinks call the credential owner's masking; delete the private copies

diagnostics.py and gateway_instance.py held a second copy of the masking
contract gateway_credentials owns (fold, forms, _views, secret_parts,
redact_url, redact_text, describe, code_field, safe_status), and it had
drifted: with no systemd-unescape view, an escaped echo of a registered
token such as `synth\x2ddrift-...` passed diagnostics.contains_secret while
the owner's registry caught it. SinkRegistry only fed one registry from the
other.

Now the owner is the one implementation and the package keeps one registry:
- shared/ag2_gateway_credentials.py (regenerated into the package and the
  room-ops skill) gains what only the copy had: scrub_exception, describe's
  " contacting <gateway>" for an exception it made (scrubbed through an
  optional registry), version_field, safe_status's optional registry on
  values only, and the surrogatepass percent form beside the escaped one.
- diagnostics.py holds REGISTRY, the process's one SecretRegistry, and
  each function delegates to the owner with it.
- gateway_instance.py re-exports the owner's redact_url, redact_text,
  REDACTED_* and scrub_exception; SinkRegistry, register_gateway_secrets'
  body and the URL helpers are gone. owner_room and owner_mxid share one
  helper, so their checks cannot drift apart.
- The bridge's _SECRETS is diagnostics.REGISTRY; its log, status and
  _redact_url call the owner. Hatch registers device candidates into the
  same registry.

Where the owner is stricter (systemd-unescaped and unquoted views in
contains and redact_text), the sinks now are too.

Tests: an escaped echo of the bearer (\x2d, \x66, \055) through the bridge's
_log and _emit_gateway_status is withheld (fails at 4a5d2eb, where the log
carries all three). A guard in gateway-credential-sources fails if a package
module defines a masking-contract name that does not delegate to the owner,
repeats an owner function's body under another name, subclasses
SecretRegistry, or holds a module-level registry outside diagnostics; at
4a5d2eb it reports 18 sites.

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

* fix(ag2-sparrow): the saved AGENT_MXID reaches the launched Sparrow, and a revision has one spelling

Install saves AGENT_MXID in its config, but neither the user service's
environment nor the detached Sparrow the keepawake run starts carried it.
Without it the bridge has no identity, so no gateway_binding and no
owner_dm_room, and the owner-DM wording never activated on a normal install.
`install_service` and `_spawn_sparrow` now read the saved value through
`cli.saved_mxid`; with none saved (or not an MXID) they set nothing and
print one NOTE line.

A served setup's `revision` was accepted as an integer and cached as one,
while the bridge's hint kept an announced `setup_revision` only as a
`code_field` string token, so an integer revision was withheld on one side
and never equal on the other. `gateway_credentials.revision_field` is the one
normaliser (an integer that is not a bool is its decimal text, then
code_field's rules); the bridge's hint writer and runtime_text's
setup_problem, refresh_cache and current_cache all call it through
diagnostics. The generated copies are regenerated from the canonical source.

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

* fix(ag2-sparrow): a launched Sparrow runs only as the saved identity, and every cached revision is read normalised

The detached launch built the child's environment from its own, so with
no saved AGENT_MXID a caller's AGENT_MXID or AGENT_ID passed through and
the bridge, whose resolver reads the process env first, ran as that
identity. `_spawn_sparrow` now removes every key in
`agent_identity.ENV_KEYS` in every branch and sets only the validated
saved MXID. A user unit inherits the user manager's whole environment,
so the unit writer gains `UnsetEnvironment=` for the identity keys it
does not set; it is applied after `Environment=`, so the saved identity
is never also unset.

`current_cache` normalised a cached revision only against an
announcement and otherwise returned the raw cache, so a no-hint cache
with a bool, float, missing, malformed or secret revision stayed usable
and a legacy integer stayed an integer. `runtime_text.cache_revision`
is now the one reader, with the registry and the redacted gateway URL
Sparrow reports: `current_cache` normalises before the comparison and
returns a copy whose revision is text, and a revision that does not
normalise reads as no cache. `keepalive main` registers the device
secrets before any cache is read, as wake-context does.

An end-to-end test runs the shipped install and the real spawn with a
stand-in Sparrow that is the real bridge module (only the gateway call
is faked), from an environment carrying a foreign identity in both
resolver keys: the launched bridge binds as the installed identity and
wake-context names the owner DM; with no saved identity it binds as
nobody and wake-context names no room.

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

* fix(ag2-sparrow): a launch pins the bridge's identity at every resolver tier, and a legacy cached revision is written back as text

The Hatch launchers removed only the process-env identity keys, so a Sparrow
launched with no saved AGENT_MXID still resolved an identity from a later tier:
an AGENT_MXID / AGENT_ID / AG2SPACE_USER_ID alias in the device env file or the
inherited channel .env, or the durable state/auth/ag2space.json binding.

The policy now lives in the resolver. agent_identity.resolve honours a launch
pin (AG2_IDENTITY_PINNED): a pinned environment answers from its own AGENT_MXID
alone, so a pinned bridge runs as the saved identity or as none at every tier.
launch_environ / pinned_environ are the one way a launcher builds that
environment; the detached spawn and the systemd unit both use them. Channel
config (CLAUDE_CONFIG_DIR, AG2_DEVICE_ENV) is unchanged: only identity is pinned.
The re-enroll log no longer tells a pinned bridge to write its identity into a
channel .env, which it would now ignore.

Tests pin each tier for saved and no-saved launches through the bridge's own
resolver: every FILE_KEYS alias in both file candidates and a durable record,
under the detached child's real final environment and the rendered unit's
final environment; the token-only end-to-end test plants all of them and waits
for the bridge to report connected instead of a fixed settle. A cache an older
build wrote with an integer revision is now rewritten with the text revision.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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