diff --git a/adoption/hooks/claude/SHA256SUMS b/adoption/hooks/claude/SHA256SUMS index 727050b31..23129e286 100644 --- a/adoption/hooks/claude/SHA256SUMS +++ b/adoption/hooks/claude/SHA256SUMS @@ -1,5 +1,5 @@ df3248659c191d8800dfd64a8c7d415fc13652cebee08c9437bf579862c2e561 effort-default-guard.py -f2ae894eddeed3583d87da7382b897f1a8b492c160547f99bb7aa91ec0b4b2a7 ../../../scripts/hooks/secret_path_guard.py +abf269ba41a126400e084b4f03828eb4f0e6fc2375633c0e788136158a3054b1 ../../../scripts/hooks/secret_path_guard.py d6c3c19d6e94d3f48d2788963c8c9be45ec57e6bb81cab92b2c30d9fd5c8e8d6 token-lanes-block.md c81a91c4ffb2fa906feb403d5a11bd294b6cf2db45795d80f9acd662c5004973 token-lanes-block.builder.md 47cc80b262468557b8cfe5c243598030757270674bc7bf286fba8c0c573abc28 token-lanes-block.researcher.md diff --git a/adoption/templates/claude.settings.template.json b/adoption/templates/claude.settings.template.json index e9bea450d..69ff1cc1e 100644 --- a/adoption/templates/claude.settings.template.json +++ b/adoption/templates/claude.settings.template.json @@ -78,6 +78,8 @@ "Read(**/mnt/*/Users/*/AppData/Roaming/omniroute/**)", "Read(//mnt/*/Users/*/.omniroute/**)", "Read(**/mnt/*/Users/*/.omniroute/**)", + "Read(~/.local/state/native-agent-stack/runtime-workers/openhands/secrets/**)", + "Read(**/.local/state/native-agent-stack/runtime-workers/openhands/secrets/**)", "Edit(~/.bashrc)", "Edit(~/.profile)", "Edit(~/.zshrc)", diff --git a/docs/secret-storage.md b/docs/secret-storage.md index d21563116..628d78b73 100644 --- a/docs/secret-storage.md +++ b/docs/secret-storage.md @@ -681,7 +681,7 @@ is replaced (`tools/adoption/install_claude_profile.py --only guard`, see | `scripts/git-hooks/pre-commit` (gitleaks on staged changes) | known secret shapes in a commit, before it is made | `--no-verify`; clones where `core.hooksPath` is not set; values with no recognizable shape | | CI gitleaks (`validate.yml`), GitHub secret scanning and push protection (public repo) | pushes and history that contain known provider patterns | anything not yet pushed; custom formats. This layer only reacts after the fact | | Project `.claude/settings.json` deny rules | Claude's Read/Edit tools on the listed paths (including both Hugging Face token files at their default location, and since 2026-09-27 the [home and tool credential stores](#home-and-tool-credential-stores-2026-09-27)); `printenv`, `env`, `gh auth token`, `hf auth token`, `git credential fill`, `gh auth git-credential`; through the `**/` twins, Context Mode's `ctx_execute_file` and `ctx_index` on the same paths | Python or other subprocesses that open the files themselves, including code run by Context Mode's `ctx_execute` or `ctx_batch_execute` that opens a file directly; forms that do not match the rule text; a moved `HF_HOME`; sessions started outside this repository | -| `scripts/hooks/secret_path_guard.py` (PreToolUse, Bash; project settings and, through the profile installer, user settings) | commands that name a store path (the Hugging Face token files also as `$HF_HOME/...` or `$XDG_CACHE_HOME/huggingface/...`); read or copy the whole Hugging Face home; read `/proc/*/environ` in any spelling; dump the environment; reference a secret variable; trace a process; print a native token (`gh auth token`, `hf auth token`, `huggingface-cli ... token`, `--show-token`, and the credential-helper forms `git credential fill`, `git credential- get`, `gh auth git-credential` that `gh auth setup-git` enables); run a reader (`cat`, `sed`, `awk`, `jq`, ...), copy (`cp`, `scp`, `rsync`) or search (`grep`, `rg`, `ag`, `ack`, `git grep`, `find -exec` with a reader) on a pointer variable such as `$HF_TOKEN_PATH`, a `.env`/`*.env` file, a secret variable **name**, or any path the template's credential-store `Read` denies cover: anything in `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.kube`, an OmniRoute data directory or a `shell_snapshots` directory, each directory and a glob in it, the Docker home, the Docker, git-credential, netrc, npm and PyPI files, and `nativestack/*.key` ([2026-09-27](#home-and-tool-credential-stores-2026-09-27), which on an RTK host is what stops `cat` of them); redirect a pointer variable such as `$HF_TOKEN_PATH` into a command (`<`, `<<<`, `<>`); turn on shell tracing or verbose mode (`bash -x`, `sh -x`, `set -x`, `set -v`, `set -o xtrace`) in a command that sources a credential file; dump the environment (`env`, `printenv`, `export -p`, `declare -p/-x`, inline `os.environ`) after sourcing one; for the kernel keyring ([Guard coverage](#guard-coverage-2026-09-26)), read a payload (`keyctl print`, `pipe`, `read`, `dh_compute`, `list` or `rlist` on anything but an unambiguous keyring, or a keyring read in inline interpreter code), print part of the Tavily key (`tvly auth` without `--json`), or give `kernel_keyring.py exec` or `tvly-keyring` a command that breaks any rule above, names the injected variable or dumps the environment it inherits, also behind a launcher; each text rule also reads the command after quote removal, and every rule reads the command an `rtk` invocation runs ([2026-09-27](#home-and-tool-credential-stores-2026-09-27)) | any Context Mode `ctx_*` call (an MCP tool: the hook is registered for `Bash`, and the guard passes every other tool), a program that imports a loader and prints the result (including `huggingface_hub.get_token()`), an inline interpreter that opens `$HF_TOKEN_PATH` itself (for example `python3 -c "...open(os.environ['HF_TOKEN_PATH'])..."`, which never spells a literal `$HF_TOKEN_PATH`), an archiver such as `tar` on the Hugging Face home, a recursive read or copy of an ancestor directory (`~`, `$HOME`, `~/.cache`, or `$XDG_CACHE_HOME` with a trailing `/` or `/*`) that reaches the Hugging Face home without naming it, a relative read after `cd` into the Hugging Face home, `$HF_HOME/.`, the credential-store gaps recorded under [2026-09-27](#home-and-tool-credential-stores-2026-09-27) (an archiver, a copy or search of `~/.config`, `~/.codex` or `~/.claude`, a client that prints its own store, an OmniRoute `DATA_DIR` elsewhere), obfuscated or renamed paths, a script file that sources and traces on its own, a shell or interpreter started by `exec` that reads its commands from a pipe or a script file, a renamed copy of `kernel_keyring.py`, a variable name assembled at run time, a launcher that takes its command as one string (`script -c`), the macOS `secret run NAME -- command` form, and anything else that is not literal text in the command | +| `scripts/hooks/secret_path_guard.py` (PreToolUse, Bash; project settings and, through the profile installer, user settings) | commands that name a store path (the Hugging Face token files also as `$HF_HOME/...` or `$XDG_CACHE_HOME/huggingface/...`); read or copy the whole Hugging Face home; read `/proc/*/environ` in any spelling; dump the environment; reference a secret variable; trace a process; print a native token (`gh auth token`, `hf auth token`, `huggingface-cli ... token`, `--show-token`, and the credential-helper forms `git credential fill`, `git credential- get`, `gh auth git-credential` that `gh auth setup-git` enables); run a reader (`cat`, `sed`, `awk`, `jq`, ...), copy (`cp`, `scp`, `rsync`) or search (`grep`, `rg`, `ag`, `ack`, `git grep`, `find -exec` with a reader) on a pointer variable such as `$HF_TOKEN_PATH`, a `.env`/`*.env` file, a secret variable **name**, or any path the template's credential-store `Read` denies cover: anything in `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.kube`, an OmniRoute data directory, a `shell_snapshots` directory or the OpenHands runtime-worker `runtime-workers/openhands/secrets` directory, each directory and a glob in it, the Docker home, the Docker, git-credential, netrc, npm and PyPI files, and `nativestack/*.key` ([2026-09-27](#home-and-tool-credential-stores-2026-09-27), which on an RTK host is what stops `cat` of them); redirect a pointer variable such as `$HF_TOKEN_PATH` into a command (`<`, `<<<`, `<>`); turn on shell tracing or verbose mode (`bash -x`, `sh -x`, `set -x`, `set -v`, `set -o xtrace`) in a command that sources a credential file; dump the environment (`env`, `printenv`, `export -p`, `declare -p/-x`, inline `os.environ`) after sourcing one; for the kernel keyring ([Guard coverage](#guard-coverage-2026-09-26)), read a payload (`keyctl print`, `pipe`, `read`, `dh_compute`, `list` or `rlist` on anything but an unambiguous keyring, or a keyring read in inline interpreter code), print part of the Tavily key (`tvly auth` without `--json`), or give `kernel_keyring.py exec` or `tvly-keyring` a command that breaks any rule above, names the injected variable or dumps the environment it inherits, also behind a launcher; each text rule also reads the command after quote removal, and every rule reads the command an `rtk` invocation runs ([2026-09-27](#home-and-tool-credential-stores-2026-09-27)) | any Context Mode `ctx_*` call (an MCP tool: the hook is registered for `Bash`, and the guard passes every other tool), a program that imports a loader and prints the result (including `huggingface_hub.get_token()`), an inline interpreter that opens `$HF_TOKEN_PATH` itself (for example `python3 -c "...open(os.environ['HF_TOKEN_PATH'])..."`, which never spells a literal `$HF_TOKEN_PATH`), an archiver such as `tar` on the Hugging Face home, a recursive read or copy of an ancestor directory (`~`, `$HOME`, `~/.cache`, or `$XDG_CACHE_HOME` with a trailing `/` or `/*`) that reaches the Hugging Face home without naming it, a relative read after `cd` into the Hugging Face home, `$HF_HOME/.`, the credential-store gaps recorded under [2026-09-27](#home-and-tool-credential-stores-2026-09-27) (an archiver, a copy or search of `~/.config`, `~/.codex`, `~/.claude` or the runtime-worker state directory, a client that prints its own store, an OmniRoute `DATA_DIR` elsewhere, and `docker exec` into the OpenHands agent-server or a full `docker inspect` of it, which show its session key), obfuscated or renamed paths, a script file that sources and traces on its own, a shell or interpreter started by `exec` that reads its commands from a pipe or a script file, a renamed copy of `kernel_keyring.py`, a variable name assembled at run time, a launcher that takes its command as one string (`script -c`), the macOS `secret run NAME -- command` form, and anything else that is not literal text in the command | | Codex `[shell_environment_policy] inherit = "none"` | credential and broker variables in the launcher environment reaching Codex shells (measured, see below) | file reads. The setting controls which environment variables a Codex shell inherits, not which files it can open. A Codex shell can still `cat` a store file. The file-level mitigations are the store's location outside every workspace and the Codex sandbox; Codex 0.155.1 has no documented per-path read deny | In plain terms: an agent running as your user in `bypassPermissions` mode can @@ -879,6 +879,8 @@ For a host that does not use the template, merge the same rules by hand under "Read(~/.codex/shell_snapshots/**)", "Read(**/.codex/shell_snapshots/**)", "Read(//mnt/*/Users/*/AppData/Roaming/omniroute/**)", "Read(**/mnt/*/Users/*/AppData/Roaming/omniroute/**)", "Read(//mnt/*/Users/*/.omniroute/**)", "Read(**/mnt/*/Users/*/.omniroute/**)", +"Read(~/.local/state/native-agent-stack/runtime-workers/openhands/secrets/**)", +"Read(**/.local/state/native-agent-stack/runtime-workers/openhands/secrets/**)", "Edit(~/.bashrc)", "Edit(~/.profile)", "Edit(~/.zshrc)", "Bash(printenv)", "Bash(printenv *)", "Bash(env)", "Bash(gh auth token *)", "Bash(hf auth token)", "Bash(hf auth token *)", @@ -911,7 +913,7 @@ credential stores that other tools keep in the home directory: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.kube`, `~/.docker/config.json`, `~/.git-credentials`, `~/.netrc`, `~/.npmrc` and `~/.pypirc`, each with its `**/` twin (corroborated by trailofbits/claude-code-config at `2109be9`, -`settings.json`). Two more stores join them: +`settings.json`). Three more stores join them: - **OmniRoute's data directory.** It holds the gateway's SQLite database, with the provider OAuth tokens and API keys it holds, its logs and @@ -936,6 +938,17 @@ credential stores that other tools keep in the home directory: `~/.ssh`, ([sweep README](../tools/sota-convergence/landscape-sweep/README.md)). The rules cover `~/.codex/shell_snapshots`; a lane-local `CODEX_HOME` elsewhere is covered only by the guard's reader rule below. +- **OpenHands runtime-worker session keys (2026-09-28).** For each attempt + the runtime worker's host driver (PR #425, which adds the + `openhands-session` inventory row) generates an agent-server session key + and writes it at mode `0600`, in a `0700` directory, to + `~/.local/state/native-agent-stack/runtime-workers/openhands/secrets/`: + `-.server.env` in Docker env-file syntax + (`OH_SESSION_API_KEYS_0=`) and `-.headers`, a header + line that curl reads with `-H @file`. It deletes both once the attempt's + containers are confirmed removed. The template alone carries + `Read(~/.local/state/native-agent-stack/runtime-workers/openhands/secrets/**)` + and its twin, because the user settings reach every session on the host. The template alone adds `Edit(~/.bashrc)`, `Edit(~/.profile)` and `Edit(~/.zshrc)`: the operator, not an agent, writes the pointer exports of @@ -982,7 +995,10 @@ match"). `scripts/hooks/secret_path_guard.py` therefore blocks a reader, copy or search of every path those `Read` denies cover as `credential_file_read`. That is anything in `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.kube`, -an OmniRoute data directory or any `shell_snapshots` directory, each +an OmniRoute data directory, any `shell_snapshots` directory or a +`runtime-workers/openhands/secrets` directory (since 2026-09-28; its +`.server.env` files, which the dotenv rule already caught, are now reported +as reads of the directory), each directory itself and a glob in it (`cp -r ~/.ssh`, `grep -r BEGIN ~/.ssh`, `cat ~/.aws/*`, `find ~/.ssh -type f -exec cat {} +`). It also covers the files `~/.docker/config.json`, `~/.git-credentials`, @@ -996,11 +1012,13 @@ project settings, and expects `cat`, `head -n`, `tail -n` and `rtk read` of that path to be blocked. Clients that use a key or a store (`ssh -i`, `ssh-add`, `ssh-keygen -y --f`, `kubectl --kubeconfig`, `curl --netrc`, `gh ssh-key add`), listings, -`stat` and `chmod` pass, because the user hook runs in every session. -Three clients name a store as the operand of a program the guard treats +-f`, `kubectl --kubeconfig`, `curl --netrc`, `gh ssh-key add`, `docker run +--env-file`), listings, `stat` and `chmod` pass, because the user hook runs +in every session. +Four clients name a store as the operand of a program the guard treats as a reader, so they are blocked too: `scp -i KEY`, `rsync -e 'ssh -i -KEY'` and `gpg --homedir ~/.gnupg`. A key named in `~/.ssh/config` or +KEY'`, `gpg --homedir ~/.gnupg` and `curl -H @FILE` on an OpenHands header +file named by its path. A key named in `~/.ssh/config` or loaded with `ssh-add`, and `gpg` without `--homedir`, avoid that. A search's own pattern is not a file it reads (grep(1): `PATTERNS [FILE...]`), so `rg shell_snapshots docs` and `grep -nF '.ssh/id_ed25519' README.md` pass. @@ -1018,11 +1036,42 @@ Recorded gaps, asserted in `tests/test_secret_path_guard.py`: database; - an archiver (`tar czf k.tgz ~/.ssh`); - a glob that names a store only after the shell expands it (`~/.n*rc`); -- a copy or search of an ancestor of a store (`~/.config`, or `~/.codex`, - which holds `shell_snapshots`); +- a copy or search of an ancestor of a store (`~/.config`, `~/.codex`, + which holds `shell_snapshots`, or the runtime-worker state directory + `runtime-workers/openhands`, which holds `secrets`); - a copy or search of a directory that holds an older store file (`~/.claude`, whose `.credentials.json` the guard blocks only by name); -- an OmniRoute `DATA_DIR` elsewhere. +- an OmniRoute `DATA_DIR` elsewhere; +- a store path the guard sees only after the shell or the program resolves + it (2026-09-28 cross-family review; every item passes on the base guard + too, for example `cat ~/.config/./omniroute/gateway.sqlite`): a `./` or an + interior `//` in the path, since the match is on the literal text; a path + relative to a parent (`< runtime-workers/openhands/secrets/F cat` or + `tar -cf - runtime-workers/openhands/secrets` from the state directory); + and a reader fed by a pipeline (`find DIR -print0 | xargs -0 cat`, `find + DIR -exec rtk read {} +`), although `find -exec cat` and `xargs cat` with a + store operand are blocked; +- the OpenHands agent-server's own environment (2026-09-28): `docker exec + printenv`, or a shell in the container, and a full `docker + inspect `, whose `Config.Env` holds the key, show the session + key, and the guard does not model docker subcommands. The variable name + `OH_SESSION_API_KEYS_0` is not one of the guard's secret names. The + runtime worker's host driver (#425) never exports it on the host: it + writes the value only to the two private files and passes their paths + (`--env-file`, `curl -H @file`), so `$OH_SESSION_API_KEYS_0` in a host + command expands to nothing. Listing the name would also stop an explicit + lookup inside the container (`docker exec sh -c 'echo + "$OH_SESSION_API_KEYS_0"'`, or `docker exec python3 -c` code that + calls `os.getenv` with the name), but not `printenv`, `env` or `docker + inspect`, and it would refuse plain code searches of the name (`rg + OH_SESSION_API_KEYS_0`), which the runtime worker's code and this page + contain. + +A search whose pattern is given through `-e` or a long option the guard does +not model (`rg --fixed-strings 'runtime-workers/openhands/secrets' docs`) is +read as a search of that path and blocked. That is the parser's existing +behaviour for every store (`rg -e .ssh/ docs` blocks too); a positional +pattern (`rg -n 'runtime-workers/openhands/secrets' docs`) passes. Since 2026-09-27 every rule of the guard also reads the command an `rtk` invocation runs. The guard runs beside RTK's Claude hook and sees the diff --git a/manifests/evidence.json b/manifests/evidence.json index 39a9b35d5..44f7b1fe6 100644 --- a/manifests/evidence.json +++ b/manifests/evidence.json @@ -3684,7 +3684,7 @@ }, { "path": "adoption/hooks/claude/SHA256SUMS", - "sha256": "55a3cdbb365f36d39bb77f0724ad89ec168a8a0608565bef7368205026f60494", + "sha256": "04dceef287c1a5ec81d37cebcc38a54086c975c8d350c5e7e041c94258a2018d", "bytes": 861 }, { @@ -3864,8 +3864,8 @@ }, { "path": "adoption/templates/claude.settings.template.json", - "sha256": "b6cc2601acf9ccef1e063e6408a557b72d804aee3e53c62c1c16d3e82d19adec", - "bytes": 10839 + "sha256": "25568fe9a02fbebd8618bd5d353f04a511d24856565ca83602c4d9f0d3fa06c2", + "bytes": 11012 }, { "path": "adoption/templates/codex.AGENTS.template.md", @@ -14385,8 +14385,8 @@ }, { "path": "docs/secret-storage.md", - "sha256": "cd94ce3e1aeee410699672d30e48af56e085974423492be555f1cf91b4b48f11", - "bytes": 78005 + "sha256": "cd2c4275b86d0908cb102e7676f68d18faf22a5f39401046fd9e304438985d48", + "bytes": 81526 }, { "path": "docs/stack.md", @@ -41675,8 +41675,8 @@ }, { "path": "scripts/hooks/secret_path_guard.py", - "sha256": "f2ae894eddeed3583d87da7382b897f1a8b492c160547f99bb7aa91ec0b4b2a7", - "bytes": 42839 + "sha256": "abf269ba41a126400e084b4f03828eb4f0e6fc2375633c0e788136158a3054b1", + "bytes": 43178 }, { "path": "scripts/host_receipts.py", @@ -42610,8 +42610,8 @@ }, { "path": "tests/test_secret_path_guard.py", - "sha256": "c9d49144e85cfdfeee37dd632eece84b5f05f6393878e1139317650d53ad0221", - "bytes": 57514 + "sha256": "a46943e13beaf956f6c96c4623451f6d398e4d9f416d41be6d50507260b63ff8", + "bytes": 62922 }, { "path": "tests/test_security_identity.py", diff --git a/scripts/hooks/secret_path_guard.py b/scripts/hooks/secret_path_guard.py index 1db6f0ad4..8f69279be 100644 --- a/scripts/hooks/secret_path_guard.py +++ b/scripts/hooks/secret_path_guard.py @@ -12,12 +12,13 @@ `hf auth token`, or through a git credential helper), reading or searching credential files or secret variable names, reading or copying the whole Hugging Face home or a home or tool credential store (anything in the SSH, -GnuPG, AWS, Azure, kube, OmniRoute and Codex shell-snapshot directories, the -Docker home as a whole, the Docker, git-credential, netrc, npm and PyPI files, -and nativestack key files: every path the settings template's Read denies -cover, which on a host running RTK's hook is how `cat` of them stays blocked -after RTK rewrites it to `rtk read`), tracing a shell while it sources a -credential file, and dumping the environment after sourcing one. Every rule +GnuPG, AWS, Azure, kube, OmniRoute, Codex shell-snapshot and OpenHands +runtime-worker secrets directories, the Docker home as a whole, the Docker, +git-credential, netrc, npm and PyPI files, and nativestack key files: every +path the settings template's Read denies cover, which on a host running RTK's +hook is how `cat` of them stays blocked after RTK rewrites it to `rtk read`), +tracing a shell while it sources a credential file, and dumping the +environment after sourcing one. Every rule also reads the command that an `rtk` invocation runs (`rtk proxy cat F`, `rtk read F`, `rtk run -c '...'`). For a key held in the Linux kernel keyring it blocks payload reads (`keyctl print`, @@ -96,22 +97,24 @@ HF_HOME_ROOT = re.compile(r"(?:(?:\.cache|XDG_CACHE_HOME)\}?/huggingface\}?|^\$\{?HF_HOME\}?)(?:/\**)?$") # Home and tool credential stores as a reader's operand (2026-09-27): every path the settings template's # credential-store Read denies cover. That is anything in an SSH, GnuPG, AWS, Azure or kube directory, an OmniRoute -# data directory (~/.omniroute, ~/.config/omniroute, and Windows' AppData/Roaming/omniroute reached from WSL2) or a +# data directory (~/.omniroute, ~/.config/omniroute, and Windows' AppData/Roaming/omniroute reached from WSL2), a # Codex `shell_snapshots` directory, whose files record every exported value (`declare -xp`, codex-rs -# shell-command/src/shell_snapshot_exports.rs at rust-v0.157.1), each directory itself and a glob in it; the Docker, -# git-credential, netrc, npm and PyPI files, also as an option's `=` value; any key file in a `nativestack` directory -# (Read(~/.config/nativestack/*.key); STORE_PATHS blocks every mention of generation.key); and the Docker home itself -# or a glob over its top level, which reaches config.json (as HF_HOME_ROOT does for the Hugging Face home). On a -# host that runs RTK's Claude hook this rule is what stops `cat`, `head` and `tail -n` of those paths: rtk 0.50.0 -# rewrites them to `rtk read` (src/discover/rules.rs), Claude Code evaluates its permission rules against the -# rewritten command, and RTK's own deny gate loads only Bash(...) rules (src/hooks/permissions.rs, append_bash_rules). -# Only a reader, copy or search is blocked, never a mention, so `ssh -i`, `ssh-add`, `kubectl --kubeconfig`, `chmod`, -# `stat` and `ls` on the same paths still pass in every session, and a search's own pattern is not taken for a file -# it reads (search_paths). tests/test_secret_path_guard.py derives a reader of every template Read deny and expects a -# block. +# shell-command/src/shell_snapshot_exports.rs at rust-v0.157.1), or the OpenHands runtime-worker secrets directory +# (2026-09-28: `runtime-workers/openhands/secrets` in the stack's state directory, where the host driver of PR #425 +# writes each attempt's agent-server session key to -.server.env and -.headers), each +# directory itself and a glob in it; the Docker, git-credential, netrc, npm and PyPI files, also as an option's `=` +# value; any key file in a `nativestack` directory (Read(~/.config/nativestack/*.key); STORE_PATHS blocks every +# mention of generation.key); and the Docker home itself or a glob over its top level, which reaches config.json (as +# HF_HOME_ROOT does for the Hugging Face home). On a host that runs RTK's Claude hook this rule is what stops `cat`, +# `head` and `tail -n` of those paths: rtk 0.50.0 rewrites them to `rtk read` (src/discover/rules.rs), Claude Code +# evaluates its permission rules against the rewritten command, and RTK's own deny gate loads only Bash(...) rules +# (src/hooks/permissions.rs, append_bash_rules). Only a reader, copy or search is blocked, never a mention, so +# `ssh -i`, `ssh-add`, `kubectl --kubeconfig`, `chmod`, `stat` and `ls` on the same paths still pass in every session, +# and a search's own pattern is not taken for a file it reads (search_paths). tests/test_secret_path_guard.py derives +# a reader of every template Read deny and expects a block. HOME_CREDENTIAL_STORE = re.compile( r"(?:^|[/=])(?:\.ssh|\.gnupg|\.aws|\.azure|\.kube|\.omniroute|\.config/omniroute|AppData/Roaming/omniroute" - r"|shell_snapshots)(?:/|$)" + r"|shell_snapshots|runtime-workers/openhands/secrets)(?:/|$)" r"|(?:^|[/=])(?:\.docker/config\.json|\.git-credentials|\.netrc|\.npmrc|\.pypirc|nativestack/[^/]*\.key)$" r"|(?:^|[/=])\.docker(?:/\**)?$") # A .env-style credential file: `.env`, `.env.local`, `.envrc`, `alpaca-paper.env`, `*.env`, diff --git a/tests/test_secret_path_guard.py b/tests/test_secret_path_guard.py index c121a0f93..5d34df2d4 100644 --- a/tests/test_secret_path_guard.py +++ b/tests/test_secret_path_guard.py @@ -19,6 +19,9 @@ HOOK = ROOT / "scripts/hooks/secret_path_guard.py" HOST_HOOK = Path.home() / ".claude" / "hooks" / "secret_path_guard.py" IN_CI = os.environ.get("GITHUB_ACTIONS") == "true" or os.environ.get("CI", "").lower() == "true" +# The OpenHands runtime-worker session-key directory (PR #425's host driver); the run id and arm in the file names +# below are illustrative. +OH_SECRETS = "~/.local/state/native-agent-stack/runtime-workers/openhands/secrets" BLOCKED = { "cat ~/.config/native-agent-stack/alpaca-paper.env": "credential_store_path", @@ -249,6 +252,27 @@ "scp -i ~/.ssh/nas_key build.tgz nas:/volume1/": "credential_file_read", "rsync -a -e 'ssh -i ~/.ssh/nas_key' dist/ nas:/volume1/dist/": "credential_file_read", "gpg --homedir ~/.gnupg --list-keys": "credential_file_read", + # OpenHands runtime-worker session keys (2026-09-28): for each attempt the host driver writes the agent-server + # key to -.server.env (Docker env-file syntax) and -.headers (a `curl -H @file` header + # line) in a 0700 secrets directory, and deletes both once the attempt's containers are removed. A reader, copy or + # search of either file, of the directory itself and of a glob in it is blocked. The .server.env file, which the + # dotenv rule already caught, is now reported as a read of the directory, which the store rule checks first; curl + # is one of the guard's readers, so a curl that reads the header file is blocked too, as `curl --netrc-file` is. + f"cat {OH_SECRETS}/rw-1-engines-on.headers": "credential_file_read", + f"head -c 80 {OH_SECRETS}/rw-1-engines-on.headers": "credential_file_read", + f"cp {OH_SECRETS}/rw-1-engines-on.headers /tmp/h": "credential_file_read", + f"grep -r X-Session-API-Key {OH_SECRETS}": "credential_file_read", + f"rg -uu . {OH_SECRETS}/": "credential_file_read", + f"rg Key {OH_SECRETS}/*.headers": "credential_file_read", + f"cat {OH_SECRETS}/*": "credential_file_read", + f"cp -r {OH_SECRETS} /tmp/s": "credential_file_read", + f"cat {OH_SECRETS}/rw-1-engines-on.server.env": "credential_file_read", + "cat \"$HOME/.local/state/native-agent-stack/runtime-workers/openhands/secrets/rw-1-engines-on.headers\"": + "credential_file_read", + "tail -n 1 \"${XDG_STATE_HOME:-$HOME/.local/state}/native-agent-stack/runtime-workers/openhands/secrets\"/*": + "credential_file_read", + "curl -H @\"$HOME/.local/state/native-agent-stack/runtime-workers/openhands/secrets/rw-1-engines-on.headers\" " + "http://127.0.0.1:8000/alive": "credential_file_read", } # The documented kernel keyring form (docs/secret-storage.md, recipes/tavily.md), and the same with a @@ -507,6 +531,12 @@ f"{EXEC} tvly search set --json", "tvly auth --json > auth.json", "tvly auth --json 2>&1", + # Commands that live lanes schedule (2026-09-28 non-regression): the paper lane's key wrapper starting a command, + # keyring status without a name, and exec's own help. The documented exec forms, `kernel_keyring.py exec + # -- `, are above ({EXEC} and {DEMO_EXEC}) and in test_documented_keyring_commands_pass. + "sh ~/.local/state/native-agent-stack/alpaca-paper/paperkeys.sh run 1 -- echo ok", + "python3 scripts/kernel_keyring.py status", + "python3 scripts/kernel_keyring.py exec --help", ] # Negative corpus: ordinary repository and shell work that must never be blocked. @@ -649,6 +679,20 @@ "rtk ls ~/.ssh", "rtk hook check 'git push origin HEAD'", "rtk --version", + # The OpenHands session-key rule blocks readers only (2026-09-28): a listing, status, mode changes, an existence + # check after cleanup, a `docker run --env-file` that hands the file to Docker, the rest of the runtime-worker + # state and a search whose pattern names the directory pass. So does a code search of OH_SESSION_API_KEYS_0, + # which is not one of the guard's secret names (docs/secret-storage.md, "Home and tool credential stores"). + f"ls -la {OH_SECRETS}", + f"stat -c '%a %U' {OH_SECRETS}/rw-1-engines-on.headers", + f"chmod 700 {OH_SECRETS}", + f"chmod 600 {OH_SECRETS}/rw-1-engines-on.headers", + f"test ! -e {OH_SECRETS}/rw-1-engines-on.headers && echo deleted", + f"docker run --rm --env-file {OH_SECRETS}/rw-1-engines-on.server.env example/agent-server", + "cat ~/.local/state/native-agent-stack/runtime-workers/openhands/runs/rw-1/engines-on/status.json", + "rg -n 'runtime-workers/openhands/secrets' docs", + "rg -n OH_SESSION_API_KEYS_0 docs", + "grep -n OH_SESSION_API_KEYS_0 docs/secret-storage.md", ] # Known heuristic gaps, asserted so a change that closes one is noticed. @@ -703,6 +747,17 @@ "cp -r ~/.codex /tmp/c", "grep -r token ~/.claude", "cat /srv/omniroute-data/storage.sqlite", + # OpenHands session keys (2026-09-28): the agent-server holds its key in its container environment, and the guard + # does not model docker subcommands, so printenv or a shell in the container and a full inspect, whose + # Config.Env holds the key, pass (the container name is illustrative). The variable name is not a listed secret + # name, so a shell in the container expanding it passes too. A search of the state directory that holds + # secrets/, and a relative read after `cd`, pass as for the home stores. + "docker exec rw-1-engines-on-server printenv", + "docker exec rw-1-engines-on-server printenv OH_SESSION_API_KEYS_0", + "docker exec rw-1-engines-on-server sh -c 'echo \"$OH_SESSION_API_KEYS_0\"'", + "docker inspect rw-1-engines-on-server", + "grep -r X-Session-API-Key ~/.local/state/native-agent-stack/runtime-workers/openhands", + "cd ~/.local/state/native-agent-stack/runtime-workers/openhands && cat secrets/rw-1-engines-on.headers", ] @@ -763,6 +818,17 @@ def test_every_template_read_deny_has_a_blocked_bash_reader(self): with self.subTest(rule=pattern, command=command): self.assertIsNotNone(guard.check(command)) + def test_template_denies_the_openhands_session_key_directory(self): + # The template reaches every session on a host, so it denies Claude's file tools the directory that holds + # each OpenHands runtime-worker attempt's session key (2026-09-28), with its Context Mode twin right after it; + # test_every_template_read_deny_has_a_blocked_bash_reader then expects the guard to block its readers. + deny = json.loads((ROOT / "adoption/templates/claude.settings.template.json").read_text(encoding="utf-8"))[ + "permissions"]["deny"] + rule = f"Read({OH_SECRETS}/**)" + self.assertEqual(rule, "Read(~/.local/state/native-agent-stack/runtime-workers/openhands/secrets/**)") + self.assertIn(rule, deny) + self.assertEqual(deny[deny.index(rule) + 1], "Read(**/" + rule[len("Read(~/"):]) + def test_documented_keyring_commands_pass(self): # The keyring commands in the fenced blocks of the pages that document them. documented = [line for page in ("recipes/tavily.md", "docs/secret-storage.md", "adoption/tools/README.md")