"""TDD test suite for the ARS write-scope guard PreToolUse hook (#134 Slice 1).

Spec: docs/design/2026-06-01-ars-134-conductor-rescope-deterministic-write-guard-spec.md (§3.4).

The hook's testable core is `evaluate_decision(payload, manifest, workspace_root)`,
a pure function returning a decision dict {"decision": "allow"|"deny", "reason": str}.
`main()` only wires stdin -> evaluate_decision -> stdout JSON and is not unit-tested here
(the decision logic is what matters; the I/O shell is a thin adapter verified by the
hooks.json wiring + spec §3.2 first-party-verified deny schema).

Decision semantics under test (spec §3.2 strict-order 4-step logic):
  Step 1: normalize the write target FIRST (absolute resolve + symlink resolve +
          workspace commonpath + traversal deny -> workspace-root-relative canonical).
  Step 2: infrastructure self-protection (unconditional, on normalized path).
  Step 3: agent gating (Bucket A agent_type -> enforce allowed_write_globs;
          absent/non-Bucket-A -> allow, but fail-loud-log absent agent_type write).
  Step 4: tool-specific (Write/Edit/MultiEdit single top-level file_path;
          Bash deny-all for a Bucket A agent, pass-through for non-Bucket-A — neither a
          denylist nor an allowlist of Bash is sound, so all-deny is the only zero-fail-open
          policy).

Tests build a REAL workspace dir tree under tmp so normalization runs against the
real filesystem (no os.path mocking).
"""

import json
import os
import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

import ars_write_scope_guard as guard  # noqa: E402


# A minimal in-test manifest mirroring the real shape (keys = frontmatter name).
TEST_MANIFEST = {
    "version": 1,
    "agents": {
        "bibliography_agent": {
            "bucket": "A",
            "skill": "deep-research",
            "phase": "2",
            "allowed_write_globs": ["phase2_*/**"],
        },
        "synthesis_agent": {
            "bucket": "A",
            "skill": "deep-research",
            "phase": "3",
            "allowed_write_globs": ["phase3_*/**"],
        },
        "formatter_agent": {
            "bucket": "A",
            "skill": "academic-paper",
            "phase": "7",
            "allowed_write_globs": ["phase7_*/**"],
        },
        # Deliberately-broad glob agent: still must lose to Step 2 self-protection.
        "broad_test_agent": {
            "bucket": "A",
            "skill": "test",
            "phase": "X",
            "allowed_write_globs": ["**"],
        },
    },
}


def payload(tool_name, tool_input, cwd, agent_type=None, agent_id=None):
    p = {
        "session_id": "test",
        "cwd": cwd,
        "hook_event_name": "PreToolUse",
        "tool_name": tool_name,
        "tool_input": tool_input,
    }
    if agent_type is not None:
        p["agent_type"] = agent_type
    if agent_id is not None:
        p["agent_id"] = agent_id
    return p


class WriteScopeGuardTest(unittest.TestCase):
    def setUp(self):
        self._tmp = tempfile.TemporaryDirectory()
        self.ws = os.path.realpath(self._tmp.name)  # workspace root
        # Build real phase dirs so normalization resolves real parents.
        for d in ("phase2_investigation", "phase3_analysis", "phase7_format", "hooks", "scripts"):
            os.makedirs(os.path.join(self.ws, d), exist_ok=True)

    def tearDown(self):
        self._tmp.cleanup()

    def decide(self, p):
        return guard.evaluate_decision(p, TEST_MANIFEST, self.ws)

    # --- Step 4 / Step 3: structured-tool in-scope vs out-of-scope ---

    def test_in_scope_write_allowed(self):
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, "phase2_investigation/annotated_bib.md"), "content": "x"},
                    cwd=self.ws, agent_type="bibliography_agent")
        self.assertEqual(self.decide(p)["decision"], "allow")

    def test_out_of_scope_write_denied(self):
        # bibliography_agent (phase 2) writing into phase3 dir = the #133 shape.
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, "phase3_analysis/synthesis.md"), "content": "x"},
                    cwd=self.ws, agent_type="bibliography_agent")
        d = self.decide(p)
        self.assertEqual(d["decision"], "deny")
        self.assertIn("bibliography_agent", d["reason"])

    def test_in_scope_edit_allowed(self):
        p = payload("Edit",
                    {"file_path": os.path.join(self.ws, "phase3_analysis/synthesis.md"),
                     "old_string": "a", "new_string": "b"},
                    cwd=self.ws, agent_type="synthesis_agent")
        self.assertEqual(self.decide(p)["decision"], "allow")

    def test_out_of_scope_multiedit_denied_via_top_level_file_path(self):
        # MultiEdit carries a SINGLE top-level file_path + edits[]; the path check
        # must use that top-level file_path, not a non-existent per-edit path array.
        p = payload("MultiEdit",
                    {"file_path": os.path.join(self.ws, "phase2_investigation/bib.md"),
                     "edits": [{"old_string": "a", "new_string": "b"},
                               {"old_string": "c", "new_string": "d"}]},
                    cwd=self.ws, agent_type="synthesis_agent")  # phase3 agent, writing phase2
        d = self.decide(p)
        self.assertEqual(d["decision"], "deny")

    def test_in_scope_multiedit_allowed(self):
        p = payload("MultiEdit",
                    {"file_path": os.path.join(self.ws, "phase3_analysis/synthesis.md"),
                     "edits": [{"old_string": "a", "new_string": "b"}]},
                    cwd=self.ws, agent_type="synthesis_agent")
        self.assertEqual(self.decide(p)["decision"], "allow")

    # --- Bash policy: DENY ALL Bash for a Bucket A agent; non-Bucket-A Bash passes
    #     through unconstrained. ---

    def test_bash_denied_wholesale_for_bucket_a(self):
        # EVERY Bash command is denied for a Bucket A agent regardless of content — the only
        # policy that reaches zero fail-open by construction (neither "writes a file" nor "is
        # read-only" is decidable from a command string). One representative per class here
        # (+ the deny flag); the exhaustive historical-bypass list lives in
        # BashBypassRegressionTest.test_all_historical_bash_bypasses_denied.
        for cmd in ("rm x",                       # plainly writing
                    "python make_synthesis.py",   # interpreter (no denylist could enumerate)
                    "grep foo f",                 # looks read-only — still denied
                    "rg --pre 'sh -c touch' x f"):  # "read-only" tool that executes a subprocess
            p = payload("Bash", {"command": cmd}, cwd=self.ws, agent_type="bibliography_agent")
            d = self.decide(p)
            self.assertEqual(d["decision"], "deny", f"{cmd!r} should deny (all-Bash-deny)")
            self.assertTrue(d.get("bash_denied"), f"{cmd!r} should set bash_denied")

    def test_bash_deny_reason_routes_to_structured_and_search_tools(self):
        # The deny reason must tell the agent what to use instead (Grep/Glob + structured).
        p = payload("Bash", {"command": "grep foo f"}, cwd=self.ws, agent_type="bibliography_agent")
        reason = self.decide(p)["reason"].lower()
        self.assertIn("grep", reason)
        self.assertIn("write/edit", reason.replace(" ", ""))

    def test_bash_allowed_for_non_bucket_a(self):
        # A non-Bucket-A agent / main session is unconstrained — ANY Bash passes (the guard
        # only fences the 23 single-phase agents). Covers a named non-bucket-A agent and the
        # absent-agent_type main session.
        p = payload("Bash", {"command": "cp a b"}, cwd=self.ws, agent_type="report_compiler_agent")
        self.assertEqual(self.decide(p)["decision"], "allow")
        p2 = payload("Bash", {"command": "rm x"}, cwd=self.ws)  # no agent_type (main session)
        self.assertEqual(self.decide(p2)["decision"], "allow")

    # --- Step 2: infrastructure self-protection (unconditional) ---

    def test_infra_hooks_json_denied_for_every_agent(self):
        for at in ("bibliography_agent", "synthesis_agent", "formatter_agent", "broad_test_agent"):
            p = payload("Write",
                        {"file_path": os.path.join(self.ws, "hooks/hooks.json"), "content": "x"},
                        cwd=self.ws, agent_type=at)
            d = self.decide(p)
            self.assertEqual(d["decision"], "deny", f"{at} must not write hooks/hooks.json")
            self.assertIn("infrastructure", d["reason"].lower())

    def test_infra_manifest_denied_even_with_broad_glob(self):
        # broad_test_agent has glob ["**"] which would otherwise permit everything;
        # Step 2 self-protection overrides any later allow.
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, "scripts/ars_phase_scope_manifest.json"), "content": "x"},
                    cwd=self.ws, agent_type="broad_test_agent")
        self.assertEqual(self.decide(p)["decision"], "deny")

    def test_infra_hook_script_denied(self):
        # The hook script itself (lives in scripts/) is part of the enforcement surface.
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, "scripts/ars_write_scope_guard.py"), "content": "x"},
                    cwd=self.ws, agent_type="broad_test_agent")
        self.assertEqual(self.decide(p)["decision"], "deny")

    def test_infra_agent_frontmatter_denied(self):
        # Agent definition files carry the agent_type==name binding; writing them would
        # let an agent rename itself out of the manifest and fail the guard open.
        os.makedirs(os.path.join(self.ws, "deep-research/agents"), exist_ok=True)
        p = payload("Edit",
                    {"file_path": os.path.join(self.ws, "deep-research/agents/bibliography_agent.md"),
                     "old_string": "a", "new_string": "b"},
                    cwd=self.ws, agent_type="broad_test_agent")
        self.assertEqual(self.decide(p)["decision"], "deny")

    # --- Step 1 ordering: normalization precedes self-protection (traversal bypass) ---

    def test_traversal_bypass_to_infra_denied(self):
        # A Write whose raw file_path uses ../ to escape a phase dir back into hooks/.
        # Normalization (Step 1) must canonicalize it to hooks/hooks.json BEFORE Step 2,
        # so self-protection catches it. Proves normalize-first ordering.
        raw = os.path.join(self.ws, "phase2_investigation/../hooks/hooks.json")
        p = payload("Write", {"file_path": raw, "content": "x"},
                    cwd=self.ws, agent_type="bibliography_agent")
        d = self.decide(p)
        self.assertEqual(d["decision"], "deny")
        self.assertIn("infrastructure", d["reason"].lower())

    def test_traversal_escaping_workspace_denied(self):
        # ../ escaping the workspace root entirely must be denied FOR A BUCKET A AGENT
        # (commonpath guard). Bucket A stays fenced to its workspace regardless of actor.
        raw = os.path.join(self.ws, "phase2_investigation/../../outside.md")
        p = payload("Write", {"file_path": raw, "content": "x"},
                    cwd=self.ws, agent_type="bibliography_agent")
        self.assertEqual(self.decide(p)["decision"], "deny")

    def test_main_session_escape_allowed(self):
        # #302: a MAIN-SESSION write whose target resolves outside the workspace root
        # (e.g. a sibling git worktree) is ALLOWED — the main session is unconstrained by
        # Slice 1 (§3.3). The escape deny is a Bucket A fence, not a main-session fence.
        raw = os.path.join(self.ws, "../sibling-worktree/file.md")
        p = payload("Write", {"file_path": raw, "content": "x"}, cwd=self.ws)  # no agent_type
        d = self.decide(p)
        self.assertEqual(d["decision"], "allow")
        # Still fail-loud about the absent agent_type (not a silent no-op).
        self.assertTrue(d.get("absent_agent_type_advisory"),
                        "absent agent_type escape write must still surface the advisory")

    def test_non_bucket_a_agent_escape_allowed(self):
        # A Bucket B/C/D agent (agent_type present but not a manifest key) is also
        # unconstrained by Slice 1 — an escaping write is allowed, no advisory (it HAS a type).
        raw = os.path.join(self.ws, "../sibling-worktree/file.md")
        p = payload("Write", {"file_path": raw, "content": "x"},
                    cwd=self.ws, agent_type="report_compiler_agent")  # Bucket B, not in manifest
        d = self.decide(p)
        self.assertEqual(d["decision"], "allow")
        self.assertFalse(d.get("absent_agent_type_advisory"))

    # --- Step 3: agent gating edge cases ---

    def test_absent_agent_type_allowed(self):
        # Main session (no agent_type) is unconstrained by Slice 1.
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, "phase7_format/anything.md"), "content": "x"},
                    cwd=self.ws)  # no agent_type
        d = self.decide(p)
        self.assertEqual(d["decision"], "allow")
        # but it must fail-loud-log the absent-agent_type write (not silent no-op)
        self.assertTrue(d.get("absent_agent_type_advisory"),
                        "absent agent_type write must surface a fail-loud advisory, not silently no-op")

    def test_absent_agent_type_still_denied_for_infra(self):
        # Even the main session may not clobber infrastructure (Step 2 unconditional).
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, "hooks/hooks.json"), "content": "x"},
                    cwd=self.ws)  # no agent_type
        self.assertEqual(self.decide(p)["decision"], "deny")

    def test_non_bucket_a_agent_type_allowed(self):
        # An agent_type not in the manifest (e.g. a Bucket B/C/D agent) is unconstrained.
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, "phase7_format/report.md"), "content": "x"},
                    cwd=self.ws, agent_type="report_compiler_agent")  # Bucket B, not in manifest
        self.assertEqual(self.decide(p)["decision"], "allow")

    # --- Step 1: absolute vs relative normalization ---

    def test_absolute_path_under_workspace_matches_relative_glob(self):
        # A Write with an ABSOLUTE file_path under the workspace must resolve and match
        # its relative glob (glob is workspace-root-anchored, not absolute, not cwd-rel).
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, "phase7_format/paper.md"), "content": "x"},
                    cwd=self.ws, agent_type="formatter_agent")
        self.assertEqual(self.decide(p)["decision"], "allow")

    def test_relative_path_resolved_against_cwd(self):
        # A relative file_path resolves against cwd (= workspace here) then matches glob.
        p = payload("Write",
                    {"file_path": "phase7_format/paper.md", "content": "x"},
                    cwd=self.ws, agent_type="formatter_agent")
        self.assertEqual(self.decide(p)["decision"], "allow")


class GlobMatchBoundaryTest(unittest.TestCase):
    """Negative + positive glob-match cases (feedback: widening a matcher needs
    explicit 'should-be-rejected' cases — unit pass at one layer can still mismatch)."""

    POSITIVE = [
        ("phase2_investigation/notes.txt", ["phase2_*/**"]),
        ("phase2_investigation/sub/deep.txt", ["phase2_*/**"]),   # deep descendant
        ("phase7_format/paper.md", ["phase7_*/**"]),
        ("scripts/ars_write_scope_guard.py", ["**/ars_write_scope_guard.py"]),  # subdir
        ("a/b/c/ars_write_scope_guard.py", ["**/ars_write_scope_guard.py"]),    # deep subdir
        ("ars_write_scope_guard.py", ["ars_write_scope_guard.py"]),             # root via bare
        ("deep-research/agents/bibliography_agent.md", ["deep-research/agents/*.md"]),
    ]
    NEGATIVE = [
        ("phase3_analysis/x.md", ["phase2_*/**"]),                # wrong phase number
        ("phase2x/notes.txt", ["phase2_*/**"]),                   # no underscore -> NOT phase2_
        ("other/phase2_investigation/x.md", ["phase2_*/**"]),     # nested, not workspace-root
        # FALSE-OPEN regression (caught in review): a ROOT-LEVEL file whose name fnmatches the
        # phase-dir glob must NOT match — `dir/**` covers descendants only, not a bare file.
        ("phase2_x.md", ["phase2_*/**"]),
        ("phase1_malicious.py", ["phase1_*/**"]),
        ("phase2_investigation", ["phase2_*/**"]),                # bare dir node: not a write target
        # `**/name` matches subdirs only, NOT root (root coverage is the bare entry).
        ("ars_write_scope_guard.py", ["**/ars_write_scope_guard.py"]),
    ]

    def test_positive_matches(self):
        for rel, globs in self.POSITIVE:
            self.assertTrue(guard._matches_any(rel, globs), f"{rel} should match {globs}")

    def test_negative_non_matches(self):
        for rel, globs in self.NEGATIVE:
            self.assertFalse(guard._matches_any(rel, globs), f"{rel} should NOT match {globs}")


class DenyJSONShapeTest(unittest.TestCase):
    """The deny output JSON shape (spec §3.2 first-party-verified)."""

    def test_render_deny_decision_json(self):
        out = guard.render_hook_output({"decision": "deny", "reason": "ARS scope guard: nope"})
        obj = json.loads(out)
        hso = obj["hookSpecificOutput"]
        self.assertEqual(hso["hookEventName"], "PreToolUse")
        self.assertEqual(hso["permissionDecision"], "deny")
        self.assertEqual(hso["permissionDecisionReason"], "ARS scope guard: nope")

    def test_render_allow_is_passthrough_not_grant(self):
        # Non-deny must be PASS-THROUGH (no permissionDecision key), NOT an explicit
        # "allow" grant — an "allow" would skip every other permission rule (review
        # finding). Assert the key is absent entirely.
        out = guard.render_hook_output({"decision": "allow", "reason": ""})
        hso = json.loads(out)["hookSpecificOutput"]
        self.assertEqual(hso["hookEventName"], "PreToolUse")
        self.assertNotIn("permissionDecision", hso)


class NormalizationRegressionTest(unittest.TestCase):
    """Regression tests for normalization / glob-boundary findings — each must stay fixed."""

    def setUp(self):
        self._tmp = tempfile.TemporaryDirectory()
        self.ws = os.path.realpath(self._tmp.name)
        for d in ("phase2_investigation", "phase3_analysis", "hooks", "scripts", ".claude-plugin"):
            os.makedirs(os.path.join(self.ws, d), exist_ok=True)

    def tearDown(self):
        self._tmp.cleanup()

    def decide(self, p):
        return guard.evaluate_decision(p, TEST_MANIFEST, self.ws)

    def test_bash_bypass_shapes_that_broke_the_old_parser_still_deny(self):
        # The shapes that defeated the old literal-target parser (quote-blind redirection,
        # quoted `;` in cp, no-whitespace redirection, `cp -t DEST` flag order) are now ALL
        # denied trivially by the all-Bash-deny policy — no parsing required. These stay as
        # regression cases so a future re-introduction of literal parsing can't reopen them.
        shapes = [
            'echo x > "' + os.path.join(self.ws, "hooks/hooks.json") + '"',  # quoted target
            "echo x>" + os.path.join(self.ws, "phase3_analysis/leak.md"),    # no whitespace
            'cp "a;b" ' + os.path.join(self.ws, "phase3_analysis/leak.md"),  # quoted ; in cp
            "cp -t " + os.path.join(self.ws, "phase3_analysis") + " src.md",  # -t flag order
        ]
        for cmd in shapes:
            p = payload("Bash", {"command": cmd}, cwd=self.ws, agent_type="bibliography_agent")
            self.assertEqual(self.decide(p)["decision"], "deny", f"{cmd!r} should deny")

    def test_root_level_phase_named_file_denied(self):
        # FALSE-OPEN P0: a phase2 agent writing a ROOT file named `phase2_x.md` must be
        # denied — it is not inside the phase dir (dir/** = descendants only).
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, "phase2_x.md"), "content": "x"},
                    cwd=self.ws, agent_type="bibliography_agent")
        self.assertEqual(self.decide(p)["decision"], "deny")

    def test_plugin_json_infra_protected(self):
        p = payload("Write",
                    {"file_path": os.path.join(self.ws, ".claude-plugin/plugin.json"), "content": "x"},
                    cwd=self.ws, agent_type="broad_test_agent")
        d = self.decide(p)
        self.assertEqual(d["decision"], "deny")
        self.assertIn("infrastructure", d["reason"].lower())

    def test_symlink_traversal_to_infra_denied(self):
        # A symlinked dir + `../` must resolve through the symlink (realpath), not be
        # lexically collapsed by abspath. Build: ws/link -> ws/phase2_investigation, then
        # write link/../hooks/hooks.json. realpath resolves link first => ws/hooks/hooks.json.
        os.symlink(os.path.join(self.ws, "phase2_investigation"), os.path.join(self.ws, "link"))
        raw = os.path.join(self.ws, "link/../hooks/hooks.json")
        p = payload("Write", {"file_path": raw, "content": "x"},
                    cwd=self.ws, agent_type="bibliography_agent")
        d = self.decide(p)
        self.assertEqual(d["decision"], "deny")
        self.assertIn("infrastructure", d["reason"].lower())

    def test_symlink_escaping_workspace_denied(self):
        # A symlink pointing OUTSIDE the workspace must be detected as an escape, not
        # approved on its lexical in-workspace name.
        outside = tempfile.mkdtemp()
        try:
            os.symlink(outside, os.path.join(self.ws, "exit"))
            raw = os.path.join(self.ws, "exit/leak.md")
            p = payload("Write", {"file_path": raw, "content": "x"},
                        cwd=self.ws, agent_type="bibliography_agent")
            self.assertEqual(self.decide(p)["decision"], "deny")
        finally:
            import shutil
            shutil.rmtree(outside, ignore_errors=True)


class BashBypassRegressionTest(unittest.TestCase):
    """Regression pins for the historical Bash fail-open shapes + the deep-path crash.

    Review surfaced a long list of Bash bypasses (quoted redirection, statement split,
    `&>`/`<>`, transparent wrappers `command rm`, shell structure `(rm x)`/`$(rm x)`,
    `sudo -u bob rm`, `>(rm)`, backtick `` `rm` ``, and "read-only" tools that execute
    subprocesses like `rg --pre`). The final policy denies ALL Bash for a Bucket A agent, so
    every one of these denies trivially. These cases stay pinned: if anyone ever re-introduces
    a denylist/allowlist/parse approach, they must still deny. (The recursive-matcher crash is
    a separate fix pinned at the end.)
    """

    def setUp(self):
        self._tmp = tempfile.TemporaryDirectory()
        self.ws = os.path.realpath(self._tmp.name)
        for d in ("phase2_investigation", "phase3_analysis", "phase7_format", "hooks", "scripts"):
            os.makedirs(os.path.join(self.ws, d), exist_ok=True)

    def tearDown(self):
        self._tmp.cleanup()

    def decide(self, p):
        return guard.evaluate_decision(p, TEST_MANIFEST, self.ws)

    def _bash(self, cmd):
        return self.decide(payload("Bash", {"command": cmd}, cwd=self.ws,
                                   agent_type="bibliography_agent"))

    def test_all_historical_bash_bypasses_denied(self):
        # Every shape that defeated an earlier denylist OR allowlist attempt. Under the final
        # all-Bash-deny policy they all deny trivially — no parsing, no allowlist membership,
        # no flag guard. (Labels name the bypass class so the coverage intent survives.)
        shapes = [
            "echo hi &> phase3_analysis/leak.md",      # combined redirect
            "cat <> phase3_analysis/leak.md",          # read-write open
            "command rm hooks/hooks.json",             # transparent wrapper
            "sudo -u bob rm x",                        # wrapper + flag
            "(rm x)", "{ rm x; }",                     # grouping
            "if true; then rm x; fi",                  # conditional
            "echo $(rm hooks/hooks.json)",             # command substitution
            "echo >(rm hooks/hooks.json)",             # process substitution
            "echo `rm x`",                             # backtick substitution
            "echo x>f", 'cp "a;b" /elsewhere',         # quote/no-whitespace parser bypasses
            # commands that LOOK read-only but execute a subprocess / write state:
            "rg --pre 'sh -c touch' x f", "git grep --open-files-in-pager=touch x",
            "sort --compress-program=touch big.txt", "GIT_EXTERNAL_DIFF=touch git diff",
            "uniq in.txt out.txt", "git status",
            # inputs once worried about as FALSE-DENY — moot now, all Bash denies:
            "grep in rm file", "grep foo 2>&1",
        ]
        for cmd in shapes:
            self.assertEqual(self._bash(cmd)["decision"], "deny", f"{cmd!r} must deny (all-Bash-deny)")

    # --- P1-c: recursive `_match_segments` blew the Python recursion limit on deep paths. ---

    def test_deep_path_glob_match_does_not_crash(self):
        # ~1100 path segments raised RecursionError (limit 1000) in the recursive matcher,
        # crashing the hook (uncaught in main()) — fail-open or wedge depending on the CC
        # contract; either way unacceptable. The matcher must handle arbitrary depth without
        # blowing the stack (caught in review).
        deep_under = "phase2_investigation/" + "/".join(["a"] * 2000) + "/leak.md"
        # Must still produce a correct decision (it IS under phase2_*, so a structured-tool
        # write is allowed; the point is: no crash).
        self.assertTrue(guard._matches_any(deep_under, ["phase2_*/**"]))
        deep_other = "phase3_analysis/" + "/".join(["a"] * 2000) + "/leak.md"
        self.assertFalse(guard._matches_any(deep_other, ["phase2_*/**"]))
        # And end-to-end through evaluate_decision (the path that actually crashed).
        p = payload("Write", {"file_path": os.path.join(self.ws, deep_under), "content": "x"},
                    cwd=self.ws, agent_type="bibliography_agent")
        self.assertEqual(self.decide(p)["decision"], "allow")


class MalformedPayloadTest(unittest.TestCase):
    """Valid-JSON-wrong-shape and odd tool_input must not crash (must pass through)."""

    def decide(self, p):
        return guard.evaluate_decision(p, TEST_MANIFEST, "/tmp")

    def test_list_payload_does_not_crash(self):
        # evaluate_decision is the pure core; main() guards non-dict payloads. Here we
        # assert tool_input of the wrong type does not crash the core.
        p = {"tool_name": "Bash", "tool_input": [], "cwd": "/tmp"}
        d = self.decide(p)
        self.assertEqual(d["decision"], "allow")  # no catchable target, no agent -> allow

    def test_null_tool_input_does_not_crash(self):
        p = {"tool_name": "Write", "tool_input": None, "cwd": "/tmp", "agent_type": "bibliography_agent"}
        # Write with no usable file_path -> schema-drift deny (fail closed), not a crash.
        d = self.decide(p)
        self.assertEqual(d["decision"], "deny")
        self.assertTrue(d.get("schema_drift_advisory"))


if __name__ == "__main__":
    unittest.main()
