"""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

# #545: GENEROUS default bound. For decision-forwarding tests the bound is pure safety
# margin — it only ever elapses when a candidate actually hangs — and the old pinned "1"
# left so little margin that interpreter startup + the guard run could overrun it under
# machine load (worse on hosts without a `timeout` binary, where the watchdog's done-file
# handshake races completions near the bound). The launcher then degraded BY DESIGN to
# pass-through and deny-expecting tests failed in rotation. Hanging-candidate tests are
# the ONLY ones whose runtime scales with the bound (they wait it out to kill the hung
# probe), so they override with _HANG_PROBE_BOUND — small for speed, but not the flaky
# "1": the same bound also clocks the real python3 probe and the guard run inside them,
# so it still needs load headroom.
_DEFAULT_PROBE_BOUND = "30"
_HANG_PROBE_BOUND = "5"


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,
        "ARS_PROBE_BOUND": _DEFAULT_PROBE_BOUND,  # #545: see the constant's comment
        # 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": _DEFAULT_PROBE_BOUND}
            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)."""

    _HANG_BOUND = {"ARS_PROBE_BOUND": _HANG_PROBE_BOUND}  # #545: see _DEFAULT_PROBE_BOUND

    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 _LAUNCHER_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._HANG_BOUND})
            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._HANG_BOUND})
            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 LauncherSlowInterpreterTest(unittest.TestCase):
    """#545 regression pin: a SLOW but working interpreter (what a loaded machine
    produces) must not be false-degraded into pass-through. Under the harness default
    bound a python3 that takes ~1.5s per invocation must still produce the real deny
    decision — see _DEFAULT_PROBE_BOUND for the full mechanism."""

    def test_slow_but_working_python_forwards_deny(self):
        with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as ws:
            _write_exec(os.path.join(bin_dir, "python3"),
                        f'#!/bin/sh\nsleep 1.5\nexec "{REAL_PY}" "$@"\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, f"err={err!r}")
            self.assertEqual(json.loads(out)["hookSpecificOutput"].get("permissionDecision"), "deny",
                             "a slow-but-real interpreter must not be false-degraded to pass-through")


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()
