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 ` 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 /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 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 , 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 (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 /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 `` 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 // 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 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 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