You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/10/24 23:42:30 UTC
(magpie) branch main updated: fix(setup): session-start hook runs a fixed bash and only a root-owned Python (#1794)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git
The following commit(s) were added to refs/heads/main by this push:
new 95d27bd3d fix(setup): session-start hook runs a fixed bash and only a root-owned Python (#1794)
95d27bd3d is described below
commit 95d27bd3d22e2a96a97ad116a2fe8135593529d5
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sun Oct 25 01:42:25 2026 +0200
fix(setup): session-start hook runs a fixed bash and only a root-owned Python (#1794)
The magpie-setup SessionStart hook runs outside the sandbox. Its bash
came from PATH (the plugin wired `bash <path>`, the shebang was
`#!/usr/bin/env bash`), which project settings can set, and so did
BASH_ENV and exported functions. It now runs as /bin/bash -p with a
fixed PATH.
It ran the first python3 in fixed locations, including macOS's
/usr/bin/python3 launcher stub and Homebrew, which the sandbox user
owns. It now runs an interpreter only when every path component and
symlink hop, its exact-version standard library and the libraries
beside it are root-owned and not group/world-writable, never a
framework build, by its real path under env -i.
Without a trusted Python the pre-flight's ~/.local/share/uv grant
cleanup cannot run; the hook then reports a remaining grant (or that it
could not check) instead of staying silent.
Generated-by: Claude Opus 5
---
plugins/magpie-setup/hooks/check-upgrade.sh | 287 +++++++++++++++++++++--
plugins/magpie-setup/hooks/hooks.json | 2 +-
tools/setup-preflight/tests/test_session_hook.py | 254 ++++++++++++++++++++
3 files changed, 526 insertions(+), 17 deletions(-)
diff --git a/plugins/magpie-setup/hooks/check-upgrade.sh b/plugins/magpie-setup/hooks/check-upgrade.sh
index b2f4cbb85..aadec96c0 100755
--- a/plugins/magpie-setup/hooks/check-upgrade.sh
+++ b/plugins/magpie-setup/hooks/check-upgrade.sh
@@ -1,4 +1,4 @@
-#!/usr/bin/env bash
+#!/bin/bash -p
# SPDX-License-Identifier: Apache-2.0
# https://www.apache.org/licenses/LICENSE-2.0
#
@@ -26,10 +26,19 @@
#
# Agents without a session hook (e.g. Gemini CLI) surface the same prompt via
# their extension context file (GEMINI.md) instead.
+#
+# The session environment comes from settings a project can set, and this hook
+# runs outside the sandbox, so none of it may choose what runs: bash is the
+# fixed /bin/bash in privileged mode (-p: no BASH_ENV, no imported functions,
+# no SHELLOPTS) — the plugin wires the command as `/bin/bash -p <path>` and the
+# shebang says the same for a direct exec — and the helpers come from the fixed
+# system PATH below.
set -euo pipefail
+PATH=/usr/bin:/bin:/usr/sbin:/sbin
+export PATH
-# Drain any event JSON delivered on stdin (unused).
-cat >/dev/null 2>&1 || true
+# The event JSON on stdin: its `cwd` is the project as Claude Code reports it.
+payload="$(cat 2>/dev/null || true)"
# Claude Code exports `CLAUDE_PLUGIN_ROOT` / `CLAUDE_PLUGIN_DATA` to hook
# processes; Codex exports `PLUGIN_ROOT` / `PLUGIN_DATA` (the Agent Plugins 1.0
@@ -79,26 +88,272 @@ fi
#
# The hook runs outside the sandbox, so the interpreter is never `python3`
# from PATH, which project settings can set: only fixed system and Homebrew
-# locations, which no sandbox grant may cover (the container-gateway hook
-# refuses to run at all when one does). uv-managed interpreters are left out:
-# trusting them needs the gateway hook's full writable-root checks, and this
-# hook only prints a notice, so without a system Python it prints its own.
-# It runs with `-I -S`, so no PYTHON* variable, site-packages or working
-# directory (the project being opened, which could carry its own
-# `setup_preflight/`) reaches `sys.path`; a one-line bootstrap puts the
-# plugin's own source there. `setup_preflight` needs only the stdlib.
+# locations. And it runs only an interpreter the sandbox cannot
+# rewrite by file permissions, whatever any settings grant: the sandbox runs as
+# this user, so a file owned by root and writable by neither group nor others
+# is out of its reach. A candidate counts only when
+# - every component of its path, and of each symlink hop on the way to its
+# real binary, is such a root-owned entry;
+# - it is a real installation, judged from its layout before it ever runs:
+# the real binary's prefix holds the standard library of its own version
+# (lib/ or lib64/python3.X for python3.X; every lib*/python3.* when the
+# name carries no version) and no pyvenv.cfg. That rules out launcher stubs such as
+# macOS's /usr/bin/python3, which execs whatever DEVELOPER_DIR names, and
+# virtualenvs, whose base runtime could be anywhere;
+# - that standard library, the shared libraries beside it in <prefix>/lib,
+# and every symlink hop out of them are root-owned the same way
+# (site-packages / dist-packages are exempt: -S never loads them).
+# Homebrew is owned by the user who installed it, and a macOS framework build
+# (python.org) re-execs a bundle this hook does not vet, so neither qualifies.
+# On a machine with no root-owned Python 3.11+ the hook prints its own notice
+# and says so when that leaves the ~/.local/share/uv cleanup below undone; the
+# isolated-setup warning, which only the pre-flight can decide, is not shown.
+# It runs by its real path under `env -i`, so no inherited variable
+# (DEVELOPER_DIR, LD_PRELOAD, DYLD_*, PYTHON*) reaches it, and with `-I -S`, so
+# no site-packages or working directory (the project being opened, which could
+# carry its own `setup_preflight/`) reaches `sys.path`; a one-line bootstrap
+# puts the plugin's own source there. `setup_preflight` needs only the stdlib.
+
+# True when <path> itself (not what it links to) is owned by root and, unless
+# it is a symlink, writable by neither group nor others. A symlink's own mode
+# is meaningless (0777 on Linux): only its directory, checked as the component
+# before it, decides whether it can be re-pointed.
+root_owned() {
+ if [ -L "$1" ]; then
+ [ -n "$(find "$1" -maxdepth 0 -user 0 -print 2>/dev/null)" ]
+ else
+ [ -n "$(find "$1" -maxdepth 0 -user 0 ! -perm -020 ! -perm -002 -print 2>/dev/null)" ]
+ fi
+}
+
+# True when every component of <path>, and of every symlink hop met while
+# resolving it, is root-owned: nothing on the way can be re-pointed.
+trusted_path() {
+ local rest="$1" cur="" part hops=0 link
+ case "$rest" in /*) ;; *) return 1 ;; esac
+ root_owned / || return 1
+ rest="${rest#/}"
+ while [ -n "$rest" ]; do
+ part="${rest%%/*}"
+ if [ "$part" = "$rest" ]; then rest=""; else rest="${rest#*/}"; fi
+ case "$part" in
+ ""|.) continue ;;
+ ..) cur="${cur%/*}"; continue ;;
+ esac
+ root_owned "$cur/$part" || return 1
+ if [ -L "$cur/$part" ]; then
+ hops=$((hops + 1))
+ [ "$hops" -le 40 ] || return 1
+ link="$(readlink -- "$cur/$part")" || return 1
+ case "$link" in /*) cur="" ;; esac
+ rest="${link#/}${rest:+/$rest}"
+ else
+ cur="$cur/$part"
+ fi
+ done
+ return 0
+}
+
+# True when everything under <dir> (site-/dist-packages aside) is root-owned,
+# and every symlink in it leads only through root-owned entries.
+trusted_tree() {
+ local dir link
+ # From the real directory: find does not descend into a symlinked start.
+ dir="$(readlink -f -- "$1" 2>/dev/null)" || return 1
+ [ -n "$(find "$dir" \( -name site-packages -o -name dist-packages \) -prune -o \
+ \( ! -user 0 -o \( ! -type l \( -perm -020 -o -perm -002 \) \) \) -print 2>/dev/null | head -n1)" ] && return 1
+ # A symlink to a directory would bring in contents this scan never saw.
+ while IFS= read -r -d '' link; do
+ [ -d "$link" ] && return 1
+ trusted_path "$link" || return 1
+ done < <(find "$dir" \( -name site-packages -o -name dist-packages \) -prune -o -type l -print0 2>/dev/null)
+ return 0
+}
+
python=""
-for candidate in /opt/homebrew/bin/python3 /usr/local/bin/python3 /usr/bin/python3; do
+for candidate in /usr/bin/python3 /usr/local/bin/python3 /opt/homebrew/bin/python3; do
[ -x "$candidate" ] || continue
- "$candidate" -I -S -c 'import sys; sys.exit(sys.version_info < (3, 11))' >/dev/null 2>&1 || continue
- python="$candidate"
+ trusted_path "$candidate" || continue
+ canonical="$(readlink -f -- "$candidate" 2>/dev/null)" || continue
+ [ -n "$canonical" ] && [ -x "$canonical" ] || continue
+ # A macOS framework build re-execs Resources/Python.app and loads the
+ # framework's own library, outside what is checked below: not trusted.
+ case "$canonical" in */*.framework/*) continue ;; esac
+ prefix="$(dirname -- "$(dirname -- "$canonical")")"
+ [ -e "$prefix/pyvenv.cfg" ] || [ -e "$(dirname -- "$canonical")/pyvenv.cfg" ] && continue
+ name="${canonical##*/}"
+ # lib64 too: Fedora / RHEL keep the standard library in <prefix>/lib64.
+ case "$name" in
+ python3.[0-9]*) stdlibs=("$prefix/lib/$name" "$prefix/lib64/$name") ;;
+ *) stdlibs=("$prefix"/lib/python3.* "$prefix"/lib64/python3.*) ;;
+ esac
+ ok=1 found=0
+ for stdlib in "${stdlibs[@]}"; do
+ [ -f "$stdlib/os.py" ] && found=1
+ [ -d "$stdlib" ] || continue
+ trusted_path "$stdlib" && trusted_tree "$stdlib" || { ok=0; break; }
+ done
+ [ "$ok" = 1 ] && [ "$found" = 1 ] || continue
+ # The shared libraries beside the standard library (libpython and friends).
+ while IFS= read -r -d '' lib; do
+ trusted_path "$lib" || { ok=0; break; }
+ done < <(find "$prefix/lib" "$prefix/lib64" -maxdepth 1 ! -type d -print0 2>/dev/null)
+ [ "$ok" = 1 ] || continue
+ env -i PATH=/usr/bin:/bin "$canonical" -I -S -c 'import sys; sys.exit(sys.version_info < (3, 11))' >/dev/null 2>&1 || continue
+ python="$canonical"
break
done
+
+# Where the session's settings live, for the ~/.local/share/uv check below:
+# CLAUDE_PROJECT_DIR and $HOME come from the environment, so the payload's cwd,
+# the working directory, the checkout each sits in (and, for a linked
+# worktree, its main checkout, whose settings Claude Code also reads) and the
+# account database's home (`~user`) count as well.
+project="${CLAUDE_PROJECT_DIR:-$PWD}"
+projects=("$project" "$PWD")
+if command -v jq >/dev/null 2>&1; then
+ payload_cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)"
+ [ -n "$payload_cwd" ] && projects+=("$payload_cwd")
+fi
+# Absolute, physical paths only (a relative CLAUDE_PROJECT_DIR would never
+# reach / below); one that does not resolve is dropped.
+resolved=()
+for p in ${projects[@]+"${projects[@]}"}; do
+ p="$(cd "$p" 2>/dev/null && pwd -P)" && resolved+=("$p")
+done
+projects=(${resolved[@]+"${resolved[@]}"})
+for p in ${projects[@]+"${projects[@]}"}; do
+ d="$p"
+ while [ -n "$d" ] && [ "$d" != / ]; do
+ if [ -e "$d/.git" ]; then projects+=("$d"); break; fi
+ next="$(dirname -- "$d")"
+ [ "$next" = "$d" ] && break
+ d="$next"
+ done
+done
+for p in ${projects[@]+"${projects[@]}"}; do
+ [ -f "$p/.git" ] || continue
+ gitdir="$(sed -n 's/^gitdir:[[:space:]]*//p' "$p/.git" 2>/dev/null | head -n1)"
+ [ -n "$gitdir" ] || continue
+ case "$gitdir" in /*) ;; *) gitdir="$p/$gitdir" ;; esac
+ common="$gitdir"
+ if [ -r "$gitdir/commondir" ]; then
+ c="$(head -n1 "$gitdir/commondir")"
+ case "$c" in /*) common="$c" ;; *) common="$gitdir/$c" ;; esac
+ fi
+ main="$(cd "$common/.." 2>/dev/null && pwd -P)" || continue
+ projects+=("$main")
+done
+homes=("$HOME")
+user="$(id -un 2>/dev/null || true)"
+if [[ $user =~ ^[A-Za-z0-9._][A-Za-z0-9._-]*$ ]]; then
+ eval "account_home=~$user"
+ [[ $account_home == /* && $account_home != "$HOME" ]] && homes+=("$account_home")
+fi
+
+# Sandbox write grants as `<settings file><TAB><absolute directory>` lines; "/"
+# for a grant that cannot be pinned down (a bare Edit rule, a `..`, a file jq
+# cannot read). Used only to report, never to decide what runs.
+write_grants() {
+ local files=() bases=() i settings base entry h p form slash=/ dslash=// dotdir=/./
+ for h in "${homes[@]}"; do
+ files+=("$h/.claude/settings.json" "$h/.claude/settings.local.json"); bases+=("$h/.claude" "$h/.claude")
+ done
+ for p in ${projects[@]+"${projects[@]}"}; do
+ files+=("$p/.claude/settings.json" "$p/.claude/settings.local.json"); bases+=("$p" "$p")
+ done
+ files+=("/Library/Application Support/ClaudeCode/managed-settings.json" "/etc/claude-code/managed-settings.json")
+ bases+=("/Library/Application Support/ClaudeCode" "/etc/claude-code")
+ for (( i = 0; i < ${#files[@]}; i++ )); do
+ settings="${files[i]}" base="${bases[i]}"
+ [ -r "$settings" ] || continue
+ command -v jq >/dev/null 2>&1 || { printf '%s\t/\t//\n' "$settings"; continue; }
+ jq -r --arg file "$settings" --arg base "$base" '((.sandbox.filesystem.allowWrite // [])[], (.permissions.additionalDirectories // [])[],
+ ((.permissions.allow // [])[] | strings
+ | if test("^(Edit|Write|MultiEdit|NotebookEdit)$") then "//"
+ else (capture("^(Edit|Write|MultiEdit|NotebookEdit)\\((?<p>.*)\\)$").p // empty) end))
+ | strings | (if test("[\t\n]") then "//" else . end) | "\($file)\t\($base)\t\(.)"' "$settings" 2>/dev/null \
+ || printf '%s\t/\t//\n' "$settings"
+ done | while IFS=$'\t' read -r settings base entry; do
+ [ -n "$entry" ] || continue
+ # A relative or single-slash entry counts against the settings file's own
+ # directory and every place the project could be.
+ forms=()
+ case "$entry" in
+ */../*|*/..|../*|..) forms=("/") ;;
+ //*) forms=("/${entry#//}") ;;
+ "~") forms=("${homes[@]}") ;;
+ "~/"*) for h in "${homes[@]}"; do forms+=("$h/${entry#\~/}"); done ;;
+ /*) forms=("$entry" "$base$entry"); for p in ${projects[@]+"${projects[@]}"}; do forms+=("$p$entry"); done ;;
+ *) forms=("$base/${entry#./}"); for p in ${projects[@]+"${projects[@]}"}; do forms+=("$p/${entry#./}"); done ;;
+ esac
+ for form in "${forms[@]}"; do
+ case "$form" in *[\*\?\[]*) form="${form%%[\*\?\[]*}"; form="${form%/*}" ;; esac
+ # `a//b`, `a/./b` and a trailing `/.` name the same directory as `a/b`.
+ # (Patterns in variables: bash 3.2 keeps a backslash in the replacement.)
+ while [[ $form == *//* ]]; do form="${form//$dslash/$slash}"; done
+ while [[ $form == */./* ]]; do form="${form//$dotdir/$slash}"; done
+ form="${form%/.}"
+ form="${form%/}"
+ printf '%s\t%s\n' "$settings" "${form:-/}"
+ done
+ done
+}
+
output="$notice"
+preflight_ran=0
if [ -n "$python" ]; then
bootstrap='import runpy, sys; sys.path.insert(0, sys.argv.pop(1)); runpy.run_module("setup_preflight.isolated", run_name="__main__", alter_sys=True)'
- output="$("$python" -I -S -c "$bootstrap" "$root/skills/setup" session-start \
- --project-root "${CLAUDE_PROJECT_DIR:-$PWD}" --notice "$notice" 2>/dev/null)" || output="$notice"
+ if ran="$(env -i PATH=/usr/bin:/bin HOME="$HOME" LANG=C.UTF-8 "$python" -I -S -c "$bootstrap" "$root/skills/setup" session-start \
+ --project-root "$project" --notice "$notice" 2>/dev/null)"; then
+ output="$ran"
+ preflight_ran=1
+ fi
+fi
+
+# The pre-flight also drops the old ~/.local/share/uv sandbox write grant
+# (apache/magpie#1785). With no trusted Python that cleanup cannot run, and
+# the grant lets a sandboxed command replace interpreters that run outside the
+# sandbox, so say so rather than stay silent. A read-only text match over the
+# files the cleanup covers: it errs towards warning, and edits nothing.
+json_str() {
+ local s="$1"
+ s="${s//\\/\\\\}" s="${s//\"/\\\"}" s="${s//$'\n'/\\n}" s="${s//$'\t'/\\t}" s="${s//$'\r'/\\r}"
+ # Any other control character has no business in a path or this message.
+ s="$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037')"
+ printf '"%s"' "$s"
+}
+if [ "$preflight_ran" = 0 ]; then
+ # Which settings files grant writes over ~/.local/share/uv (it, an ancestor,
+ # or something inside it), from the normalised write grants alone; a file
+ # that could not be read leaves the answer unknown rather than guessed.
+ stale_files=() unknown_files=()
+ while IFS=$'\t' read -r file grant; do
+ [ -n "$grant" ] || continue
+ hit=0
+ if [ "$grant" = / ]; then
+ case " ${unknown_files[*]-} " in *" $file "*) ;; *) unknown_files+=("$file") ;; esac
+ continue
+ fi
+ for h in "${homes[@]}"; do
+ uv="$h/.local/share/uv"
+ case "$uv/" in "$grant"/*) hit=1 ;; esac
+ case "$grant/" in "$uv"/*) hit=1 ;; esac
+ done
+ [ "$hit" = 1 ] || continue
+ case " ${stale_files[*]-} " in *" $file "*) ;; *) stale_files+=("$file") ;; esac
+ done < <(write_grants)
+ stale=""
+ if [ "${#stale_files[@]}" -gt 0 ]; then
+ stale="Apache Magpie: ~/.local/share/uv holds the interpreters and tools uv runs outside the sandbox, so the sandbox must not write there (apache/magpie#1785). This session had no trusted Python to remove that grant, and these settings still make it: ${stale_files[*]}. Remove it by hand, or run /magpie-setup verify."
+ elif [ "${#unknown_files[@]}" -gt 0 ]; then
+ stale="Apache Magpie: this session had no trusted Python and could not read ${unknown_files[*]}, so it could not check for the ~/.local/share/uv sandbox write grant (apache/magpie#1785). Run /magpie-setup verify."
+ fi
+ if [ -n "$stale" ]; then
+ context="$stale"
+ [ -n "$notice" ] && context="$notice"$'\n\n'"$stale"
+ output="{\"systemMessage\": $(json_str "$stale"), \"hookSpecificOutput\": {\"hookEventName\": \"SessionStart\", \"additionalContext\": $(json_str "$context")}}"
+ fi
fi
# stdout, not stderr: for a `SessionStart` hook exiting 0, Claude Code adds
diff --git a/plugins/magpie-setup/hooks/hooks.json b/plugins/magpie-setup/hooks/hooks.json
index 7d434f76c..a713ec974 100644
--- a/plugins/magpie-setup/hooks/hooks.json
+++ b/plugins/magpie-setup/hooks/hooks.json
@@ -8,7 +8,7 @@
"hooks": [
{
"type": "command",
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh\"",
+ "command": "/bin/bash -p \"${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh\"",
"timeout": 10
}
]
diff --git a/tools/setup-preflight/tests/test_session_hook.py b/tools/setup-preflight/tests/test_session_hook.py
index bf07a058f..f58b68689 100644
--- a/tools/setup-preflight/tests/test_session_hook.py
+++ b/tools/setup-preflight/tests/test_session_hook.py
@@ -23,6 +23,8 @@ import os
import subprocess
from pathlib import Path
+import pytest
+
PLUGIN_ROOT = Path(__file__).resolve().parents[3] / "plugins" / "magpie-setup"
HOOK = PLUGIN_ROOT / "hooks" / "check-upgrade.sh"
@@ -105,3 +107,255 @@ def test_pythonpath_does_not_reach_the_preflight(project: Path, tmp_path: Path)
)
assert done.returncode == 0, done.stderr
assert not marker.exists()
+
+
+HOOKS_JSON = PLUGIN_ROOT / "hooks" / "hooks.json"
+
+
+def _wired_command() -> str:
+ import json
+
+ data = json.loads(HOOKS_JSON.read_text(encoding="utf-8"))
+ return data["hooks"]["SessionStart"][0]["hooks"][0]["command"]
+
+
+def test_the_hook_runs_a_fixed_bash_in_privileged_mode() -> None:
+ """`bash` and `#!/usr/bin/env bash` both resolve through PATH, which a project can set."""
+ assert _wired_command().startswith("/bin/bash -p ")
+ assert HOOK.read_text(encoding="utf-8").splitlines()[0] == "#!/bin/bash -p"
+
+
+def test_a_bash_on_path_is_never_run(project: Path, tmp_path: Path) -> None:
+ """The wired command, run by a shell as Claude Code runs it, ignores a bash on PATH."""
+ bin_dir = tmp_path / "bin"
+ bin_dir.mkdir()
+ marker = tmp_path / "path-bash-ran"
+ fake = bin_dir / "bash"
+ fake.write_text(f"#!/bin/sh\ntouch {marker}\nexit 0\n")
+ fake.chmod(0o755)
+ env = {
+ **os.environ,
+ "PATH": f"{bin_dir}:{os.environ['PATH']}",
+ "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT),
+ "CLAUDE_PLUGIN_DATA": str(tmp_path / "data"),
+ "CLAUDE_PROJECT_DIR": str(project),
+ }
+ done = subprocess.run(
+ ["/bin/sh", "-c", _wired_command()],
+ input="{}",
+ capture_output=True,
+ text=True,
+ env=env,
+ cwd=project,
+ check=False,
+ )
+ assert done.returncode == 0, done.stderr
+ assert not marker.exists()
+ assert "Apache Magpie plugin" in done.stdout
+
+
+def test_bash_env_never_runs(project: Path, tmp_path: Path) -> None:
+ """Executed directly, privileged mode keeps BASH_ENV and exported functions out."""
+ marker = tmp_path / "bash-env-ran"
+ rc = tmp_path / "rc.sh"
+ rc.write_text(f"touch {marker}\n")
+ env = {
+ **os.environ,
+ "BASH_ENV": str(rc),
+ "BASH_FUNC_sed%%": f"() {{ touch {marker}; }}",
+ "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT),
+ "CLAUDE_PLUGIN_DATA": str(tmp_path / "data"),
+ "CLAUDE_PROJECT_DIR": str(project),
+ }
+ done = subprocess.run(
+ [str(HOOK)], input="{}", capture_output=True, text=True, env=env, cwd=project, check=False
+ )
+ assert done.returncode == 0, done.stderr
+ assert not marker.exists()
+ assert "Apache Magpie plugin" in done.stdout
+
+
+def test_a_skipped_preflight_still_reports_the_stale_uv_grant(project: Path, tmp_path: Path) -> None:
+ """Without a trusted Python the ~/.local/share/uv cleanup cannot run; the hook
+ must say the grant is still there rather than drop the warning silently."""
+ import json
+
+ if _chosen_python(project, tmp_path):
+ pytest.skip("a trusted Python runs here, and the pre-flight removes the grant itself")
+ if not any(Path(d, "jq").exists() for d in ("/usr/bin", "/bin")):
+ pytest.skip("no system jq: the fallback can only say it could not check")
+ home = tmp_path / "home"
+ home.mkdir()
+ (project / ".claude").mkdir()
+ (project / ".claude" / "settings.local.json").write_text(
+ json.dumps({"sandbox": {"filesystem": {"allowWrite": ["~/.local/share/uv"]}}})
+ )
+ env = {
+ **os.environ,
+ "HOME": str(home),
+ "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT),
+ "CLAUDE_PLUGIN_DATA": str(tmp_path / "data"),
+ "CLAUDE_PROJECT_DIR": str(project),
+ }
+ done = subprocess.run(
+ [str(HOOK)],
+ input=json.dumps({"cwd": str(project)}),
+ capture_output=True,
+ text=True,
+ env=env,
+ cwd=project,
+ check=False,
+ )
+ assert done.returncode == 0, done.stderr
+ assert done.stdout.startswith("{"), f"no warning: {done.stdout!r}"
+ message = json.loads(done.stdout)["systemMessage"]
+ assert "~/.local/share/uv" in message
+ assert str(project / ".claude" / "settings.local.json") in message
+ # The file is reported, never edited without the pre-flight.
+ assert "~/.local/share/uv" in (project / ".claude" / "settings.local.json").read_text()
+
+
+def _skipped_preflight(project: Path, tmp_path: Path, extra: dict[str, object]) -> str:
+ """Run the hook where no trusted Python exists (skipped elsewhere), with <extra> settings."""
+ import json
+
+ home = tmp_path / "home"
+ home.mkdir(exist_ok=True)
+ (project / ".claude").mkdir(exist_ok=True)
+ if _chosen_python(project, tmp_path):
+ pytest.skip("a trusted Python runs here, and the pre-flight handles the grant itself")
+ if not any(Path(d, "jq").exists() for d in ("/usr/bin", "/bin")):
+ pytest.skip("no system jq: the fallback can only say it could not check")
+ filesystem: dict[str, list[object]] = {}
+ for key, value in extra.items():
+ filesystem.setdefault(key, []).extend(value if isinstance(value, list) else [value])
+ settings = {"sandbox": {"filesystem": filesystem}}
+ (project / ".claude" / "settings.local.json").write_text(json.dumps(settings))
+ env = {
+ **os.environ,
+ "HOME": str(home),
+ "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT),
+ "CLAUDE_PLUGIN_DATA": str(tmp_path / "data"),
+ "CLAUDE_PROJECT_DIR": str(project),
+ }
+ return subprocess.run(
+ [str(HOOK)],
+ input=json.dumps({"cwd": str(project)}),
+ capture_output=True,
+ text=True,
+ env=env,
+ cwd=project,
+ check=False,
+ ).stdout
+
+
+def test_a_read_grant_on_the_uv_dir_is_not_a_stale_write(project: Path, tmp_path: Path) -> None:
+ """allowRead of ~/.local/share/uv is what the reference settings carry: no warning."""
+ out = _skipped_preflight(project, tmp_path, {"allowRead": ["~/.local/share/uv/"]})
+ assert "~/.local/share/uv" not in out
+
+
+@pytest.mark.parametrize("grant", ["~", "~/.local", "~/.local/share/uv/python", "~/.local/./share//uv"])
+def test_a_covering_or_nested_write_grant_is_reported(project: Path, tmp_path: Path, grant: str) -> None:
+ import json
+
+ out = _skipped_preflight(project, tmp_path, {"allowWrite": [grant]})
+ message = json.loads(out)["systemMessage"]
+ assert "~/.local/share/uv" in message
+ assert str(project / ".claude" / "settings.local.json") in message
+
+
+def _chosen_python(project: Path, tmp_path: Path) -> str:
+ """The interpreter the hook picks, read from its own xtrace ("" for none)."""
+ env = {
+ **os.environ,
+ "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT),
+ "CLAUDE_PLUGIN_DATA": str(tmp_path / "trace-data"),
+ "CLAUDE_PROJECT_DIR": str(project),
+ }
+ done = subprocess.run(
+ ["/bin/bash", "-px", str(HOOK)],
+ input="{}",
+ capture_output=True,
+ text=True,
+ env=env,
+ cwd=project,
+ check=False,
+ )
+ chosen = [
+ line.split("python=", 1)[1].strip("'") for line in done.stderr.splitlines() if "+ python=" in line
+ ]
+ return chosen[-1] if chosen else ""
+
+
+def _root_owned_all_the_way(path: str) -> bool:
+ """Every component of the real path (and the path as given) root-owned, no group/world write."""
+ import stat
+
+ def ok(p: str) -> bool:
+ st = os.lstat(p)
+ return st.st_uid == 0 and not st.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
+
+ real = os.path.realpath(path)
+ parts = Path(real).parts
+ return all(ok(str(Path(*parts[: i + 1]))) for i in range(len(parts)))
+
+
+def test_only_a_root_owned_interpreter_is_ever_run(project: Path, tmp_path: Path) -> None:
+ """The sandbox runs as this user: a Python it can rewrite (Homebrew, a user-owned
+ install) must never run outside it, whatever the settings grant."""
+ chosen = _chosen_python(project, tmp_path)
+ if chosen:
+ assert _root_owned_all_the_way(chosen), chosen
+ assert not chosen.startswith("/opt/homebrew/")
+ homebrew = Path("/opt/homebrew/bin/python3")
+ if homebrew.exists() and not _root_owned_all_the_way(str(homebrew)):
+ assert not chosen.startswith(os.path.realpath(homebrew).rsplit("/bin/", 1)[0])
+
+
+def test_settings_the_fallback_cannot_read_are_reported_as_unchecked(project: Path, tmp_path: Path) -> None:
+ """A settings file that cannot be parsed leaves the grant unknown: say so, never guess."""
+ import json
+
+ if _chosen_python(project, tmp_path):
+ pytest.skip("a trusted Python runs here, and the pre-flight reads the settings itself")
+ home = tmp_path / "home"
+ home.mkdir(exist_ok=True)
+ (project / ".claude").mkdir(exist_ok=True)
+ (project / ".claude" / "settings.local.json").write_text("{ not json")
+ env = {
+ **os.environ,
+ "HOME": str(home),
+ "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT),
+ "CLAUDE_PLUGIN_DATA": str(tmp_path / "data"),
+ "CLAUDE_PROJECT_DIR": str(project),
+ }
+ out = subprocess.run(
+ [str(HOOK)],
+ input=json.dumps({"cwd": str(project)}),
+ capture_output=True,
+ text=True,
+ env=env,
+ cwd=project,
+ check=False,
+ ).stdout
+ message = json.loads(out)["systemMessage"]
+ assert "could not check" in message
+ assert str(project / ".claude" / "settings.local.json") in message
+
+
+def test_a_relative_project_dir_outside_git_does_not_hang(tmp_path: Path) -> None:
+ """CLAUDE_PROJECT_DIR comes from the environment and may be relative."""
+ plain = tmp_path / "plain"
+ plain.mkdir()
+ env = {
+ **os.environ,
+ "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT),
+ "CLAUDE_PLUGIN_DATA": str(tmp_path / "data"),
+ "CLAUDE_PROJECT_DIR": ".",
+ }
+ done = subprocess.run(
+ [str(HOOK)], input="{}", capture_output=True, text=True, env=env, cwd=plain, check=False, timeout=8
+ )
+ assert done.returncode == 0, done.stderr
+ assert "Apache Magpie plugin" in done.stdout