Skip to content

fix(sandbox): keep uv state the host executes out of sandbox reach - #1785

Merged
potiuk merged 2 commits into
apache:mainfrom
potiuk:fix/sandbox-uv-host-state
Oct 24, 2026
Merged

potiuk merged 2 commits into
apache:mainfrom
potiuk:fix/sandbox-uv-host-state

Conversation

@potiuk

@potiuk potiuk commented Oct 24, 2026

Copy link
Copy Markdown
Member

Hooks, MCP servers, the excluded uvx / uv run commands (vetted-op-read without a prompt) and the operator's terminal run uv-managed code outside the sandbox. The reference setup let the sandbox write both places that code came from: ~/.local/share/uv (interpreters, tool venvs, credentials) and ~/.cache/uv, where uvx runs packages straight from archive-v0.

  • ~/.local/share/uv becomes read-only to the sandbox. It is dropped from allowWrite, sandbox-add-project-root.sh removes the old grant, and sandbox-lint rejects it.
  • The host gets its own uv cache via cache-dir in ~/.config/uv/uv.toml, which the sandbox is denied reading, so sandboxed uv keeps its default writable cache.
  • Covered by install Step U, verify check 19, an update drift item and a troubleshooting entry.

🤖 Generated with Claude Code

Hooks, MCP servers, the excluded uvx / uv run commands (vetted-op-read
with no prompt) and the operator's terminal run uv-managed code outside the
sandbox, but the reference setup let the sandbox write both places it came
from: ~/.local/share/uv (interpreters, tool venvs, credentials) and
~/.cache/uv, where uvx runs packages straight out of archive-v0. Any
sandboxed command could plant code that then ran unsandboxed.

~/.local/share/uv is now readable only: dropped from the framework
allowWrite, never added by sandbox-add-project-root.sh, which removes the
write it once added, and rejected by sandbox-lint. The host gets its own uv
cache from cache-dir in ~/.config/uv/uv.toml, which the sandbox is denied
reading (uv skips an unreadable config), so sandboxed uv keeps its default
writable cache. Install Step U, verify check 19, an update drift item and a
troubleshooting entry cover the switch.

Generated-by: Claude (Opus 5.5)
Review follow-up. Verify check 19b, install Step U and the docs only named
configured allowWrite entries, so a cache-dir under /tmp or $TMPDIR would
pass while the sandbox writes there without a grant. The host cache must
now sit outside every allowWrite and additionalDirectories entry, every
project root and the implicit temp roots, symlinks resolved, with no
writable root inside it.

Generated-by: Claude (Opus 5.5)
@github-actions github-actions Bot added family:setup setup-* skills family:tools tools/* family:docs Docs, MISSION.md, READMEs capability:platform Framework / agent substrate skills (install, verify, doctor, override, status, setup bootstrap) substrate:sandbox Tool substrate: agent isolation, egress control, settings audit labels Oct 24, 2026
@potiuk
potiuk merged commit d467f84 into apache:main Oct 24, 2026
15 checks passed
potiuk added a commit to potiuk/magpie that referenced this pull request Oct 24, 2026
… hop checks

uv's managed CPython under ~/.local/share/uv/python is now a candidate,
after the system locations and newest first. The reference setup keeps
~/.local/share/uv read-only to the sandbox (apache#1785) and
sandbox-lint rejects a grant over it, so that one directory is exempt from
the blanket ~/.local refusal; any settings grant over it still refuses it.

A link-target component that is itself a symlink is now followed hop by
hop through the writable-root check, so a name inside the prefix cannot
route through ~/.cache on its way to a system location.

Generated-by: Claude (Opus 5.5)
potiuk added a commit to potiuk/magpie that referenced this pull request Oct 24, 2026
… hop checks

uv's managed CPython under ~/.local/share/uv/python is now a candidate,
after the system locations and newest first. The reference setup keeps
~/.local/share/uv read-only to the sandbox (apache#1785) and
sandbox-lint rejects a grant over it, so that one directory is exempt from
the blanket ~/.local refusal; any settings grant over it still refuses it.

A link-target component that is itself a symlink is now followed hop by
hop through the writable-root check, so a name inside the prefix cannot
route through ~/.cache on its way to a system location.

Generated-by: Claude (Opus 5.5)
potiuk added a commit to potiuk/magpie that referenced this pull request Oct 24, 2026
The ~/.local exemption for uv's managed interpreters matched the resolved
form of ~/.local/share/uv/python too, so a symlink there (or at a parent)
could widen it to wherever it pointed. It now holds only when that path is
a real directory reached without a symlink, and matches it literally.

Also regenerates the secure-setup fingerprint after the rebase onto apache#1785.

Generated-by: Claude (Opus 5.5)
potiuk added a commit that referenced this pull request Oct 24, 2026
…the sandbox (#1784)

* fix(container-gateway-hook): never run sandbox-writable code outside the sandbox

The SessionStart/SessionEnd hook runs outside the sandbox but took python3
from PATH, which is often a uv-managed interpreter under the
sandbox-writable ~/.local/share/uv, and it accepted gateway sources inside
the project root (the .apache-magpie/ snapshot, or an override pointing into
the checkout). Any sandboxed command, which runs without a prompt, could
plant code there that ran unsandboxed at the next session start.

The hook now refuses any interpreter or source that resolves, through every
symlink hop, under the project root, ~/.cache, ~/.local, /tmp/claude*, or a
sandbox.filesystem.allowWrite / permissions.additionalDirectories entry. The
interpreter comes from an operator pin or fixed Homebrew/system paths, never
PATH, and runs with -I plus a one-line bootstrap instead of PYTHONPATH and -P.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway-hook): pin PATH; resolve grants per settings file

The hook ran jq, git, readlink and dirname from the inherited PATH, which
project settings can set; it now uses fixed system locations. Relative
allowWrite / additionalDirectories entries resolve against their own
settings file root (~/.claude for user scope), and a single-slash entry is
counted both absolute and relative, so an ambiguous grant only refuses more.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway-hook): judge the runtime and every source file

Review follow-up. The interpreter now runs by its canonical path, so no
symlink on the way can be re-pointed between check and exec, and with -I -S,
so no site-packages, .pth file or sitecustomize loads. Its prefix and
standard library are refused when they overlap a writable root in either
direction, so a grant on one stdlib file counts. The source tree is judged
file by file and refused if it holds a symlink, since the whole directory
goes on sys.path.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway-hook): trust nothing before it is judged

Second review follow-up. The shebang names /bin/bash and helpers come only
from the root-owned system directories, failing closed when settings exist
but jq does not. The source override must be absolute and is resolved once,
so the judged path is the path that runs. An interpreter is judged from its
layout before the first exec: a virtualenv, or anything without its standard
library in <prefix>/lib/python3.* (a launcher stub), is never run. Keeps to
bash 3.2.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway): stop recognises the hook launch; judge stdlib links

Third review follow-up. `stop` matched only `python -m container_gateway`,
so it refused every gateway the hook now launches with `-I -S -c <bootstrap>`,
leaving the old policy running. It now also matches that exact bootstrap
(HOOK_BOOTSTRAP, kept identical to the hook by a test), and accepts the
`.../Python.app/Contents/MacOS/Python` argv a macOS framework build re-execs
as, which the `-m` form never matched either. Verified live: a gateway
started by the hook is stopped by it.

The hook also refuses an interpreter whose prefix holds a symlink into a
writable directory, checked before the first exec.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway-hook): main-checkout grants; walk linked stdlib dirs

Fourth review follow-up. In a linked worktree Claude Code also applies the
main checkout's settings, so their write grants (and the main checkout
itself) now count as writable roots. The interpreter-prefix symlink scan
follows linked directories, so a module behind a linked stdlib directory is
judged too; a maze of links refuses the interpreter.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway-hook): count Edit allow rules; confirm the main checkout

Fifth review follow-up. Edit/Write allow rules merge into the sandbox's
write access, so their paths now count as writable roots (a bare `Edit`
refuses everything). A linked worktree's .git file is itself writable, so
the main checkout it names counts only when its worktrees/<name>/gitdir
points back; otherwise the hook does not start the gateway.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway-hook): no escaping links in the stdlib; fold every base

Sixth review follow-up, replacing the linked-directory walk with a stricter
rule: <prefix>/lib/python3.X must be a real directory, and every symlink in
it (site-packages aside, which -S never loads) must stay inside the prefix
both lexically and once resolved, so no hop can lead out to something
re-pointable. Relative grant entries count against the settings directory,
the project root and the main checkout alike; an entry with `..` refuses
everything. The worktree back-pointer is read from <common>/worktrees/<name>,
and the git dir must be exactly that.

The runtime tests now use a marker-script interpreter, so each one shows
whether the hook trusted the layout far enough to execute it.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway-hook): step-wise link containment; refuse system grants

Seventh review follow-up. Folding `..` before the containment test hid a
writable hop in the middle of a link target; every symlink in the prefix
is now followed one component at a time and must stay inside the prefix
(or on its ancestors) at each step. A write grant over a system or
package-manager location (/usr, /opt, /Library, ...) refuses the gateway
outright, since native libraries such as Homebrew's OpenSSL live there,
beyond what the prefix checks can see.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway-hook): accept Linux system Python; explicit test concat

CI ran the hook with /usr/bin/python3.12, whose prefix is /usr. Scanning
every symlink under /usr refused it, since Debian links legitimately leave
the prefix (/etc/alternatives, sitecustomize.py into /etc), so every
positive test saw an empty command.

A link step may now also pass through a system location (/usr, /etc, /opt,
...): the hook already refuses to run when any write grant overlaps one, so
nothing there is writable or re-pointable from the sandbox. The scan covers
what -I -S loads, the standard library and the libpython beside it, rather
than the whole prefix. Verified on Ubuntu 24.04 and macOS.

Also makes a string concatenation in a test parameter list explicit
(CodeQL: implicit concatenation in a list).

Generated-by: Claude (Opus 5.5)

* feat(container-gateway-hook): trust uv-managed interpreters; physical hop checks

uv's managed CPython under ~/.local/share/uv/python is now a candidate,
after the system locations and newest first. The reference setup keeps
~/.local/share/uv read-only to the sandbox (#1785) and
sandbox-lint rejects a grant over it, so that one directory is exempt from
the blanket ~/.local refusal; any settings grant over it still refuses it.

A link-target component that is itself a symlink is now followed hop by
hop through the writable-root check, so a name inside the prefix cannot
route through ~/.cache on its way to a system location.

Generated-by: Claude (Opus 5.5)

* fix(container-gateway-hook): exempt only a real, unlinked uv python dir

The ~/.local exemption for uv's managed interpreters matched the resolved
form of ~/.local/share/uv/python too, so a symlink there (or at a parent)
could widen it to wherever it pointed. It now holds only when that path is
a real directory reached without a symlink, and matches it literally.

Also regenerates the secure-setup fingerprint after the rebase onto #1785.

Generated-by: Claude (Opus 5.5)
potiuk added a commit that referenced this pull request Oct 24, 2026
… start (#1788)

* fix(setup): drop the stale ~/.local/share/uv sandbox grant at session start

sandbox-add-project-root.sh removes the old ~/.local/share/uv write grant
(#1785) only when it runs, so a project nobody checks out or upgrades keeps
it, and sandboxed code there can still change the interpreters and tools uv
runs outside the sandbox. magpie-setup's session-start hook now removes
the exact entries the helper once added from the checkout's gitignored
.claude/settings.local.json (and the main checkout's, in a linked
worktree), and tells the user in a systemMessage. A broader grant, or one in
user-scope, committed or tracked settings, is reported for the user to
remove.

Generated-by: Claude (Opus 5.5)

* fix(setup): session-start cleanup runs no git and follows no symlink

Review follow-up; the hook runs outside the sandbox over a sandbox-writable
tree. It wrote through a fixed settings.local.json.tmp name a sandboxed
command could plant as a symlink; it now writes a fresh mkstemp file beside
the target and refuses when .claude or the file is a symlink. It ran
`git check-ignore`, which the repository's own sandbox-writable config can
turn into command execution (core.fsmonitor); no git runs now, and a tracked
settings.local.json is cleaned too, since dropping the grant only narrows the
sandbox. It followed a linked worktree's .git pointer to the main checkout,
letting sandboxed code choose the directory it rewrites; it now cleans only
the session's own checkout.

Generated-by: Claude (Opus 5.5)

* fix(setup): rewrite settings.local.json through one directory descriptor

Review follow-up. Checking for symlinks and then writing by path left a
window in which a sandboxed process still running from an earlier session
could swap .claude for a symlink. The cleanup now opens .claude once with
O_NOFOLLOW and does the read (O_NOFOLLOW), the fresh temporary file
(O_EXCL|O_NOFOLLOW, random name) and the rename relative to that
descriptor, so nothing that happens to the path meanwhile redirects it.

Generated-by: Claude (Opus 5.5)

* fix(setup): address CodeQL findings in the session-start cleanup

Builds the session-start message and context as lists instead of
conditional generator parts (CodeQL: potentially uninitialized local), and
replaces the silent except around the temporary-file cleanup with a
commented contextlib.suppress (CodeQL: empty except).

Generated-by: Claude (Opus 5.5)
potiuk added a commit that referenced this pull request Oct 24, 2026
…1791)

* fix(setup): session-start hook runs a trusted, isolated interpreter

The SessionStart hook runs outside the sandbox but took python3 from PATH,
which project settings can set and which is often a uv-managed interpreter.
It now uses only fixed system and Homebrew locations, then uv's managed
CPython (read-only to the sandbox since #1785), first one 3.11 or newer,
and runs it with -I and a one-line bootstrap instead of PYTHONPATH and -P,
so no PYTHON* variable, user site-packages or the opened project reaches
sys.path.

Generated-by: Claude (Opus 5.5)

* fix(setup): session-start hook trusts system Python only, with -I -S

Review follow-up. Trusting uv-managed interpreters needs the gateway hook's
full writable-root checks, and this hook only prints a notice, so it now
takes only fixed system and Homebrew locations (which no sandbox grant may
cover) and otherwise prints its own notice alone. It runs with -I -S:
setup_preflight needs only the standard library.

Generated-by: Claude (Opus 5.5)
potiuk added a commit that referenced this pull request Oct 25, 2026
Ships the fixes merged since 0.9.0.dev202610241347 to marketplace installs:
the container-gateway hook hardening (#1784, #1789, #1790, #1793, #1804),
host-only uv state (#1785), session-start cleanup of the stale uv grant
(#1788), the trusted session-start interpreter (#1791), the sandboxed prek
home (#1792) and per-machine gateway hook registration (#1797).

Generated-by: Claude (Opus 5.5)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

capability:platform Framework / agent substrate skills (install, verify, doctor, override, status, setup bootstrap) family:docs Docs, MISSION.md, READMEs family:setup setup-* skills family:tools tools/* substrate:sandbox Tool substrate: agent isolation, egress control, settings audit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant