Files
imbad0202__academic-researc…/scripts/test_run_guard_launcher.py
T
Edward Cheng-I Wu 4f8acbcaf2 fix(#454): Windows Python hook portability + graceful no-Python degradation
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)
2026-06-18 11:56:04 +08:00

468 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.13.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()