Repository navigation
Secret guard and template: cover the OpenHands runtime-worker session-key directory - #468
Conversation
…-key directory The OpenHands runtime worker (PR #425) has its host driver write each attempt's agent-server session key to two 0600 files in a 0700 directory, ~/.local/state/native-agent-stack/runtime-workers/openhands/secrets/: <run-id>-<arm>.server.env (Docker env-file syntax) and <run-id>-<arm>.headers (a curl -H @file header line), deleted after the attempt. ENV_FILE_WORD already caught readers of *.server.env; nothing covered *.headers, the directory itself or a glob in it. This follows the guard's own design for home and tool credential stores (docs/secret-storage.md, "Home and tool credential stores (2026-09-27)"): HOME_CREDENTIAL_STORE plus matching template Read denies, with tests/test_secret_path_guard.py deriving a reader from every template Read deny. - scripts/hooks/secret_path_guard.py: HOME_CREDENTIAL_STORE adds runtime-workers/openhands/secrets as a directory (the directory, anything in it and a glob in it); comment and docstring extended. - adoption/templates/claude.settings.template.json: adds Read(~/.local/state/native-agent-stack/runtime-workers/openhands/secrets/**) and its Read(**/...) Context Mode twin after the other credential-store denies. Claude Code permissions (https://code.claude.com/docs/en/permissions, read 2026-09-28): Read rules use gitignore syntax, `~/path` is relative to the home directory, and a user-settings rule that should apply in every project needs a `//` or `~/` anchor. - tests/test_secret_path_guard.py blocks: cat, head, tail, cp, cp -r, grep -r, rg and curl -H @ of the header and env files, the directory and a glob in it, also behind rtk proxy and a keyring exec. - It passes: ls, stat, chmod, test ! -e, docker run --env-file, a read of the sibling runs/ state and code searches of OH_SESSION_API_KEYS_0. - Paper-lane non-regression, all passing on base and after: paperkeys.sh run 1 -- echo ok, kernel_keyring.py status and kernel_keyring.py exec --help. The documented exec form was already covered. - Recorded pass-throughs: docker exec printenv, a shell in the container, a full docker inspect, a search of the state directory above secrets/, and a relative read after cd. - A new test checks that the template keeps the deny and its twin. - docs/secret-storage.md: guard coverage row, hand-merge block, and in the credential-store section a new store bullet, the guard paragraph, clients and recorded gaps. The gaps name docker exec into the agent-server and a full docker inspect of it (Config.Env holds the key), which the guard does not model. - adoption/hooks/claude/SHA256SUMS and manifests/evidence.json re-pinned with host_receipts.register_file. OH_SESSION_API_KEYS_0 is deliberately not added to SECRET_NAMES (scope change). It is set only inside the agent-server container, so a host $OH_SESSION_API_KEYS_0 expands to nothing, and docker exec printenv is not a reader. Listing it would refuse plain code searches of the name. Red and green runs (TMPDIR under /var/tmp, python3 -m unittest tests.test_secret_path_guard): - Red 1, tests only: FAILED (failures=49): test_blocked_commands 12, test_every_rule_applies_behind_rtk_proxy 24, test_every_rule_applies_to_a_keyring_exec 12, the template-deny test 1. - Red 2, plus the template deny: FAILED (failures=52). The derived-reader test adds 4 without any change to it: cat, head -n, tail -n and rtk read of .../secrets/sample.txt. - Green, plus the guard: 16 tests, 1 failure, test_host_profile_copy_is_verbatim. The host's installed guard copy is still the previous f2ae894e until install_claude_profile.py --only guard is rerun (docs/secret-storage.md, "User-level guards"). With CI=true: OK (skipped=1). Non-regression replay of the base bdf25d2 guard against this guard: 5917 fenced lines from 616 Markdown files (this repository plus #425's 7 at e45c3cd) and #425's 81 recorded commands, 0 verdict changes. tests.test_workflow_hardening OK (74 tests). test_install_claude_profile, test_apply_claude_settings and test_render_config OK (110 tests). scripts/validate.py passed. git diff --check is clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…row the variable-name rationale The GPT-6 cross-family review of 3a4fdba (approve, low/info amends) found: - path matching is literal: a `./` or interior `//`, a path relative to a parent, and pipeline-fed readers (`find -print0 | xargs -0 cat`, `find -exec rtk read`) pass, as they do for older stores on the base guard (`cat ~/.config/./omniroute/gateway.sqlite`); - a pattern given via `-e` or an unmodelled long option (`rg --fixed-strings`) that names the directory is now read as a search of it; - "never on the host" overstated: the driver never exports the name; listing it would also stop explicit lookups inside `docker exec`. Each case was replayed in-process against base and head guards. Docs only; no guard, template or test change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GPT-6 cross-family review of
|
| id | verdict | severity | location | reason (condensed) |
|---|---|---|---|---|
| coverage | amend | low | scripts/hooks/secret_path_guard.py:117 |
All 462 direct reader, copy and search cases blocked, including trailing slashes, globs, quotes, HOME/XDG spellings and RTK wrappers. Matching is literal, though: rtk read …/openhands/./secrets/F and an interior // pass. The base guard behaves the same way (cat ~/.config/./omniroute/gateway.sqlite). Qualify the coverage claim. |
| inherited-reader-gaps | amend | info | scripts/hooks/secret_path_guard.py:555 |
find DIR -print0 | xargs -0 cat, find DIR -exec rtk read {} +, and paths relative to the state root (tar -cf - runtime-workers/openhands/secrets, < runtime-workers/openhands/secrets/F cat) pass. Equivalent gaps exist for older stores, but these forms are not listed. |
| false-positives | amend | low | scripts/hooks/secret_path_guard.py:548 |
rg --fixed-strings 'runtime-workers/openhands/secrets' docs changes from allowed to credential_file_read, because the parser treats an unrecognised long option's pattern as a path, and -e does the same. Plain -n searches, repository reads, sibling runs/ reads, ls, stat, chmod and docker run --env-file all pass. Scope the zero-change replay to its corpus. |
| template | confirm | info | adoption/templates/claude.settings.template.json:81 |
Both forms are valid per Claude permissions. The derived-reader tests block cat, head, tail and rtk read. |
| secret-name-rationale | amend | low | docs/secret-storage.md:1051 |
Omitting the name is defensible. However, listing it would also stop explicit lookups inside docker exec (python3 -c calling os.getenv(...)), not only a shell. Replace "never on the host" with the driver-specific statement. |
| evidence | confirm | info | tests/test_secret_path_guard.py:777 |
The 49/52 red counts, 16 tests OK with 1 skip, validate.py passing, 9 SHA256SUMS OK and diff --check were all reproduced independently. The 5,917-command replay and the consumer-suite counts were not. |
Overall (verbatim): "approve — The intended literal-path protection works, the template syntax is correct, and the principal test and integrity claims are corroborated. The remaining bypasses inherit existing parser limitations rather than weaken prior protection. Two low-severity qualifications deserve follow-up: legitimate long-option searches can now be blocked, and the variable-name rationale understates protection of explicit lookups. Approval is for the documented accidental-exposure heuristic, not comprehensive credential isolation."
Disposition: each example command was replayed in-process through both the base and head guards, and all reproduced. 012f548f (docs only) records the literal-path, pipeline and relative-path gaps, the -e/long-option behaviour, and the narrowed variable-name rationale. The PR body now scopes the replay claim to its corpus and lists these as residuals. The decision not to list the name stands.
🤖 Generated with Claude Code
…s guard coverage The rebase dropped the earlier registration commit (conflict with #468 in manifests/evidence.json, resolved to main's side), so all 55 files this PR changes are registered again with host_receipts.register_file. research.md no longer says the secret-path guard misses secrets/: #468 (11a23f1) covers it, with docker exec/inspect recorded as the remaining gap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n, reviewed repair round) (#425) * Runtime worker recipe draft: openhands on GPT-6 through OmniRoute (pinned install, gateway FW rules, token MCP wiring, skills, frozen E2E) Written by GPT-6 (gpt-6-astra, effort max) through the Codex stack-worker profile on the OmniRoute lane from the coordinator's brief; tests red then green; no host install or live run yet. Draft pending the Claude cross-family review, the upstream eval-harness mapping and the skills-lifecycle wiring. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Runtime worker openhands: round 2 (SWE-bench grading via OpenHands/benchmarks, native LLM transport) GPT-6 (astra, max) round-2 build: the verdict comes from official SWE-bench 4.1.0 through OpenHands/benchmarks at 405bae71 (SDK submodule 43376f18); the standalone worker pins SDK 1.49.6. check.py validates transport only and relays the official report. Native tool calling on a configurable cx/gpt-6-astra-max route with stable conversation headers and fresh idempotency keys; exact native LLM objects keep SDK accounting and condenser registration. No host ports published; owned rw-openhands- resources. Not installed, not run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands runtime worker: round-3 repair, REST dispatch and both gateway arms GPT-6 repair builder (effort max) against the Claude review (C1-C8), the security review (S2-S12) and the common round-3 requirements: - arms: control cx/gpt-6-astra-max on 20128; engines-on sharedgw/gpt-6-astra-max (one slash) on 20129; arm URL and model carried into the native request and the receipt; - usage only from each arm's entry gateway (unknown stays unknown), compression counted separately; - native agent-server REST start/wait/result on loopback 127.0.0.1:3730 with deterministic paths, early status, a deadline and distinct exits (setup 3, official negative 1, incomplete evidence 2); - SWE-bench checkout via the pinned REPO_BASE_COMMIT_BRANCH; no local grader resource limits; - security: remote MCPs disabled with fail-closed network evidence gates, verified upstream lock and hashed build tools, a resolved mount allowlist, non-root model containers with dropped capabilities, no-new-privileges and read-only roots, bounded no-follow reads of worker files. Declined with residuals: host firewall enforcement (needs host probes) and containerized grader builds; the receipt keeps evidence_complete=false. Coordinator fixes: build-requirements.lock moves setuptools 80.9.0 to 84.0.0 (PyPI wheel sha256 51a52592...), clearing GHSA-h35f-9h28-mq5c (fixed in 83); the four SDK build systems at 43376f1 require only setuptools>=61, and a hashed --only-binary install of the lock succeeds. pins.json carries the new lock digest. The OSV inventory lists requirements.lock and build-requirements.lock. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test_runtime_worker_openhands: resolve temp roots for macOS validate-macos (run 36351247715) failed 6 tests with NotADirectoryError: 'var'. read_bounded refuses every symlink hop from "/" by design, and macOS temp dirs sit under the /var -> /private/var symlink. The test module now realpath-resolves all nine temp roots and keeps the production rule unchanged. Reproduced on Linux with TMPDIR set to a symlinked directory: failures=2, errors=4 before, the same counts as macOS. After the change: 59 tests OK with a symlinked or a plain TMPDIR. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands runtime lock: linux/amd64 environments restriction plus four security upgrades OSV-Scanner 2.6.0 flagged 14 advisories in five packages of the previous lock (CI run 36351247754). v1.49.6 is still upstream's latest release, and its main branch pins the same versions. - Restrict the resolution to the recipe's only install target with uv's documented `environments` setting (pins.json image.platform linux/amd64). - Upgrade anyio 4.14.2, click 8.5.0, pypdf 6.19.0 and soupsieve 2.9.2 (version-pinned `uv lock --upgrade-package`). - cryptography needs no upgrade: the restriction drops the darwin x86_64 48.0.1 line, and the linux 50.0.0 already fixes all three of its advisories. The coordinator reproduced the relock from a fresh copy of the unchanged upstream workspace: uv.lock 30e608b1 and requirements.lock 02d0a7f0, byte for byte. `uv lock --check` exits 0. The classification reports 0 violations (no linux x86_64 wheel or sdist hash removed, no marker change that alters linux x86_64 applicability). The CI osv command exits 0 with "No issues found". The install-container.sh sequence, `uv pip check` and the offline import check all exit 0. research.md and the evidence file (section F) describe the final lock. A superseded five-package variant (cryptography 50.0.1) stays in sections C to E. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands worker: phase-1 takeover repairs for #425 (F16-F20, session key, port) - F16: runs that end stuck or at MaxIterationsReached now export and officially grade the partial patch and exit 1, never 0; other native errors stay exit 3. The termination comes from the native status plus the newest ConversationErrorEvent (SDK@fcc102a state.py:48-79, local_conversation.py:727-733,753-755,2021-2043,2339-2360, event_router.py:68-139, event_service.py:456-460). - F17: receipts no longer read /run-output or the server state directory; trace, skill, MCP and version fields are not_collected with the reason (receipt schema 4). - F18: README scope corrected (the relock covers only the recipe venv) and a grype 0.119.0 digest-scan receipt added for the pinned image. - F19: recorded as overturned for the reference host; documentation only. - F20: removed the unreferenced round-1 files. - Preflight requires the OH_SESSION_API_KEYS_0 name in OPENHANDS_SERVER_ENV, checking the name only (docker/cli@v29.8.1 pkg/kvfile/kvfile.go:92-124). - --port is validated to 3730..3799 as in PR #428 host.py:44-45; default 3730, resolver 3740; stored in status.json and revalidated by dispatch. manifests/evidence.json is intentionally not updated; the coordinator re-registers hashes after rebasing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands takeover phase 1 follow-up: bound F16, fix install image identity Applies the coordinator decisions of 2026-09-28 on top of bac0b278. - F16: finish_result keeps a termination label only as a refinement of the REST execution_status (PERMITTED_TERMINATIONS). Exit 0 requires REST "finished"; a planted event can move exit 3 to 1 at most. The README states the residual (SDK@fcc102a event_store.py:144-169,320-362, event_service.py:420-431, subprocess_terminal.py:157-170) and the analysis rule: exit 1 and 3 are both non-success, and reruns re-queue only exit 3. - install(): pinned_image_identity() requires the exact pinned ref in RepoDigests. It accepts a plain .Id equal to the index digest (containerd store) or the config digest (classic store), and on containerd it checks the linux/amd64 platform manifest. Sources: moby docker-v29.8.1@464cd50c daemon/containerd/image_inspect.go:28,71-73, 95,97; daemon/images/image_inspect.go:59; daemon/internal/image/store.go:152,160; fs.go:120; daemon/internal/distribution/pull_v2.go:431-434. - The empty OH_SESSION_API_KEYS_0 value is documented as a residual. - run_worker, record_event and observations() are kept because they are still referenced. Red: 73 tests, failures=29, errors=1. Green: 73 tests OK. Record: blueprints/runtime-workers/openhands/evidence/phase1-followup-commands.json Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands phase 2 (1/4): proxy base URL, recorded compression combos, unreachable host MCP Both arms now call the per-attempt proxy alias http://gw:8081/v1; the arm's gateway (10.0.2.2:20128 control, :20129 engines-on) becomes the proxy's fixed upstream (gateway_upstream). engines-on selects one of the seven header combos recorded in the 20129 apply record (default allow-lossy); control sends none. One helper, environment_selection, reads OPENHANDS_* for host.run and the container worker, with an empty OPENHANDS_COMPRESSION meaning unset. The receipt (schema 5) records the proxy base URL, the upstream and the combo. ai-memory and socraticode are disabled in the tool policy as unreachable by design under O1; no proxy route is added for them. Red: 75 tests, failures=5 errors=7 (all in the changed contracts). Green: 75 tests OK. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands phase 2 (2/4): O1 topology, pinned nginx allowlist proxy, per-attempt session key Replaces the DOCKER-USER receipt contract (model_network, the egress host-file block) with the per-attempt topology from the phase-2 plan (section 1, E1). For stem S = <run-id>-<arm>: - S-int is created --internal --ipv6=false with gateway_mode_ipv4=isolated and S-gw --ipv6=false, both owner-labelled and read back through inspect --format; a rejected option fails closed with no fallback. - The agent-server joins S-int only and publishes nothing. - A pinned nginx-unprivileged proxy (index ed04ec1f, linux/amd64 manifest f4522a5f) is created on S-gw with 127.0.0.1:<port>:8080, connected to S-int with alias gw, then started. config/proxy-nginx.conf allows three exact /v1 routes with fixed methods, refuses query strings, and passes only an allowlisted header set with fixed correlation, session and compression values. host.py renders it per attempt from arm_config and refuses any value outside its pattern; the rendered file is 0644 and outside every model mount. - docker_args allows S-int only for the server and the P1/P2 probe, and S-gw only for the P0 probe. - Teardown removes proxy, server, then both networks by exact name with confirmed-removal records; never prune. E2: the session key is generated per attempt with secrets, written with O_CREAT|O_EXCL|O_NOFOLLOW at 0600 under the state root (docker env-file syntax plus the curl header file), never in argv, and deleted after the containers are confirmed removed. The OPENHANDS_SERVER_ENV and OPENHANDS_HEADERS pointer variables are retired; dispatch derives the header path from the validated attempt identity. Inventory row openhands-session and its docs/secret-storage.md row are added. install() pulls and identity-checks the proxy image; run() re-checks it. Red: 85 tests, failures=2 errors=90 (implementation stashed). Green: 85 tests OK; secret guard, credential, host-request and codex-lane suites: 153 tests OK (9 skipped). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands phase 2 (3/4): P0-P2 containment probe, dispatch-start gate, method pairing - e2e/netprobe.py: locally composed stdlib probe (plan section 1), run in the pinned agent image on <stem>-gw (P0) and <stem>-int (P1, P2). Raw targets go through http.client; only status, route class and errno names are kept. P3 is a skeleton and is not run. - host.py: P2 targets from ss/ip plus both attempt networks, a selected-field container contract, run_probe writing a 0600 isolation-probe.json bound to network and container IDs, verify_isolation (at most 900 s old, live IDs), `host.py probe`, and a probe stage in run() that tears down on failure. - dispatch.py: start refuses without a fresh bound receipt, before the serial lock and without mutation; E3 pairs each native route with its one method. - receipt.py: schema 6 with an ID-free isolation summary, the "probe" failure stage, and probe container removals counted. Red: 104 tests, failures=16, errors=43. Green: 104 OK. Docker is mocked; no container, network or gateway request. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands phase 2 (4a/4): teardown action and README for O1, the probe and the gate - host.py teardown: removes a prepared or failed attempt by exact name without starting a conversation; refuses with no Docker call while a conversation may be live or the serial reservation names the attempt; a prepared status becomes torn_down, which start and probe refuse. - README: session-key files and the container environment (E2), proxy ingress and method pairing (E3), the O1 topology, the locally composed nginx allowlist with its nginx.org and nginx source citations, the P0-P2 probe and its receipt, the dispatch gate, P3-P5 as documented steps, and the coordinator's live probe sequence. G7's P3 half is not enforced in code and is documented as the coordinator's duty. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands phase 2 (4b/4): E5 decision record, research corrections, evidence record - docs/decisions/2026-09-28-openhands-resolver-isolation.md (E5): O1 against split-port, DOCKER-USER, O2, O3/O4, O5, O7 and the daemon-wide loopback switch, with overturn conditions; the agent-branch ruleset variants; the scoped narrowing of the 2026-09-25 host-request-lane clause for the resolver lane only; a pointer to the follow-up PR for the GitHub harness; the build's deviations from the plan. - research.md: takeover phase 2 corrections and open items. - evidence/phase2-commands.json: red, failed-attempt and green unit runs for all four slices, and the read-only host observations behind the nginx pin (registry byte hashes, pull by digest, image identity) and the selected-field network template; a test ties it to pins.json and scans it with the repository's private-content patterns. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands phase 2: correct $S-int membership and host-side reach wording The decision record said only the agent-server and the P1/P2 probe join $S-int; the proxy also joins it as gw. The README said only the host reaches the proxy's 8080 while the next sentence said the agent reaches it as gw:8080. Both now state the actual membership and reach. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands phase 2: record the repository checks and their base control Adds entries 21-24 to evidence/phase2-commands.json: the post-fix recipe suite, the validate job's read-only check commands and the full unit suite, both run in an unprivileged network namespace with only loopback, and a base-versus-head control. Every failure also occurs at efa73f40: the stale evidence manifest, credential ancestor checks under the user namespace, and a worktree under /tmp. The record now names the 4b and review-fix commits, and the decision record and research note describe the added evidence part. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands phase 2: bound the namespace claim and mark masked suite results The record said no network request could leave the namespace; a network namespace isolates TCP/IP only, so it now says no TCP or UDP traffic could leave, that path-based unix sockets stayed reachable, and what bounded them (no Docker CLI on PATH, zero owned resources afterwards). Entry 24 now says the base control shows no new failure but not a pass at head, and names the one failing test that reads changed paths. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands repair R1: grade with network mode none and inspect every grader container The official grader containers had upstream network settings (the default bridge), so grading could reach host-loopback listeners through 10.0.2.2. e2e/docker_grader.py now: - forces network_mode "none" on every ContainerCollection.create spec and refuses any upstream network, network_mode, networking_config or network_disabled option (docker-py 7.1.0 models/containers.py:686-694); - refuses any SDK POST /containers/create body whose HostConfig.NetworkMode is not "none" (api/container.py:445-457, types/containers.py:351); - refuses to grade when the created container's inspect shows another mode or any network besides "none", and removes that container (moby docker-v29.8.1 daemon/create.go:251, container_operations.go:363-406). main() installs the transport through install_transport, which the tests drive with fake docker modules. README and the decision record scope containment to grading, document the fail-closed offline-verdict trade-off (SWE-bench v4.1.0 test_spec/python.py:443-444, test_spec.py:55-60) and the overturn condition: a separately probed internal network for graders. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands repair R2, R4 and addendum b: probe targets, route evidence and resolver class R2. probe_targets added each network's subnet .1 and run_probe refused when an attempt container held a target. moby docker-v29.8.1 allocates no gateway in isolated mode (bridge_linux.go:700-713, network.go:1594-1602), so the server always holds .1 on $S-int and every live probe would have refused. Targets now come from recorded IPAM gateways only; the attempt containers' own addresses (server_int, proxy_int, proxy_gw) are excluded and recorded by role in targets.excluded, never a refusal. The unit fixtures now model the isolated network (no int gateway, server at .1). R4. P2 now: - starts with a positive control, a TCP connect to gw:8081; - requires every pair outside the run subnet to fail with ENETUNREACH or EHOSTUNREACH, not a timeout (the host passes --subnets and classifies from the expected pairs); - sends one DNS datagram (RFC 1035 4.1.1-4.1.2) to 10.0.2.3:53, which must get no answer and fail for want of a route; - reads /proc/net/route (linux v6.18 fib_trie.c:2940-3000) and requires no default route and no RTF_GATEWAY route. Addendum (b). The P2 summary and receipt keep dns_errors (EAI_NONAME vs EAI_AGAIN); the pass condition is still that no address resolved. README probe section and research.md:436 are corrected to match. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands repair R6/G7 and probe-receipt trust: re-derive the verdict, require recorded stage gates Probe-receipt trust (GPT-6 host.py:953). verify_isolation trusted the receipt's passed booleans. checked_probe_counts now re-derives the verdict from the recorded counts and refuses contradictions: both exit codes 0; P0 and P1 fully observed and matched; a non-empty target set with pairs equal to addresses times ports; every P2 pair observed, failed with a named error and, off-subnet, unreachable; the positive control connected; no DNS answer; all five names matched; no IPv6 address, default route or gateway route. R6/G7. verify_stage_gates requires the host-owned (0600, outside the checkout) <state>/stage-gates.json that the coordinator records live: passed G2 scans of both pinned image refs, P3 (plus P4 and P5 for engines-on) recorded under the pinned proxy image and the current proxy template digest with a gateway build, and the arm's G5 record, none dated after the check. The recipe never writes the file, so dispatch start and host.py run refuse until it exists. README, research.md and the decision record now describe the gate and its limit (the declared build is not checked against the running build). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands repair R3 (G5): read each reachable gateway store read-only and allowlist its providers verify_isolation now runs verify_gateway_providers before any Docker read. For each OmniRoute store the arm reaches (control: 20128; engines-on: 20129 and the 20128 store it forwards to), gateway_surface opens sqlite with mode=ro and query_only and runs four statements only: SELECT provider FROM provider_connections (no credential column) SELECT count(*) FROM combos the values of settings keys blockedProviders and noAuthFallbackDisabledProviders (no other settings key) check_gateway_surface refuses a provider row outside the host file's per-arm allowlist (gateway_providers: control codex, engines-on openai-compatible-responses-*; only a trailing * wildcard), any routing combo, and any no-auth or anonymous-fallback provider that settings do not disable by id or alias. Extension beyond the brief: OmniRoute 045aa81f3 and dd6e9607e serve those providers with a synthetic credential and no provider_connections row (src/sse/services/auth.ts:739-800,1193-1201), and they include the subprocess-backed devin-cli-agentic, auggie and zcode, so a row-only check would pass while they stay reachable. read_host_file is factored out of preflight, which now also validates the allowlists. Tests use fixture stores only (trace-checked statements, mode=ro, unchanged bytes, no secret in the output); an audited suite run recorded no access under ~/.local/share/omniroute*. README (new G5 section), research.md and the decision record describe the check, its consequence (the live stores refuse until those providers are disabled) and its limits (codex app-server opt-in unread, inactive rows count, combos refused not resolved). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands repair R5 (source-checked), gw:8080 log format and key-file wording R5 deviation: receipts keep time-window attribution. At OmniRoute@045aa81f3 and @dd6e9607e the /v1/responses route passes a fresh randomUUID to handleChat (src/app/api/v1/responses/route.ts:193,213; requestId.ts:100-102; chat.ts:436), which call_logs.correlation_id stores (attemptLogging.ts:611). The recipe fixes /v1/responses, so filtering on the run id would drop every row (negative control: 'empty_window' != 'observed'). The attribution string now says why, and a regression test counts rows with gateway-generated IDs. The P3 skeleton moves its correlation check to a /v1/chat/completions call, the route that keeps a caller ID (route.ts:292-322). gw:8080 access log: an http-level ingress format (time, method, status) so a URI sent to the host side is not copied whole; 8081 keeps combined for P5 (nginx release-1.30.5 ngx_http_log_module.c:170-171,230-232). check_server_env docstring: reads the file and compares only the variable name; the value is never logged or returned (addendum a). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * OpenHands repair round docs: scope the containment claims and record red/green C9: the decision record separates what holds by construction (the agent-server's containment, grading with no network) from G5's checked gateway state, and narrowing condition 2 names the proxy routes, the session key, G5 and offline grading. G5 joins the narrowing's overturn list. GPT-6 items: every "three routes" claim now says three routes reach OmniRoute and gw:8080 reaches only the agent's own server (README, decision record, MCP policy reasons, netprobe docstring). The header bullet lists nginx's own defaults at release-1.30.5 (generated Content-Length or chunked framing, an empty and so omitted Connection; ngx_http_proxy_module.c:747-757,4210-4228) and its framing refusals (ngx_http_request.c:2034-2064). The session key is "not intentionally logged", with the retained-log residual. P0-P2 are described as bounded observations with the trusted-producer limit. R5: README usage accounting and the P3 steps state why receipts cannot match the run id on /v1/responses at the pinned OmniRoute builds; P3 expects the routed model the receipt matches. evidence/repair-round-commands.json records each code item's red run, the suite on each fixing commit (re-run from git archive), the literal-R5 negative control, the live-store audit and its planted-event control, and the pinned source reads; a new test checks its pairing and publishability. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Rebase onto main 83229e2: register the PR's files; research notes #468's guard coverage The rebase dropped the earlier registration commit (conflict with #468 in manifests/evidence.json, resolved to main's side), so all 55 files this PR changes are registered again with host_receipts.register_file. research.md no longer says the secret-path guard misses secrets/: #468 (11a23f1) covers it, with docker exec/inspect recorded as the remaining gap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Scout <scout@local> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Scope
What this PR changes: the Claude secret-path guard and the settings template now cover the OpenHands runtime-worker session-key directory,
runtime-workers/openhands/secretsunder the stack's state root.<run-id>-<arm>.server.envand<run-id>-<arm>.headers, at 0600 in a 0700 directory. The files are deleted once removal is confirmed..server.envfile was caught, by the.envrule (dotenv_read). The.headersfile, the directory and a glob in it were not.How: one alternative is added to
HOME_CREDENTIAL_STORE. Only a reader, copy or search is blocked;ls,stat,chmodanddocker run --env-filestill pass. The template gainsRead(~/.local/state/native-agent-stack/runtime-workers/openhands/secrets/**)plus itsRead(**/...)twin.Deliberately not changed:
OH_SESSION_API_KEYS_0is not added toSECRET_NAMES.$OH_SESSION_API_KEYS_0protects nothing.segment_reasonwould refuse ordinary code searches of that literal, which appears in Runtime worker: OpenHands on GPT-6 via OmniRoute (recipe, O1 isolation, reviewed repair round) #425's source and docs.docker exec … printenvis not a reader.That vector is recorded as a named gap in
docs/secret-storage.md.Lane:
lane:foundation.manifests/evidence.jsonchanges only by hash re-registration withhost_receipts.register_file.Files:
scripts/hooks/secret_path_guard.pyadoption/templates/claude.settings.template.jsontests/test_secret_path_guard.pydocs/secret-storage.md: the coverage row, the 2026-09-27 store section and the recorded gapsadoption/hooks/claude/SHA256SUMS: guard hashabf269ba…manifests/evidence.jsonSOTA sources
docs/secret-storage.md, "Home and tool credential stores (2026-09-27)". Every template credential-storeReaddeny has a matching guard rule, andtests/test_secret_path_guard.pyderives a reader from each deny and expects a block. The derived-reader test picked up the new deny without edits: red oncat,head -n 5,tail -n 3andrtk readbefore the guard change, green after.~/is home-relative, and a user-settings rule meant to apply in every project needs a~/or//anchor, hence the~/…rule plus the**/…twin, matching the existing denies.OH_SESSION_API_KEYS_0(OpenHands/software-agent-sdk@fcc102a,openhands-agent-server/openhands/agent_server/config.py:24,V1_SESSION_API_KEY_ENV = "OH_SESSION_API_KEYS_0").Evidence
FAILED (failures=49), covering plain commands,rtk proxyand keyringexecforms. Red plus the template deny:failures=52. Green:CI=true python3 -m unittest tests.test_secret_path_guard,Ran 16 tests,OK (skipped=1)ls,stat,chmod,docker run --env-file, siblingruns/reads and code searches of the variable name still passe45c3cd1) and #425's 81 recorded commands: 0 verdict changes. Outside that corpus,rg --fixed-strings 'runtime-workers/openhands/secrets' docschanges from pass to block (see Residuals)sh …/paperkeys.sh run 1 -- echo ok,kernel_keyring.py status,kernel_keyring.py exec --helpadded toALLOWED, and pass on base and headtests.test_workflow_hardening74 OK (2 skipped); the profile, apply-settings and render-config suites: 110 OKpython3 scripts/validate.py→"status": "passed"(7927 hashed files);sha256sum -cinadoption/hooks/claude: 9 OK;git diff --checkrc 0test_host_profile_copy_is_verbatimfails on a host until the installed guard copy is refreshed withpython3 tools/adoption/install_claude_profile.py --only guard. It is skipped underCI=true, as documented.Review
GPT-6 cross-family review (Codex CLI 0.157.1, gpt-6-astra, effort max, read-only) of
3a4fdbae: approve. It blocked all 462 of its direct reader, copy and search cases. The template forms were confirmed against the Claude permissions docs, and the 49/52 red counts were reproduced. There were four low/info amends, each replayed in-process against the base and head guards:--fixed-strings/-efalse positive;One repair round,
012f548f(docs only), records them. The raw verdict is posted as a PR comment.Rollout
scripts/hooks/secret_path_guard.py, so the rule is live for every session on a checkout as soon as it moves to this commit.Readdenies take effect only after the profile installer and the template are applied on each host.Residuals
Matching is on the literal path:
./or an interior//, a path relative to a parent, and pipeline-fed readers (find -print0 | xargs -0 cat,find -exec rtk read) pass. The same holds for older stores on the base guard.A search whose pattern goes through
-eor an unmodelled long option (rg --fixed-strings 'runtime-workers/openhands/secrets' docs) is now blocked. This is the parser's existing behaviour for every store; a positional pattern passes.docker exec <server> printenv/env/a shell in the container, and a fulldocker inspectof the agent-server, still show the key. The guard does not model docker subcommands.A search or copy of the parent
runtime-workers/openhands, a relative read aftercd, and a key file copied outside the directory are not caught.The template deny names the default state root only. The guard matches the directory under any root.
🤖 Generated with Claude Code