Repository navigation
fix(container-gateway-hook): never run sandbox-writable code outside the sandbox - #1784
Merged
potiuk merged 12 commits intoOct 24, 2026
Merged
Conversation
…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
force-pushed
the
fix/gateway-hook-trusted-interpreter
branch
from
October 24, 2026 20:00
519c912 to
8edadab
Compare
This was referenced Oct 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The SessionStart/SessionEnd hook runs outside the sandbox, but it took
python3fromPATH, 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:
~/.cache,~/.local, the sandbox temp roots, or anyallowWrite,additionalDirectoriesorEdit(...)allow grant. That includes the main checkout's settings in a linked worktree.PATH. It runs by its canonical path with-I -S.~/.local/share/uv/pythonis also a candidate, trusted only while no settings scope makes~/.local/share/uvwritable (see fix(sandbox): keep uv state the host executes out of sandbox reach #1785).stopnow recognises the hook's launch form. It also accepts the macOS framework re-exec argv (…/MacOS/Python), which the old-mmatch never did, so SessionEnd could not stop a gateway on Homebrew Python. Verified live.🤖 Generated with Claude Code