From 7d1f8876542c46ce9027722e5f9bb430075c4ff1 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Thu, 29 Oct 2026 11:08:27 +0100 Subject: [PATCH] Keep gh's token out of the sandbox The local settings template granted sandboxed Bash a read of ~/.config/gh/, so any unprompted sandboxed command could copy the gh token in hosts.yml. Nothing sandboxed needs it: `gh *` is in sandbox.excludedCommands and reads its own credentials outside the sandbox. The template and the sandbox-lint baseline drop the grant, and sandbox-lint now refuses ~/.config/gh in allowRead, as it does ~/.config/apache-magpie. The setup docs and the spec say why, and the isolated-setup fingerprint moves so installed setups are told to update. Generated-by: Claude Code (Opus 5.5) --- docs/setup/sandbox-troubleshooting.md | 2 +- docs/setup/secure-agent-internals.md | 9 ++++----- docs/setup/secure-agent-setup.md | 16 +++++++++------- .../setup_preflight/isolated_fingerprint.py | 2 +- .../claude-settings.local.template.json | 1 - .../agent-isolation/sandbox-add-project-root.sh | 4 ++-- tools/sandbox-lint/expected.json | 1 - tools/sandbox-lint/src/sandbox_lint/__init__.py | 3 +++ tools/sandbox-lint/tests/test_validator.py | 2 ++ tools/spec-loop/specs/agent-isolation-sandbox.md | 3 +++ 10 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/setup/sandbox-troubleshooting.md b/docs/setup/sandbox-troubleshooting.md index 32351f7c9..a94d08a2b 100644 --- a/docs/setup/sandbox-troubleshooting.md +++ b/docs/setup/sandbox-troubleshooting.md @@ -1119,7 +1119,7 @@ Confirm with `prek --version`, or with the doctor skill's - The helper deliberately does **not** mirror the whole committed `allowRead`. That list also names credential paths - (`~/.config/gh/`, `~/.config/apache-magpie/`, `~/.gnupg/`), which + (`~/.config/apache-magpie/`, `~/.gnupg/`), which the same harness behaviour currently keeps out of sandboxed Bash. Re-open one of those only for the tool that needs it, per the entries above. diff --git a/docs/setup/secure-agent-internals.md b/docs/setup/secure-agent-internals.md index 04c30bf6d..4c4c303c2 100644 --- a/docs/setup/secure-agent-internals.md +++ b/docs/setup/secure-agent-internals.md @@ -112,11 +112,10 @@ a `python` snippet the model decided to run) from reading a denied path. `permissions.deny` stops the agent's Read tool from reading the same path. A secure setup needs both: the framework's committed [`.claude/settings.json`](../../.claude/settings.json) deny-lists -`Read(~/.config/gh/**)`, *and* its local template -[`claude-settings.local.template.json`](../../tools/agent-isolation/claude-settings.local.template.json) -(layered in through `.claude/settings.local.json`) allow-reads -`~/.config/gh/` in the sandbox, so `gh` can see its token but the agent -can never read the file. +`Read(~/.config/gh/**)`, and the sandbox's `denyRead: ["~/"]` keeps +`~/.config/gh/` from every sandboxed command. `gh` still sees its token +because it runs outside the sandbox (`sandbox.excludedCommands`), so +neither the agent nor a sandboxed command can ever read the file. ## Linux: bubblewrap + user namespaces diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index 0d2831821..a1701791c 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -552,7 +552,6 @@ The full block below is the two merged, annotated. ".", // the project tree (cwd) "~/.gitconfig", // git's user.name / user.email "~/.config/git/", // git's per-host config - "~/.config/gh/", // gh CLI auth (token in hosts.yml) "~/.cache/", // dev tool caches (uv HTTP cache, prek logs, ruff/mypy caches, and prek's own rustup + CARGO_HOME for the `lychee` rust hook) "~/.local/share/uv/", // uv's tool venvs (prek, etc.) "~/.local/bin/", // uv-installed tool entry points @@ -715,7 +714,7 @@ The full block below is the two merged, annotated. "deny": [ "Read(~/.aws/**)", "Read(~/.ssh/**)", "Read(~/.netrc)", "Read(~/.docker/**)", "Read(~/.kube/**)", - "Read(~/.config/gh/**)", // bash can read it (sandbox.allowRead); the AGENT can't + "Read(~/.config/gh/**)", // neither the agent nor sandboxed bash; only the excluded `gh` reads it "Read(~/.config/apache-magpie/**)", // Gmail OAuth token: only the oauth-draft MCP server reads it, outside the sandbox "Edit(~/.config/apache-magpie/local-tracker-parsers.json)", // the user's custom-parser confirmations: only `trust-parser`, run by the user, writes them "Read(~/.config/gcloud/**)", "Read(~/.azure/**)", @@ -881,11 +880,14 @@ The full block below is the two merged, annotated. } ``` -The deny / allow split for `~/.config/gh/` is deliberate: the `gh` -CLI needs to *use* the credential, but the agent should never *see* -it. `sandbox.filesystem.allowRead` permits the bash subprocess to -read the file; `permissions.deny[Read(...)]` blocks the agent's Read -tool from reading the same path. +`~/.config/gh/` is denied to both the agent and sandboxed Bash. +The `gh` CLI needs to *use* the credential, and it does: `gh *` is in +`sandbox.excludedCommands`, so it runs outside the sandbox and reads +its own token there. No sandboxed command needs the file, and a +readable `hosts.yml` would let any unprompted sandboxed command copy +the token. `permissions.deny[Read(...)]` blocks the agent's Read tool, +and the default `denyRead: ["~/"]` blocks every sandboxed command; +`sandbox-lint` refuses an `allowRead` entry that re-opens it. `~/.config/apache-magpie/` gets no such split: it is denied to both. It holds the Gmail OAuth refresh token, and a sandboxed command runs diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py index 79eddff65..b00c19107 100644 --- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py +++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py @@ -21,4 +21,4 @@ for installs that do not carry the framework source (see `isolated.py`). """ -FRAMEWORK_FINGERPRINT = "sha256:be5231e45d7c001e" +FRAMEWORK_FINGERPRINT = "sha256:51a5a6c8256be7ff" diff --git a/tools/agent-isolation/claude-settings.local.template.json b/tools/agent-isolation/claude-settings.local.template.json index 1d75ec3fd..03e58febb 100644 --- a/tools/agent-isolation/claude-settings.local.template.json +++ b/tools/agent-isolation/claude-settings.local.template.json @@ -8,7 +8,6 @@ ], "filesystem": { "allowRead": [ - "~/.config/gh/", "~/.docker/bin/", "~/.docker/cli-plugins/", "~/.gnupg/", diff --git a/tools/agent-isolation/sandbox-add-project-root.sh b/tools/agent-isolation/sandbox-add-project-root.sh index aec02d56c..5f0f5c5d6 100755 --- a/tools/agent-isolation/sandbox-add-project-root.sh +++ b/tools/agent-isolation/sandbox-add-project-root.sh @@ -50,8 +50,8 @@ # `~/.config/uv/uv.toml`, which the sandbox is denied reading (see # docs/setup/secure-agent-setup.md, "Host-only uv state"). # The set is deliberately NOT a mirror of the committed `allowRead`: -# that list also carries credential paths (`~/.config/gh/`, -# `~/.config/apache-magpie/`, `~/.gnupg/`), and re-opening them to +# that list also carries credential paths (`~/.config/apache-magpie/`, +# `~/.gnupg/`), and re-opening them to # every sandboxed command is a decision for the operator, not for a # helper that runs from a git hook. `--no-tool-paths` skips the set. # diff --git a/tools/sandbox-lint/expected.json b/tools/sandbox-lint/expected.json index 3895293ba..6be33738b 100644 --- a/tools/sandbox-lint/expected.json +++ b/tools/sandbox-lint/expected.json @@ -22,7 +22,6 @@ ".", "~/.gitconfig", "~/.config/git/", - "~/.config/gh/", "~/.cache/", "~/.local/share/uv/", "~/.local/bin/", diff --git a/tools/sandbox-lint/src/sandbox_lint/__init__.py b/tools/sandbox-lint/src/sandbox_lint/__init__.py index aab4eaef3..64c6308f3 100644 --- a/tools/sandbox-lint/src/sandbox_lint/__init__.py +++ b/tools/sandbox-lint/src/sandbox_lint/__init__.py @@ -111,6 +111,9 @@ def registry_errors(floor: dict[str, Any], registry: dict[str, Any]) -> list[str # outside the sandbox; a readable token plus the Google API hosts # would let any unprompted sandboxed command use the user's Gmail. "~/.config/apache-magpie", + # gh's token (hosts.yml). `gh *` runs outside the sandbox and reads it + # there; a sandboxed command never needs it. + "~/.config/gh", } # allowWrite must be even more restrictive than allowRead. A path diff --git a/tools/sandbox-lint/tests/test_validator.py b/tools/sandbox-lint/tests/test_validator.py index 38b810b07..057ba63b5 100644 --- a/tools/sandbox-lint/tests/test_validator.py +++ b/tools/sandbox-lint/tests/test_validator.py @@ -421,6 +421,8 @@ def test_invariant_missing_deny_read_root(baseline: dict[str, Any]) -> None: "~/.azure", "~/.config/gcloud", "~/.config/apache-magpie/", + "~/.config/gh", + "~/.config/gh/", "/", "~/", "~/.ssh//", diff --git a/tools/spec-loop/specs/agent-isolation-sandbox.md b/tools/spec-loop/specs/agent-isolation-sandbox.md index f5365ff9e..a2e56efd2 100644 --- a/tools/spec-loop/specs/agent-isolation-sandbox.md +++ b/tools/spec-loop/specs/agent-isolation-sandbox.md @@ -428,6 +428,9 @@ See `docs/adapters/goose.md`. `~/.config/apache-magpie` in `allowRead`: the Gmail OAuth token there is read only by the drafting MCP server outside the sandbox, and the template no longer grants it or the Google API hosts (#1681). + It refuses `~/.config/gh` in `allowRead` too: `gh *` is excluded from the + sandbox and reads its own token outside it, so no sandboxed command needs + `hosts.yml`, and the template no longer grants it. Because that server runs outside the sandbox, it accepts a caller-supplied `credentials_path` only as a `.json` file directly in `~/.config/apache-magpie/` or in the directory of