mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
4f8acbcaf2
The PreToolUse write-scope guard was wired as a bare `python3 ".../ars_write_scope_guard.py"`. On Windows `python3` is commonly a 0-byte Microsoft Store alias stub (exit 49, no output), so the hook failed before the guard's own fail-safes ran and spammed the hook log every call. A new cross-platform launcher `hooks/run_guard.sh` (POSIX sh; `hooks.json` invokes it via bash) finds a real interpreter — `py -3` / `python3` / `python`, each verified by a marker probe that must exit 0 AND print the marker (a stub is skipped) — then runs the guard as a supervised, time-bounded subprocess. Plan A graceful degradation: if no real interpreter is found or the guard subprocess misbehaves, the launcher emits a valid pass-through hook JSON and exits 0, never blocking and staying silent on stderr on the degraded paths. Hardened across a two-model dual-track review (codex + gemini, both POSIX-reproduced): the no-`timeout` watchdog fallback's stdin/stdout/timeout handling, a pid-reuse race (done-file handshake), a predictable-/tmp symlink fail-open (mktemp failure degrades to pass-through), and a gameable CI exec assertion (line-anchored). 76 tests (21 launcher + 55 guard); README documents the Git Bash prerequisite on Windows. Verified against the reporter's confirmed environment (py -3 real, python3 an exit-49 stub): the stub is skipped, the guard runs via py -3, and the decision is forwarded. Closes #454. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
468 lines
27 KiB
Python
468 lines
27 KiB
Python
"""Launch-layer tests for hooks/run_guard.sh (#454 Windows Python hook portability).
|
||
|
||
The existing test_ars_write_scope_guard.py drives the guard via [sys.executable, guard]
|
||
— it never exercises INTERPRETER RESOLUTION, which is the entire #454 bug class (a Windows
|
||
`python3` that is a 0-byte Microsoft Store alias stub, not real Python). These tests cover
|
||
the launcher's job: find a REAL python (skipping stubs), run the guard as a SUPERVISED
|
||
subprocess, and degrade to pass-through+exit-0 (never block, never spam) when no real python
|
||
is found OR the guard subprocess is broken.
|
||
|
||
Design: docs/design/2026-06-17-454-windows-python-hook-portability-design.md (§3.1–3.6).
|
||
|
||
Mechanism: each test builds a temp `bin/` dir, puts fake `py`/`python3`/`python` programs
|
||
in it, and runs the launcher with PATH=that bin (plus a real python for the cases that need
|
||
one). A "stub" is a program that exits non-zero / prints nothing (mimicking the 0-byte Store
|
||
alias as the marker probe sees it). The launcher must:
|
||
- emit canonical pass-through JSON {"hookSpecificOutput":{"hookEventName":"PreToolUse"}}
|
||
and exit 0 when no real python verifies, or when the guard subprocess misbehaves;
|
||
- forward the guard's stdout JSON verbatim (deny via permissionDecision:"deny", or
|
||
pass-through) when a real python runs the guard cleanly;
|
||
- stay SILENT on stderr on the degraded paths (PreToolUse is a hot path — any per-call
|
||
stderr is the log spam #454 is about).
|
||
|
||
NOTE (honest limitation): this is a POSIX-host simulation. The real Windows Store-alias path,
|
||
`py.exe -3` under Git Bash, Git Bash path conversion, CRLF, and the no-Git-Bash PowerShell
|
||
fallback still need a Windows repro (tracked in the spec §6).
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import shutil
|
||
import stat
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
LAUNCHER = os.path.join(REPO_ROOT, "hooks", "run_guard.sh")
|
||
GUARD = os.path.join(REPO_ROOT, "scripts", "ars_write_scope_guard.py")
|
||
PASS_THROUGH = {"hookSpecificOutput": {"hookEventName": "PreToolUse"}}
|
||
|
||
# A real python to hand the launcher when a test needs the guard to actually run.
|
||
REAL_PY = sys.executable
|
||
|
||
|
||
def _write_exec(path, body):
|
||
"""Write an executable script at `path` with `body` (a /bin/sh script)."""
|
||
with open(path, "w") as fh:
|
||
fh.write(body)
|
||
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||
|
||
|
||
def _stub(path):
|
||
"""A non-executing stub: prints nothing, exits 1 — what the marker probe sees for a
|
||
0-byte Microsoft Store alias when invoked non-interactively."""
|
||
_write_exec(path, "#!/bin/sh\nexit 1\n")
|
||
|
||
|
||
def _fake_real_python(path, real=REAL_PY):
|
||
"""A fake `python3`/`python`/`py` that behaves like a real interpreter by delegating to
|
||
an actual python. Handles the marker probe (`-c ...`) and running the guard script."""
|
||
_write_exec(path, f'#!/bin/sh\nexec "{real}" "$@"\n')
|
||
|
||
|
||
# System dirs that hold sh/cat/grep/command/mktemp/sleep/kill/timeout. bin_dir goes FIRST
|
||
# on PATH so our injected fake py/python3/python (and any stub that shadows the system one)
|
||
# win interpreter resolution, while the launcher's own shell utilities still resolve.
|
||
_SYS_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"
|
||
_SH = shutil.which("sh") or "/bin/sh"
|
||
_LAUNCHER_TIMEOUT = 45 # generous so a (bounded) hanging-candidate test can't false-fail
|
||
|
||
|
||
def _run_launcher(bin_dir, payload, extra_env=None, launcher=LAUNCHER):
|
||
"""Run the launcher with PATH = bin_dir + system dirs (bin_dir first, so injected
|
||
interpreters shadow the real ones). Returns (exit_code, stdout, stderr).
|
||
|
||
`launcher` defaults to the repo's real launcher; pass an alternate path to run a copy from
|
||
a temp plugin layout (the only way to exercise a broken/alternate guard now that the
|
||
ARS_GUARD_PATH_FOR_TEST back door is gone — the guard is ALWAYS resolved from the
|
||
launcher's own ../scripts/, P2-e)."""
|
||
path = bin_dir + os.pathsep + _SYS_PATH
|
||
env = {
|
||
"PATH": path,
|
||
# Short probe bound keeps the suite fast; a hanging-candidate is killed in ~1s.
|
||
"ARS_PROBE_BOUND": "1",
|
||
# The launcher must resolve the guard from its OWN path, not any var (codex P1).
|
||
# We deliberately do NOT set CLAUDE_PLUGIN_ROOT in most tests.
|
||
}
|
||
if extra_env:
|
||
env.update(extra_env)
|
||
proc = subprocess.run(
|
||
[_SH, launcher],
|
||
input=json.dumps(payload),
|
||
env=env,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=_LAUNCHER_TIMEOUT,
|
||
)
|
||
return proc.returncode, proc.stdout, proc.stderr
|
||
|
||
|
||
def _make_plugin_layout(base, guard_body):
|
||
"""Build a temp plugin layout under `base`: hooks/run_guard.sh (real launcher copy) +
|
||
scripts/ars_write_scope_guard.py (a guard whose body is `guard_body`) + the manifest the
|
||
real guard needs. Returns the path to the copied launcher.
|
||
|
||
This is how a test runs an ALTERNATE/broken guard without a production back door: the
|
||
launcher resolves the guard from its OWN ../scripts/, so we plant the guard there (P2-e)."""
|
||
os.makedirs(os.path.join(base, "hooks"))
|
||
os.makedirs(os.path.join(base, "scripts"))
|
||
launcher_copy = os.path.join(base, "hooks", "run_guard.sh")
|
||
shutil.copy(LAUNCHER, launcher_copy)
|
||
with open(os.path.join(base, "scripts", "ars_write_scope_guard.py"), "w") as fh:
|
||
fh.write(guard_body)
|
||
# The real guard reads this manifest; copy it so an unmodified-guard layout also works.
|
||
manifest = os.path.join(os.path.dirname(GUARD), "ars_phase_scope_manifest.json")
|
||
shutil.copy(manifest, os.path.join(base, "scripts", "ars_phase_scope_manifest.json"))
|
||
return launcher_copy
|
||
|
||
|
||
def _bucket_a_payload(workspace, file_path):
|
||
"""A Bucket A subagent Write payload (research_architect_agent is Bucket A)."""
|
||
return {
|
||
"tool_name": "Write",
|
||
"cwd": workspace,
|
||
"agent_type": "research_architect_agent",
|
||
"tool_input": {"file_path": file_path, "content": "x"},
|
||
}
|
||
|
||
|
||
class LauncherNoPythonTest(unittest.TestCase):
|
||
"""No real python anywhere -> pass-through + exit 0 + silent (Plan A, spec §3.2)."""
|
||
|
||
def _assert_clean_passthrough(self, code, out, err):
|
||
self.assertEqual(code, 0, f"must exit 0, got {code}; stderr={err!r}")
|
||
self.assertEqual(json.loads(out), PASS_THROUGH, f"must emit canonical pass-through; got {out!r}")
|
||
self.assertEqual(err.strip(), "", f"must be SILENT on hot-path stderr; got {err!r}")
|
||
|
||
def test_only_silent_stubs_on_path(self):
|
||
# All three candidates are stubs that exit 1 and print nothing (the marker probe sees
|
||
# exactly what a 0-byte Store alias produces). bin_dir is FIRST on PATH so these
|
||
# shadow any real system python.
|
||
with tempfile.TemporaryDirectory() as bin_dir:
|
||
for name in ("py", "python3", "python"):
|
||
_stub(os.path.join(bin_dir, name))
|
||
code, out, err = _run_launcher(bin_dir, {"tool_name": "Bash", "tool_input": {"command": "ls"}})
|
||
self._assert_clean_passthrough(code, out, err)
|
||
|
||
def test_stubs_that_print_error_to_stderr(self):
|
||
# A different stub variant: exits 1 AND writes a Windows-style error to stderr (closer
|
||
# to "is not recognized" / Store-stub noise). The marker check is on STDOUT, so these
|
||
# are still rejected. Confirms the launcher keys on the stdout marker, not exit text.
|
||
with tempfile.TemporaryDirectory() as bin_dir:
|
||
for name in ("py", "python3", "python"):
|
||
_write_exec(os.path.join(bin_dir, name),
|
||
"#!/bin/sh\necho 'not a real python' 1>&2\nexit 9\n")
|
||
code, out, err = _run_launcher(bin_dir, {"tool_name": "Write", "tool_input": {"file_path": "/x", "content": "y"}})
|
||
self._assert_clean_passthrough(code, out, err)
|
||
|
||
def test_marker_printed_but_nonzero_exit_rejected(self):
|
||
"""P1-a: a candidate that PRINTS the exact marker on stdout but EXITS NON-ZERO must be
|
||
rejected (a half-broken interpreter, or a stub that echoes then fails). The probe keys
|
||
on `exit 0 AND marker`, not the marker alone -> no real python -> pass-through."""
|
||
with tempfile.TemporaryDirectory() as bin_dir:
|
||
for name in ("py", "python3", "python"):
|
||
# Print the marker to stdout (what a REAL probe would print) but exit 9.
|
||
# Must still be rejected because the exit status is non-zero.
|
||
_write_exec(os.path.join(bin_dir, name),
|
||
"#!/bin/sh\nprintf 'ARS_PY_OK'\nexit 9\n")
|
||
code, out, err = _run_launcher(bin_dir, {"tool_name": "Bash", "tool_input": {"command": "ls"}})
|
||
self._assert_clean_passthrough(code, out, err)
|
||
|
||
def test_no_timeout_binary_present(self):
|
||
"""Launcher must still work (and not hang) when the `timeout` binary is bypassed —
|
||
forces the process-group watchdog fallback via ARS_GUARD_FORCE_WATCHDOG."""
|
||
with tempfile.TemporaryDirectory() as bin_dir:
|
||
for name in ("py", "python3", "python"):
|
||
_stub(os.path.join(bin_dir, name))
|
||
code, out, err = _run_launcher(bin_dir, {"tool_name": "Bash", "tool_input": {"command": "ls"}},
|
||
extra_env={"ARS_GUARD_FORCE_WATCHDOG": "1"})
|
||
self._assert_clean_passthrough(code, out, err)
|
||
|
||
|
||
class LauncherRealPythonForwardsTest(unittest.TestCase):
|
||
"""A real python is found -> launcher runs the guard and forwards its JSON decision."""
|
||
|
||
def test_in_scope_write_passthrough_forwarded(self):
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
# research_architect_agent's allowed dir — an in-scope write should pass through.
|
||
# Use the agent's actual allowed glob area; a clearly in-scope path: workspace root file
|
||
# owned by main session is simplest, but we want the guard to RUN and say pass-through.
|
||
payload = {"tool_name": "Write", "cwd": ws,
|
||
"tool_input": {"file_path": os.path.join(ws, "notes.md"), "content": "x"}}
|
||
code, out, err = _run_launcher(bin_dir, payload, extra_env={"CLAUDE_PROJECT_DIR": ws})
|
||
self.assertEqual(code, 0)
|
||
decision = json.loads(out)
|
||
self.assertNotIn("permissionDecision", decision["hookSpecificOutput"])
|
||
|
||
def test_out_of_scope_bucket_a_deny_forwarded(self):
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
# Bucket A agent writing outside its allowed globs -> guard denies via JSON.
|
||
payload = _bucket_a_payload(ws, os.path.join(ws, "totally_out_of_scope_dir", "x.md"))
|
||
code, out, err = _run_launcher(bin_dir, payload, extra_env={"CLAUDE_PROJECT_DIR": ws})
|
||
self.assertEqual(code, 0, f"guard always exits 0; got {code} err={err!r}")
|
||
decision = json.loads(out)
|
||
self.assertEqual(decision["hookSpecificOutput"].get("permissionDecision"), "deny",
|
||
f"out-of-scope Bucket A write must be denied via forwarded JSON; got {out!r}")
|
||
|
||
def test_bash_denied_for_bucket_a_forwarded(self):
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
payload = {"tool_name": "Bash", "cwd": ws, "agent_type": "research_architect_agent",
|
||
"tool_input": {"command": "rm -rf /"}}
|
||
code, out, err = _run_launcher(bin_dir, payload, extra_env={"CLAUDE_PROJECT_DIR": ws})
|
||
self.assertEqual(code, 0)
|
||
decision = json.loads(out)
|
||
self.assertEqual(decision["hookSpecificOutput"].get("permissionDecision"), "deny")
|
||
|
||
|
||
class LauncherStubSkipOrderingTest(unittest.TestCase):
|
||
"""First candidate is a stub, a later one is real -> skip the stub, use the real one."""
|
||
|
||
def test_py_stub_python3_real(self):
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
_stub(os.path.join(bin_dir, "py")) # py -3 fails (stub)
|
||
_fake_real_python(os.path.join(bin_dir, "python3")) # python3 is real
|
||
payload = _bucket_a_payload(ws, os.path.join(ws, "out_of_scope", "x.md"))
|
||
code, out, err = _run_launcher(bin_dir, payload, extra_env={"CLAUDE_PROJECT_DIR": ws})
|
||
self.assertEqual(code, 0)
|
||
decision = json.loads(out)
|
||
self.assertEqual(decision["hookSpecificOutput"].get("permissionDecision"), "deny",
|
||
"must skip the py stub and use the real python3")
|
||
|
||
|
||
class LauncherPyDashThreeArgTest(unittest.TestCase):
|
||
"""`py -3` must be invoked as command `py` with arg `-3`, not an executable named `py -3`."""
|
||
|
||
def test_py_invoked_with_dash_three(self):
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
argv_log = os.path.join(bin_dir, "py_argv.log")
|
||
# fake `py` that only succeeds when given -3 as the first arg; logs argv.
|
||
_write_exec(os.path.join(bin_dir, "py"), (
|
||
"#!/bin/sh\n"
|
||
f'echo "$@" >> "{argv_log}"\n'
|
||
'if [ "$1" = "-3" ]; then shift; exec "' + REAL_PY + '" "$@"; fi\n'
|
||
"exit 1\n"
|
||
))
|
||
payload = _bucket_a_payload(ws, os.path.join(ws, "out_of_scope", "x.md"))
|
||
code, out, err = _run_launcher(bin_dir, payload, extra_env={"CLAUDE_PROJECT_DIR": ws})
|
||
self.assertEqual(code, 0)
|
||
decision = json.loads(out)
|
||
self.assertEqual(decision["hookSpecificOutput"].get("permissionDecision"), "deny")
|
||
# Confirm py was actually called with -3 (not as a literal "py -3" command, which
|
||
# would have produced no log line at all because no such command exists).
|
||
self.assertTrue(os.path.exists(argv_log), "py was never invoked")
|
||
with open(argv_log) as fh:
|
||
first = fh.readline().strip()
|
||
self.assertTrue(first.startswith("-3"), f"py must be called with -3 first; got {first!r}")
|
||
|
||
|
||
class LauncherSelfResolveTest(unittest.TestCase):
|
||
"""Launcher resolves the guard from its OWN path; works with CLAUDE_PLUGIN_ROOT unset
|
||
and with a plugin path containing spaces (codex P1)."""
|
||
|
||
def test_claude_plugin_root_unset(self):
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
payload = _bucket_a_payload(ws, os.path.join(ws, "out_of_scope", "x.md"))
|
||
# _run_launcher deliberately does not set CLAUDE_PLUGIN_ROOT.
|
||
code, out, err = _run_launcher(bin_dir, payload, extra_env={"CLAUDE_PROJECT_DIR": ws})
|
||
self.assertEqual(code, 0)
|
||
self.assertEqual(json.loads(out)["hookSpecificOutput"].get("permissionDecision"), "deny")
|
||
|
||
def test_plugin_path_with_spaces(self):
|
||
# Copy the launcher + guard into a path containing spaces, run from there.
|
||
with tempfile.TemporaryDirectory() as base, tempfile.TemporaryDirectory() as bin_dir, \
|
||
tempfile.TemporaryDirectory() as ws:
|
||
spaced = os.path.join(base, "plugin dir with spaces")
|
||
os.makedirs(os.path.join(spaced, "hooks"))
|
||
os.makedirs(os.path.join(spaced, "scripts"))
|
||
shutil.copy(LAUNCHER, os.path.join(spaced, "hooks", "run_guard.sh"))
|
||
shutil.copy(GUARD, os.path.join(spaced, "scripts", "ars_write_scope_guard.py"))
|
||
# the guard imports nothing repo-local except its manifest, which lives in scripts/
|
||
manifest = os.path.join(os.path.dirname(GUARD), "ars_phase_scope_manifest.json")
|
||
shutil.copy(manifest, os.path.join(spaced, "scripts", "ars_phase_scope_manifest.json"))
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
payload = _bucket_a_payload(ws, os.path.join(ws, "out_of_scope", "x.md"))
|
||
env = {"PATH": bin_dir + os.pathsep + _SYS_PATH, "CLAUDE_PROJECT_DIR": ws,
|
||
"ARS_PROBE_BOUND": "1"}
|
||
proc = subprocess.run([_SH, os.path.join(spaced, "hooks", "run_guard.sh")],
|
||
input=json.dumps(payload), env=env, capture_output=True,
|
||
text=True, timeout=_LAUNCHER_TIMEOUT)
|
||
self.assertEqual(proc.returncode, 0, f"stderr={proc.stderr!r}")
|
||
self.assertEqual(json.loads(proc.stdout)["hookSpecificOutput"].get("permissionDecision"),
|
||
"deny", "launcher must resolve guard via its own path even under spaces")
|
||
|
||
|
||
class LauncherGuardBrokeTest(unittest.TestCase):
|
||
"""Found real python BUT the guard subprocess misbehaves -> pass-through + exit 0,
|
||
do NOT block (maintainer decision §3.2.1), do NOT spam stderr.
|
||
|
||
Mechanism (P2-e): the launcher resolves the guard from its OWN ../scripts/ — no test-only
|
||
path override exists in production. So each test plants a broken guard in a temp plugin
|
||
layout (real launcher copy + broken scripts/ars_write_scope_guard.py) and runs that copy."""
|
||
|
||
def _run_with_broken_guard(self, guard_body):
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as base:
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
launcher_copy = _make_plugin_layout(base, guard_body)
|
||
return _run_launcher(
|
||
bin_dir, {"tool_name": "Bash", "tool_input": {"command": "ls"}},
|
||
launcher=launcher_copy,
|
||
)
|
||
|
||
def test_guard_exits_nonzero(self):
|
||
code, out, err = self._run_with_broken_guard("import sys\nsys.exit(3)\n")
|
||
self.assertEqual(code, 0, f"must not propagate guard crash as block/error; err={err!r}")
|
||
self.assertEqual(json.loads(out), PASS_THROUGH)
|
||
self.assertEqual(err.strip(), "")
|
||
|
||
def test_guard_prints_nothing(self):
|
||
code, out, err = self._run_with_broken_guard("pass\n") # exits 0, no stdout
|
||
self.assertEqual(code, 0)
|
||
self.assertEqual(json.loads(out), PASS_THROUGH)
|
||
self.assertEqual(err.strip(), "")
|
||
|
||
def test_guard_prints_invalid_json(self):
|
||
code, out, err = self._run_with_broken_guard("print('not json at all')\n")
|
||
self.assertEqual(code, 0)
|
||
self.assertEqual(json.loads(out), PASS_THROUGH)
|
||
self.assertEqual(err.strip(), "")
|
||
|
||
def test_guard_prints_substring_not_json(self):
|
||
"""P1-c: stdout CONTAINS the literal 'hookSpecificOutput' but is NOT valid JSON. A
|
||
substring grep would false-accept and forward garbage; the real json.load parse must
|
||
reject it and degrade to pass-through."""
|
||
code, out, err = self._run_with_broken_guard(
|
||
"print('not json but mentions \"hookSpecificOutput\" in prose')\n"
|
||
)
|
||
self.assertEqual(code, 0, f"non-JSON substring match must NOT be forwarded; err={err!r}")
|
||
self.assertEqual(json.loads(out), PASS_THROUGH)
|
||
self.assertEqual(err.strip(), "")
|
||
|
||
def test_guard_script_missing(self):
|
||
# A plugin layout with the launcher but NO scripts/ars_write_scope_guard.py: the
|
||
# launcher resolves a guard path that doesn't exist -> the run_bounded exec fails ->
|
||
# broken-path degrade. Build the layout, then delete the guard the helper planted.
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as base:
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
launcher_copy = _make_plugin_layout(base, "pass\n")
|
||
os.remove(os.path.join(base, "scripts", "ars_write_scope_guard.py"))
|
||
code, out, err = _run_launcher(
|
||
bin_dir, {"tool_name": "Bash", "tool_input": {"command": "ls"}},
|
||
launcher=launcher_copy,
|
||
)
|
||
self.assertEqual(code, 0, f"missing guard -> pass-through, not block; err={err!r}")
|
||
self.assertEqual(json.loads(out), PASS_THROUGH)
|
||
|
||
|
||
class LauncherHangingCandidateTest(unittest.TestCase):
|
||
"""A candidate interpreter that hangs must be killed within a bound, then move on —
|
||
the hot path must never hang (spec §3.3 watchdog)."""
|
||
|
||
def test_hanging_py_then_real_python3(self):
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
# py hangs on the marker probe; python3 is real. Launcher must kill py's probe
|
||
# and fall through to python3 within the test's 30s subprocess timeout.
|
||
_write_exec(os.path.join(bin_dir, "py"), "#!/bin/sh\nsleep 60\n")
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
payload = _bucket_a_payload(ws, os.path.join(ws, "out_of_scope", "x.md"))
|
||
code, out, err = _run_launcher(bin_dir, payload, extra_env={"CLAUDE_PROJECT_DIR": ws})
|
||
self.assertEqual(code, 0, f"must not hang/error on a hanging candidate; err={err!r}")
|
||
self.assertEqual(json.loads(out)["hookSpecificOutput"].get("permissionDecision"), "deny",
|
||
"must kill the hanging py and use the real python3")
|
||
|
||
def test_hanging_py_killed_via_watchdog_path(self):
|
||
"""P2-d: same hanging-candidate scenario, but ARS_GUARD_FORCE_WATCHDOG=1 forces the
|
||
no-`timeout` process-group watchdog instead of the `timeout` binary. Confirms the
|
||
watchdog (setsid + kill -TERM/-KILL on the negative pid) actually reaps a hung probe
|
||
so the launcher still falls through to the real python3 within the bound. A candidate
|
||
that hangs AND spawns a child grandprocess exercises the process-GROUP kill."""
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
# py spawns a long-sleeping grandchild then waits — a bare-pid TERM would leak the
|
||
# child; the process-group kill must take down the whole group.
|
||
_write_exec(os.path.join(bin_dir, "py"),
|
||
"#!/bin/sh\nsleep 60 &\nwait\n")
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
payload = _bucket_a_payload(ws, os.path.join(ws, "out_of_scope", "x.md"))
|
||
code, out, err = _run_launcher(bin_dir, payload,
|
||
extra_env={"CLAUDE_PROJECT_DIR": ws,
|
||
"ARS_GUARD_FORCE_WATCHDOG": "1"})
|
||
self.assertEqual(code, 0, f"watchdog must reap the hung probe; err={err!r}")
|
||
self.assertEqual(json.loads(out)["hookSpecificOutput"].get("permissionDecision"), "deny",
|
||
"watchdog must kill the hanging py and fall through to python3")
|
||
|
||
|
||
class LauncherWatchdogRobustnessTest(unittest.TestCase):
|
||
"""Round-6 gemini track: the no-`timeout` watchdog must not fail OPEN under (a) a private-
|
||
temp allocation failure or (b) the pid-reuse race that would false-report a timeout."""
|
||
|
||
def test_mktemp_failure_degrades_to_passthrough_not_fail_open(self):
|
||
"""gemini P1: if `mktemp` can't hand out a private temp, the launcher must NOT fall back
|
||
to a predictable /tmp path (symlink-attack surface whose redirect failure would read as a
|
||
broken guard and fail OPEN). It must degrade to a clean pass-through: exit 0, canonical
|
||
JSON, silent. We force mktemp to fail by shadowing it on PATH and force the watchdog path
|
||
(where the temp files live) via ARS_GUARD_FORCE_WATCHDOG."""
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
_write_exec(os.path.join(bin_dir, "mktemp"), "#!/bin/sh\nexit 1\n") # mktemp always fails
|
||
# A Bucket A out-of-scope write would normally be DENIED — proving we don't fail open,
|
||
# the launcher must NOT emit deny here (it can't run the guard without a temp), it must
|
||
# cleanly pass through instead of erroring or blocking.
|
||
payload = _bucket_a_payload(ws, os.path.join(ws, "out_of_scope", "x.md"))
|
||
code, out, err = _run_launcher(bin_dir, payload,
|
||
extra_env={"CLAUDE_PROJECT_DIR": ws,
|
||
"ARS_GUARD_FORCE_WATCHDOG": "1"})
|
||
self.assertEqual(code, 0, f"mktemp failure must not crash/block; err={err!r}")
|
||
self.assertEqual(json.loads(out), PASS_THROUGH,
|
||
"mktemp failure must degrade to canonical pass-through, never a "
|
||
"predictable-temp path that fails open")
|
||
self.assertEqual(err.strip(), "", f"degraded path must be silent; got {err!r}")
|
||
|
||
def test_fast_guard_not_falsely_timed_out_on_watchdog_path(self):
|
||
"""gemini P1 (pid-reuse race): on the forced watchdog path, a guard that answers WELL
|
||
within the bound must have its real decision forwarded — the done-file handshake must not
|
||
let the watchdog false-flag a timeout (which would fail open to pass-through). Run the
|
||
deny case several times; a single false-timeout would surface as a pass-through."""
|
||
with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
payload = _bucket_a_payload(ws, os.path.join(ws, "out_of_scope", "x.md"))
|
||
for i in range(5):
|
||
code, out, err = _run_launcher(bin_dir, payload,
|
||
extra_env={"CLAUDE_PROJECT_DIR": ws,
|
||
"ARS_GUARD_FORCE_WATCHDOG": "1"})
|
||
self.assertEqual(code, 0, f"run {i}: err={err!r}")
|
||
self.assertEqual(json.loads(out)["hookSpecificOutput"].get("permissionDecision"),
|
||
"deny",
|
||
f"run {i}: fast guard decision must be forwarded, not lost to a "
|
||
f"false timeout; got {out!r}")
|
||
|
||
|
||
class LauncherInfraProtectionTest(unittest.TestCase):
|
||
"""A Bucket A subagent writing to hooks/run_guard.sh itself must be denied — confirms
|
||
the hooks/*.sh infra glob covers the new launcher end-to-end through the launcher."""
|
||
|
||
def test_subagent_write_to_launcher_denied(self):
|
||
with tempfile.TemporaryDirectory() as bin_dir:
|
||
_fake_real_python(os.path.join(bin_dir, "python3"))
|
||
# plugin_root defaults to the repo root (launcher's ../). Target the real launcher
|
||
# path inside this repo so it resolves inside plugin_root and matches hooks/*.sh.
|
||
payload = {
|
||
"tool_name": "Write",
|
||
"cwd": REPO_ROOT,
|
||
"agent_type": "research_architect_agent",
|
||
"tool_input": {"file_path": LAUNCHER, "content": "evil"},
|
||
}
|
||
code, out, err = _run_launcher(bin_dir, payload, extra_env={"CLAUDE_PLUGIN_ROOT": REPO_ROOT})
|
||
self.assertEqual(code, 0)
|
||
self.assertEqual(json.loads(out)["hookSpecificOutput"].get("permissionDecision"), "deny",
|
||
"writing the launcher itself must be infra-denied")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|