Skip to content

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

Merged
potiuk merged 12 commits into
apache:mainfrom
potiuk:fix/gateway-hook-trusted-interpreter
Oct 24, 2026
Merged

potiuk merged 12 commits into
apache:mainfrom
potiuk:fix/gateway-hook-trusted-interpreter

Conversation

@potiuk

@potiuk potiuk commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

The SessionStart/SessionEnd hook runs outside the sandbox, but it took python3 from PATH, often a uv-managed interpreter under the sandbox-writable ~/.local/share/uv, and it accepted gateway sources inside the project root. A sandboxed command could plant code that then ran unsandboxed.

The hook now executes only an interpreter and a source tree that no sandboxed command can write:

  • It refuses anything that overlaps the project root, ~/.cache, ~/.local, the sandbox temp roots, or any allowWrite, additionalDirectories or Edit(...) allow grant. That includes the main checkout's settings in a linked worktree.
  • The interpreter comes from an operator pin or fixed Homebrew/system paths, never PATH. It runs by its canonical path with -I -S.
  • Virtualenvs, launcher stubs, and stdlib symlinks that leave the prefix are rejected before the first exec.
  • A write grant over a system location refuses the gateway outright.
  • uv's managed CPython under ~/.local/share/uv/python is also a candidate, trusted only while no settings scope makes ~/.local/share/uv writable (see fix(sandbox): keep uv state the host executes out of sandbox reach #1785).

stop now recognises the hook's launch form. It also accepts the macOS framework re-exec argv (…/MacOS/Python), which the old -m match never did, so SessionEnd could not stop a gateway on Homebrew Python. Verified live.

🤖 Generated with Claude Code

@github-actions github-actions Bot added family:setup setup-* skills family:tools tools/* family:docs Docs, MISSION.md, READMEs capability:reassess Re-run historical issues against current code 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
Comment thread tools/container-gateway/tests/test_daemon.py Fixed
potiuk added 12 commits October 24, 2026 21:49
…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)
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)
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)
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)
… 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)
… 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)
… 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)
…ery 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)
…m 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)
…t 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)
… 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)
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
potiuk force-pushed the fix/gateway-hook-trusted-interpreter branch from 519c912 to 8edadab Compare October 24, 2026 20:00
@potiuk
potiuk merged commit c29ca22 into apache:main Oct 24, 2026
14 checks passed
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) capability:reassess Re-run historical issues against current code 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.

2 participants