diff --git a/plugins/magpie-setup/hooks/check-upgrade.sh b/plugins/magpie-setup/hooks/check-upgrade.sh index adef0acab..b2f4cbb85 100755 --- a/plugins/magpie-setup/hooks/check-upgrade.sh +++ b/plugins/magpie-setup/hooks/check-upgrade.sh @@ -74,14 +74,30 @@ fi # Once per session, the isolated-setup warning: `setup_preflight` decides # whether it applies and prints the notice with it, as hook JSON whose -# `systemMessage` the user sees. Without python3 the notice goes out alone. -# `-P` keeps the working directory, which is the project being opened, off -# `sys.path`: `python3 -m` would otherwise put it ahead of `PYTHONPATH`, and -# a `setup_preflight/` package committed to that project would run in place -# of the plugin's own. +# `systemMessage` the user sees. Without a usable Python the notice goes out +# alone. +# +# 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. +python="" +for candidate in /opt/homebrew/bin/python3 /usr/local/bin/python3 /usr/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" + break +done output="$notice" -if command -v python3 >/dev/null 2>&1; then - output="$(PYTHONPATH="$root/skills/setup" python3 -P -m setup_preflight.isolated session-start \ +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" fi diff --git a/tools/setup-preflight/tests/test_session_hook.py b/tools/setup-preflight/tests/test_session_hook.py index edd0e2dee..bf07a058f 100644 --- a/tools/setup-preflight/tests/test_session_hook.py +++ b/tools/setup-preflight/tests/test_session_hook.py @@ -54,3 +54,54 @@ def test_a_package_in_the_project_does_not_shadow_the_plugin(project: Path, tmp_ assert not marker.exists() assert "PLANTED" not in done.stdout assert "Apache Magpie plugin" in done.stdout + + +def test_a_python3_on_path_is_never_run(project: Path, tmp_path: Path) -> None: + """The hook runs outside the sandbox; PATH comes from settings a project can set.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + marker = tmp_path / "path-python-ran" + fake = bin_dir / "python3" + fake.write_text(f"#!/bin/sh\ntouch {marker}\nexit 0\n") + fake.chmod(0o755) + env_path = f"{bin_dir}:{os.environ['PATH']}" + done = subprocess.run( + ["bash", str(HOOK)], + input="{}", + capture_output=True, + text=True, + env={ + **os.environ, + "PATH": env_path, + "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT), + "CLAUDE_PLUGIN_DATA": str(tmp_path / "data"), + "CLAUDE_PROJECT_DIR": str(project), + }, + cwd=project, + check=False, + ) + assert done.returncode == 0, done.stderr + assert not marker.exists() + assert "Apache Magpie plugin" in done.stdout + + +def test_pythonpath_does_not_reach_the_preflight(project: Path, tmp_path: Path) -> None: + """-I drops PYTHONPATH: a planted setup_preflight there never runs.""" + planted = tmp_path / "pp" / "setup_preflight" + planted.mkdir(parents=True) + marker = tmp_path / "pp-ran" + code = f"open({str(marker)!r}, 'w').close()\n" + (planted / "__init__.py").write_text(code, encoding="utf-8") + (planted / "isolated.py").write_text(code, encoding="utf-8") + env = { + **os.environ, + "PYTHONPATH": str(tmp_path / "pp"), + "CLAUDE_PLUGIN_ROOT": str(PLUGIN_ROOT), + "CLAUDE_PLUGIN_DATA": str(tmp_path / "data"), + "CLAUDE_PROJECT_DIR": str(project), + } + done = subprocess.run( + ["bash", str(HOOK)], input="{}", capture_output=True, text=True, env=env, cwd=project, check=False + ) + assert done.returncode == 0, done.stderr + assert not marker.exists()