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