Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/setup/sandbox-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 4 additions & 5 deletions docs/setup/secure-agent-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 9 additions & 7 deletions docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/**)",
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
for installs that do not carry the framework source (see `isolated.py`).
"""

FRAMEWORK_FINGERPRINT = "sha256:be5231e45d7c001e"
FRAMEWORK_FINGERPRINT = "sha256:51a5a6c8256be7ff"
1 change: 0 additions & 1 deletion tools/agent-isolation/claude-settings.local.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
],
"filesystem": {
"allowRead": [
"~/.config/gh/",
"~/.docker/bin/",
"~/.docker/cli-plugins/",
"~/.gnupg/",
Expand Down
4 changes: 2 additions & 2 deletions tools/agent-isolation/sandbox-add-project-root.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
1 change: 0 additions & 1 deletion tools/sandbox-lint/expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
".",
"~/.gitconfig",
"~/.config/git/",
"~/.config/gh/",
"~/.cache/",
"~/.local/share/uv/",
"~/.local/bin/",
Expand Down
3 changes: 3 additions & 0 deletions tools/sandbox-lint/src/sandbox_lint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tools/sandbox-lint/tests/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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//",
Expand Down
3 changes: 3 additions & 0 deletions tools/spec-loop/specs/agent-isolation-sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading