Skip to content

Secret guard and template: cover the OpenHands runtime-worker session-key directory - #468

Merged
seathatflowsinourveins merged 2 commits into
mainfrom
claude/guard-openhands-session-20260928
Sep 28, 2026
Merged

seathatflowsinourveins merged 2 commits into
mainfrom
claude/guard-openhands-session-20260928

Conversation

@seathatflowsinourveins

@seathatflowsinourveins seathatflowsinourveins commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

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/secrets under the stack's state root.

  • How: one alternative is added to HOME_CREDENTIAL_STORE. Only a reader, copy or search is blocked; ls, stat, chmod and docker run --env-file still pass. The template gains Read(~/.local/state/native-agent-stack/runtime-workers/openhands/secrets/**) plus its Read(**/...) twin.

  • Deliberately not changed: OH_SESSION_API_KEYS_0 is not added to SECRET_NAMES.

    That vector is recorded as a named gap in docs/secret-storage.md.

  • Lane: lane:foundation. manifests/evidence.json changes only by hash re-registration with host_receipts.register_file.

  • Files:

    • scripts/hooks/secret_path_guard.py
    • adoption/templates/claude.settings.template.json
    • tests/test_secret_path_guard.py
    • docs/secret-storage.md: the coverage row, the 2026-09-27 store section and the recorded gaps
    • adoption/hooks/claude/SHA256SUMS: guard hash abf269ba…
    • manifests/evidence.json

SOTA sources

  • Repository design this extends: docs/secret-storage.md, "Home and tool credential stores (2026-09-27)". Every template credential-store Read deny has a matching guard rule, and tests/test_secret_path_guard.py derives a reader from each deny and expects a block. The derived-reader test picked up the new deny without edits: red on cat, head -n 5, tail -n 3 and rtk read before the guard change, green after.
  • Claude Code permission rules: code.claude.com/docs/en/permissions, read 2026-09-28. ~/ 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.
  • Upstream behaviour it guards: Runtime worker: OpenHands on GPT-6 via OmniRoute (recipe, O1 isolation, reviewed repair round) #425's key files follow OpenHands agent-server 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

Claim Evidence class Command / result
The new rules block readers, copies and searches of the directory, its files and a glob in it integration test Red with the new tests only: FAILED (failures=49), covering plain commands, rtk proxy and keyring exec forms. 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, sibling runs/ reads and code searches of the variable name still pass integration test the same suite's allowed cases
No existing verdict changed in the replayed corpus local replay (builder-run; not independently reproduced) Base and new guard compared over 5,917 command lines from 616 Markdown files (the repo plus #425 at e45c3cd1) and #425's 81 recorded commands: 0 verdict changes. Outside that corpus, rg --fixed-strings 'runtime-workers/openhands/secrets' docs changes from pass to block (see Residuals)
The paper lane's keyring wrapper forms still pass integration test sh …/paperkeys.sh run 1 -- echo ok, kernel_keyring.py status, kernel_keyring.py exec --help added to ALLOWED, and pass on base and head
Template consumers are unaffected integration test tests.test_workflow_hardening 74 OK (2 skipped); the profile, apply-settings and render-config suites: 110 OK
Repository integrity local check python3 scripts/validate.py → "status": "passed" (7927 hashed files); sha256sum -c in adoption/hooks/claude: 9 OK; git diff --check rc 0

test_host_profile_copy_is_verbatim fails on a host until the installed guard copy is refreshed with python3 tools/adoption/install_claude_profile.py --only guard. It is skipped under CI=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:

  • path matching is literal;
  • inherited pipeline and relative-path gaps;
  • the --fixed-strings/-e false positive;
  • an overstated variable-name rationale.

One repair round, 012f548f (docs only), records them. The raw verdict is posted as a PR comment.

Rollout

  • The project hook runs the checkout's own 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.
  • The user-level hook copy and the settings template's Read denies take effect only after the profile installer and the template are applied on each host.
  • The live paper-lane session is told before merge.

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 -e or 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 full docker inspect of 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 after cd, 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

…-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>
@seathatflowsinourveins seathatflowsinourveins added the lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers label Sep 28, 2026
…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>
@seathatflowsinourveins

Copy link
Copy Markdown
Owner Author

GPT-6 cross-family review of 3a4fdbae

Codex CLI codex-cli 0.157.1, model gpt-6-astra, effort max, read-only sandbox, via tools/sota-convergence/landscape-sweep/codex_call.sh. Overall: approve

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

@seathatflowsinourveins
seathatflowsinourveins marked this pull request as ready for review September 28, 2026 21:20
@seathatflowsinourveins
seathatflowsinourveins merged commit 11a23f1 into main Sep 28, 2026
27 of 32 checks passed
@seathatflowsinourveins
seathatflowsinourveins deleted the claude/guard-openhands-session-20260928 branch September 28, 2026 21:39
seathatflowsinourveins pushed a commit that referenced this pull request Sep 28, 2026
…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>
seathatflowsinourveins added a commit that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant