#!/usr/bin/env python3
"""Tests for check_tools_allowlist.py (#524).

Mutation discipline: every invariant branch has a passing case (green fixture
tree + the real repo tree) and a failing case proving the check fires when
the guarded property is broken. Two load-bearing families:

  * The SYMMETRIC source+mirror edit re-adding Bash — it passes
    check_agents_mirror_sync.py (byte-equal pair) and the name-keyed runtime
    guard lint, so before #524 it sailed through CI green (the drift the
    issue documents).
  * YAML-form bypasses of the semantic checks — quoted/flow/block/escaped
    spellings that a raw line scan cannot see. These were found by the codex
    xhigh review; the node-tree parse closes them and each has a witness
    here.
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

from check_tools_allowlist import (
    ALLOWLISTED_FILES,
    CANONICAL_TOOLS,
    MANIFEST,
    PINNED_TOOLS_LINE,
    REPO_ROOT,
    check,
)


def make_tree(tmp_path: Path) -> Path:
    """A green fixture tree: six allowlisted files carrying the pinned line,
    a minimal Bucket A manifest, a fenced no-tools agent, and an unfenced
    Bash-holding agent."""
    for rel in ALLOWLISTED_FILES:
        p = tmp_path / rel
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(
            f"---\nname: {p.stem}\ndescription: \"x\"\nmodel: inherit\n"
            f"{PINNED_TOOLS_LINE}\n---\n\nbody\n",
            encoding="utf-8",
        )
    manifest = tmp_path / MANIFEST
    manifest.parent.mkdir(parents=True, exist_ok=True)
    manifest.write_text(json.dumps({"agents": {
        "report_compiler_agent": {},
        "research_architect_agent": {},
        "synthesis_agent": {},
        "eic_agent": {},
    }}), encoding="utf-8")
    # A Bucket A agent with NO tools key (inherit) — must pass untouched.
    eic = tmp_path / "academic-paper-reviewer/agents/eic_agent.md"
    eic.parent.mkdir(parents=True, exist_ok=True)
    eic.write_text("---\nname: eic_agent\n---\n\nbody\n", encoding="utf-8")
    # A NON-Bucket-A agent advertising Bash — allowed (invariant 2 is
    # scoped to fenced agents; the orchestrator legitimately holds shell).
    orch = tmp_path / "academic-pipeline/agents/pipeline_orchestrator_agent.md"
    orch.parent.mkdir(parents=True, exist_ok=True)
    orch.write_text(
        "---\nname: pipeline_orchestrator_agent\ntools: Read, Bash\n---\n\nbody\n",
        encoding="utf-8",
    )
    return tmp_path


def first_pair() -> tuple[str, str]:
    """One (source, mirror) pair for symmetric-edit mutations."""
    return ("deep-research/agents/research_architect_agent.md",
            "agents/research_architect_agent.md")


def rewrite_tools_line(path: Path, new_line: str) -> None:
    text = path.read_text(encoding="utf-8")
    path.write_text(text.replace(PINNED_TOOLS_LINE, new_line),
                    encoding="utf-8")


def errs_for(tmp_path: Path, rel: str) -> list[str]:
    return [e for e in check(tmp_path) if rel in e]


# --- invariant 0: the real tree is green --------------------------------------

def test_real_repo_passes():
    assert check(REPO_ROOT) == []


# --- green fixture -------------------------------------------------------------

def test_green_tree_passes(tmp_path):
    make_tree(tmp_path)
    assert check(tmp_path) == []


# --- invariant 1: exact value + semantic parse ---------------------------------

def test_symmetric_bash_readd_fails_on_both_files(tmp_path):
    # THE #524 drift scenario: source+mirror edited together to re-add Bash.
    # Mirror-sync stays green (byte-equal pair); this lint must fire on BOTH,
    # via the semantic value check AND the byte-exact witness.
    make_tree(tmp_path)
    src, mirror = first_pair()
    for rel in (src, mirror):
        rewrite_tools_line(tmp_path / rel, PINNED_TOOLS_LINE + ", Bash")
    assert any("diverges from the canonical" in e for e in errs_for(tmp_path, src))
    assert any("diverges from the canonical" in e for e in errs_for(tmp_path, mirror))


def test_dropped_tool_fails(tmp_path):
    make_tree(tmp_path)
    src, _ = first_pair()
    rewrite_tools_line(tmp_path / src, "tools: Read, Write, Edit, Glob")
    assert any("diverges from the canonical" in e for e in errs_for(tmp_path, src))


def test_typoed_tool_name_fails(tmp_path):
    make_tree(tmp_path)
    src, _ = first_pair()
    rewrite_tools_line(tmp_path / src, "tools: Read, Write, Edit, Gerp, Glob")
    assert any("diverges from the canonical" in e for e in errs_for(tmp_path, src))


def test_trailing_whitespace_is_byte_drift(tmp_path):
    # A trailing space keeps the semantic value intact but breaks the
    # byte-exact witness — the exact-form pin must still fire.
    make_tree(tmp_path)
    src, _ = first_pair()
    rewrite_tools_line(tmp_path / src, PINNED_TOOLS_LINE + " ")
    assert any("not byte-equal" in e for e in errs_for(tmp_path, src))


def test_crlf_conversion_is_byte_drift(tmp_path):
    # A symmetric LF->CRLF conversion leaves YAML semantics intact, so only
    # the CR-sensitive byte witness catches it (codex round-1 P2).
    make_tree(tmp_path)
    src, mirror = first_pair()
    for rel in (src, mirror):
        p = tmp_path / rel
        p.write_bytes(p.read_bytes().replace(b"\n", b"\r\n"))
    assert any("not byte-equal" in e for e in errs_for(tmp_path, src))
    assert any("not byte-equal" in e for e in errs_for(tmp_path, mirror))


def test_missing_tools_key_fails_as_widening(tmp_path):
    # Dropping the key silently widens capability (agent inherits ALL tools).
    make_tree(tmp_path)
    src, _ = first_pair()
    p = tmp_path / src
    p.write_text(p.read_text(encoding="utf-8").replace(
        PINNED_TOOLS_LINE + "\n", ""), encoding="utf-8")
    assert any("no `tools:` key" in e for e in errs_for(tmp_path, src))


def test_escaped_duplicate_key_fails(tmp_path):
    # The sharpest round-2 bypass: keep the pinned bare line AND add a
    # `s`-escaped `"tools":` duplicate. safe_load collapses the two
    # to the last-wins canonical value; the duplicate-preserving node tree
    # sees TWO `tools` keys and fires.
    make_tree(tmp_path)
    src, _ = first_pair()
    rewrite_tools_line(
        tmp_path / src,
        '"tool\\u0073": Read, Write, Edit, Grep, Glob, Bash\n'
        + PINNED_TOOLS_LINE,
    )
    assert any("2 `tools` keys" in e for e in errs_for(tmp_path, src))


def test_quoted_duplicate_key_fails(tmp_path):
    make_tree(tmp_path)
    src, _ = first_pair()
    rewrite_tools_line(tmp_path / src,
                       f'{PINNED_TOOLS_LINE}\n"tools": Read, Bash')
    assert any("2 `tools` keys" in e for e in errs_for(tmp_path, src))


def test_missing_allowlisted_file_fails(tmp_path):
    make_tree(tmp_path)
    src, _ = first_pair()
    (tmp_path / src).unlink()
    assert any("missing" in e for e in errs_for(tmp_path, src))


def test_no_frontmatter_fails(tmp_path):
    make_tree(tmp_path)
    src, _ = first_pair()
    (tmp_path / src).write_text("body only\n", encoding="utf-8")
    assert any("no YAML frontmatter" in e for e in errs_for(tmp_path, src))


def test_uncomposable_frontmatter_fails_closed(tmp_path):
    make_tree(tmp_path)
    src, _ = first_pair()
    p = tmp_path / src
    p.write_text(p.read_text(encoding="utf-8").replace(
        PINNED_TOOLS_LINE, "tools: [unclosed"), encoding="utf-8")
    assert any("does not compose" in e for e in errs_for(tmp_path, src))


def test_body_tools_line_does_not_satisfy_pin(tmp_path):
    # The pinned line must live in FRONTMATTER; a body mention is not a grant.
    make_tree(tmp_path)
    src, _ = first_pair()
    p = tmp_path / src
    p.write_text(p.read_text(encoding="utf-8").replace(
        PINNED_TOOLS_LINE + "\n", "") + f"\n{PINNED_TOOLS_LINE}\n",
        encoding="utf-8")
    assert any("no `tools:` key" in e for e in errs_for(tmp_path, src))


# --- invariant 2: Bucket A frontmatter must not declare Bash --------------------

def bash_fixture(tmp_path, frontmatter_body: str) -> list[str]:
    """Write a Bucket A agent with the given frontmatter body and return the
    errors mentioning it."""
    eic = tmp_path / "academic-paper-reviewer/agents/eic_agent.md"
    eic.write_text(f"---\n{frontmatter_body}\n---\n\nbody\n",
                   encoding="utf-8")
    return [e for e in check(tmp_path) if "eic_agent" in e]


def test_bucket_a_agent_advertising_bash_fails(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, Bash")
    assert any("declares Bash" in e for e in errs)


def test_quoted_string_value_bash_fails(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, 'name: eic_agent\ntools: "Read, Bash"')
    assert any("declares Bash" in e for e in errs)


def test_quoted_name_with_bash_fails(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, 'name: "eic_agent"\ntools: Read, Bash')
    assert any("declares Bash" in e for e in errs)


def test_flow_list_bash_fails(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: [Read, Bash]")
    assert any("declares Bash" in e for e in errs)


def test_block_list_bash_fails(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path,
                        "name: eic_agent\ntools:\n  - Read\n  - Bash")
    assert any("declares Bash" in e for e in errs)


def test_inline_comment_bash_fails(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path,
                        "name: eic_agent\ntools: Read, Bash # reviewed")
    assert any("declares Bash" in e for e in errs)


def test_permission_specifier_bash_fails(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, Bash(git:*)")
    assert any("declares Bash" in e for e in errs)


def test_bashoutput_is_a_different_tool_and_passes(tmp_path):
    # Exact base-name match: BashOutput grants no shell; a prefix match
    # would false-fire on it.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, BashOutput")
    assert errs == []


def test_lowercase_bash_is_a_different_tool_and_passes(tmp_path):
    # Tool names are exact: `bash` (lowercase) is not the shell-granting
    # `Bash` tool. Case-folding would false-fire on a legitimate value.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, bash")
    assert errs == []


def test_bom_padded_bash_fails(tmp_path):
    # #524 r9: a BOM (U+FEFF) is NOT stripped by str.strip(), so `﻿Bash`
    # would survive as a token != `Bash` and slip the membership test.
    # _fold drops Cf format chars so it collapses back to Bash.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, ﻿Bash﻿")
    assert any("declares Bash" in e for e in errs)


def test_zero_width_space_padded_bash_fails(tmp_path):
    # #524 r9: zero-width space (U+200B) — a Cf format char str.strip() leaves.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, ​Bash​")
    assert any("declares Bash" in e for e in errs)


def test_zero_width_non_joiner_padded_bash_fails(tmp_path):
    # #524 r9: zero-width non-joiner (U+200C) — another Cf format char.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, ‌Bash‌")
    assert any("declares Bash" in e for e in errs)


def test_fullwidth_bash_fails(tmp_path):
    # #524 r9: fullwidth "Ｂａｓｈ" (U+FF22 etc.) is a compatibility homoglyph;
    # NFKC in _fold folds it onto ASCII "Bash".
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, Ｂａｓｈ")
    assert any("declares Bash" in e for e in errs)


def test_fullwidth_paren_permission_specifier_bash_fails(tmp_path):
    # #524 r10: fullwidth parens U+FF08/U+FF09 in a permission specifier. The
    # ASCII "(" split misses them, so folding must happen BEFORE the split —
    # otherwise NFKC leaves the whole "Bash（git:*）" as one token != "Bash".
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, Bash（git:*）")
    assert any("declares Bash" in e for e in errs)


def test_fullwidth_bash_and_paren_specifier_fails(tmp_path):
    # #524 r10: both the tool name AND its parens fullwidth — the whole thing
    # must fold to ASCII "Bash" and be caught.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, Ｂａｓｈ（git:*）")
    assert any("declares Bash" in e for e in errs)


def test_fullwidth_comma_separated_bash_fails(tmp_path):
    # #524 r11: fullwidth comma U+FF0C. split(",") misses it, so "Read，Bash"
    # stays one token — folding must happen on the WHOLE value BEFORE the comma
    # split (the r11 corollary of r10), or "Read，Bash" folds to "Read,Bash"
    # (one token != "Bash") and slips.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read，Bash")
    assert any("declares Bash" in e for e in errs)


def test_small_comma_separated_bash_fails(tmp_path):
    # #524 r11: small comma U+FE50 — another compatibility comma NFKC folds to
    # ASCII "," only if the fold precedes the split.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read﹐Bash")
    assert any("declares Bash" in e for e in errs)


def test_fullwidth_comma_and_name_and_paren_bash_fails(tmp_path):
    # #524 r11: fullwidth comma + fullwidth name + fullwidth parens all at once
    # — the whole-value fold must reduce it to Read + Bash.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read，Ｂａｓｈ（git:*）")
    assert any("declares Bash" in e for e in errs)


def test_nfkc_stable_alt_separator_is_not_a_bash_grant(tmp_path):
    # #524 r12 (convergence boundary, documented as a NON-bug): an alternate
    # separator that NFKC does NOT fold to ASCII "," — e.g. an ideographic comma
    # U+3001, an Arabic comma U+060C, or a semicolon — keeps "Read、Bash" as ONE
    # token for EVERYONE: the string is a single YAML scalar, this lint splits
    # only on ASCII "," (and any NFKC-normalizing consumer would too), so no
    # consumer extracts a bare "Bash" from it and no shell is granted. The
    # fullwidth comma/paren cases fail (above) precisely because NFKC DOES fold
    # them into the ASCII separators the split honors; these do not. Not
    # flagging this is correct — flagging it would be a false positive a future
    # maintainer might "fix" by over-broadening the separator set.
    make_tree(tmp_path)
    assert bash_fixture(tmp_path, "name: eic_agent\ntools: Read、Bash") == []
    assert bash_fixture(tmp_path, "name: eic_agent\ntools: Read؍Bash") == []
    assert bash_fixture(tmp_path, "name: eic_agent\ntools: Read;Bash") == []


def test_bom_canonical_allowlist_value_still_fires_byte_witness(tmp_path):
    # #524 r9 companion: folding the SEMANTIC check must not weaken invariant 1.
    # A plugin agent whose tools value is BOM-padded canonical now folds to the
    # five canonical tools semantically — but the additive byte-witness must
    # STILL fire (the raw line is not byte-equal to PINNED_TOOLS_LINE).
    make_tree(tmp_path)
    target = tmp_path / sorted(ALLOWLISTED_FILES)[0]
    target.write_text(
        "---\nname: report_compiler_agent\n"
        "tools: ﻿Read﻿, Write, Edit, Grep, Glob\n---\n\nbody\n",
        encoding="utf-8")
    errs = [e for e in check(tmp_path) if "byte-equal" in e]
    assert errs, "byte-witness must still fire on an invisible-char canonical value"


def test_repeated_identical_scalars_are_not_aliases(tmp_path):
    # False-positive guard for the alias-by-shared-identity detector:
    # `yaml.compose` does NOT intern identical scalar values (each gets a
    # distinct node), so a clean file repeating a value must still pass.
    make_tree(tmp_path)
    errs = bash_fixture(
        tmp_path,
        "name: eic_agent\ntools: Read, Read, Grep\ndescription: Read Read")
    assert errs == []


def test_non_string_manifest_agent_key_still_reconciles(tmp_path):
    # A manifest whose `agents` mapping carries a non-string key alongside
    # the real ones must not break reconciliation of the real Bucket A agent.
    make_tree(tmp_path)
    (tmp_path / MANIFEST).write_text(
        '{"agents": {"123": {}, "report_compiler_agent": {}, '
        '"research_architect_agent": {}, "synthesis_agent": {}, '
        '"eic_agent": {}}}', encoding="utf-8")
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Read, Bash")
    assert any("declares Bash" in e for e in errs)


def test_nested_list_member_fails_closed(tmp_path):
    # A non-scalar list member is an unrecognized shape — must not be
    # silently stringified into a passing value (codex round-2 P2).
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: [Read, [Bash]]")
    assert any("unrecognized shape" in e for e in errs)


def test_mapping_list_member_fails_closed(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: [Read, {Bash: 1}]")
    assert any("unrecognized shape" in e for e in errs)


def test_mapping_tools_value_fails_closed(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: {Read: yes}")
    assert any("unrecognized shape" in e for e in errs)


def test_typed_scalar_tools_on_bucket_a_fails_closed(tmp_path):
    # A Bucket A `tools` that is an int/bool/null/timestamp scalar is an
    # unrecognized shape — the reconciliation cannot confirm it excludes
    # Bash, so it fails closed (scoped to Bucket A; out-of-scope agents with
    # a nonsense tools value are not this lint's concern).
    make_tree(tmp_path)
    for val in ("5", "true", "null", "2020-01-01"):
        errs = bash_fixture(tmp_path, f"name: eic_agent\ntools: {val}")
        assert any("unrecognized shape" in e for e in errs), val


def test_bare_bash_scalar_fails_closed(tmp_path):
    # Bash as the whole scalar value (not a list member) still resolves to
    # the `Bash` base name.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, "name: eic_agent\ntools: Bash")
    assert any("declares Bash" in e for e in errs)


def test_merge_nested_in_sequence_fails_closed(tmp_path):
    # The merge/alias walk must recurse into sequence values, not only
    # mapping values — a `<<`/alias buried in a list still fails closed.
    make_tree(tmp_path)
    errs = bash_fixture(
        tmp_path,
        "name: eic_agent\n_a: &a {tools: [Read, Bash]}\nx: [<<, *a]")
    assert any("merge key / alias" in e for e in errs)


def test_merge_tagged_complex_key_fails_closed(tmp_path):
    # codex round-5 P1: the merge tag can land on a non-scalar KEY
    # (`? !!merge [x]` is a merge-tagged sequence key). safe_load applies
    # merge semantics and injects `tools: [Read, Bash]`; the tag check must
    # fire on any node type, not just scalars.
    make_tree(tmp_path)
    for key in ("!!merge [x]", "!!merge {a: 1}"):
        errs = bash_fixture(
            tmp_path,
            f"name: eic_agent\n? {key}\n: {{tools: [Read, Bash]}}")
        assert any("merge key / alias" in e for e in errs), key


def test_deeply_nested_frontmatter_does_not_crash(tmp_path):
    # codex round-5 P2: a pathologically deep flow sequence must never crash
    # the lint with an unhandled traceback — check() must RETURN. Whether a
    # given depth trips RecursionError is Python-version-dependent (3.14
    # tolerates far deeper nesting than 3.11), so the version-independent
    # contract is "returns, does not raise", not "fails at depth N". A
    # depth that does NOT recurse past the limit is a harmless (if odd) file
    # and legitimately passes; the fail-closed path is exercised
    # deterministically by test_recursion_error_fails_closed below.
    make_tree(tmp_path)
    deep = "name: eic_agent\ntools: Read, Grep\nx: " + "[" * 400 + "]" * 400
    errs = bash_fixture(tmp_path, deep)  # must not raise
    assert isinstance(errs, list)


def test_compose_recursion_error_maps_to_none():
    # _mapping_node must turn a RecursionError from yaml.compose into None
    # (→ the caller emits a fail-closed "does not compose" error), not let it
    # escape. Driven by monkeypatching compose to raise, so it is
    # deterministic and Python-version-independent (unlike relying on a
    # specific nesting depth tripping the interpreter's own limit).
    import check_tools_allowlist as m

    def boom(*a, **k):
        raise RecursionError("maximum recursion depth exceeded")

    orig = m.yaml.compose
    m.yaml.compose = boom
    try:
        assert m._mapping_node("name: x\n") is None
    finally:
        m.yaml.compose = orig


def test_walk_recursion_error_fails_closed():
    # _uses_merge_or_alias must treat a RecursionError in the walk as True
    # (fail closed — the tree could not be proven clean). Forced
    # deterministically: a MappingNode whose `.value` is an iterable that
    # raises RecursionError on iteration (standing in for a walk that
    # recurses past the interpreter limit), so the test does not depend on
    # any Python-version recursion depth.
    import check_tools_allowlist as m
    import yaml as y

    class ExplodingValue:
        def __iter__(self):
            raise RecursionError("maximum recursion depth exceeded")

    node = y.MappingNode("tag:yaml.org,2002:map", [])
    node.value = ExplodingValue()
    assert m._uses_merge_or_alias(node) is True


def test_uncomposable_bucket_a_frontmatter_fails_closed(tmp_path):
    # Frontmatter that won't compose can't be cleared by name — fail closed
    # (codex round-2 P2: a quoted/indented name under malformed YAML must
    # not be silently skipped).
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path, 'name: "eic_agent"\ndescription: [unclosed')
    assert any("does not compose" in e for e in errs)


def test_merge_key_injecting_bash_fails_closed(tmp_path):
    # codex round-3: `<<: *base` merges a `tools: [..., Bash]` the
    # duplicate-preserving node scan never sees (the top level only shows a
    # literal `<<` key). The composed node tree carries the `merge` tag —
    # detected there, not by text, and failed closed.
    make_tree(tmp_path)
    errs = bash_fixture(
        tmp_path,
        "_base: &b {tools: [Read, Bash]}\n<<: *b\nname: eic_agent")
    assert any("merge key / alias" in e for e in errs)


def test_flow_merge_with_quoted_hash_key_fails_closed(tmp_path):
    # codex round-3 P1-2: a `#` inside a quoted flow key fooled the old
    # text-scan comment strip; the node-tree merge-tag detector is immune.
    make_tree(tmp_path)
    eic = tmp_path / "academic-paper-reviewer/agents/eic_agent.md"
    eic.write_text(
        '---\n{ "x#": y, name: eic_agent, <<: &b {tools: "Read, Bash"} }\n'
        "---\n\nbody\n", encoding="utf-8")
    assert any("merge key / alias" in e
               for e in check(tmp_path) if "eic_agent" in e)


def test_alias_value_bash_fails_closed(tmp_path):
    # An alias makes `tools` share another node's value — the node tree sees
    # the SAME node object twice (shared identity). Fail closed.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path,
                        "name: eic_agent\n_x: &a [Read, Bash]\ntools: *a")
    assert any("merge key / alias" in e for e in errs)


def test_ampersand_in_quoted_value_is_not_an_anchor(tmp_path):
    # False-positive guard: a literal `&`/`*` inside a quoted scalar is not a
    # YAML anchor/alias (the node carries a plain str value) and must NOT
    # fail the clean file — the node-tree detector, unlike a text scan, sees
    # this correctly.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path,
                        'name: eic_agent\ndescription: "A & B, 3 * 4"\n'
                        "tools: Read, Grep")
    assert errs == []


def test_allowlisted_file_with_alias_fails_closed(tmp_path):
    # Invariant 1 also fails closed on aliases (a `<<`/alias could inject the
    # pinned value from elsewhere, defeating the value lock).
    make_tree(tmp_path)
    src, _ = first_pair()
    p = tmp_path / src
    p.write_text(
        "---\nname: research_architect_agent\n_t: &t Read, Write, Edit, "
        "Grep, Glob\ntools: *t\n---\n\nbody\n", encoding="utf-8")
    assert any("merge key / alias" in e for e in errs_for(tmp_path, src))


def test_indented_fence_in_block_scalar_does_not_truncate(tmp_path):
    # codex round-3 P1-1: an indented `---` inside a `description: |` block
    # scalar must NOT be read as the closing fence — doing so truncated the
    # block and hid a Bucket A `name` + `tools: Read, Bash` below it. Only a
    # column-0 `---` closes frontmatter.
    make_tree(tmp_path)
    eic = tmp_path / "academic-paper-reviewer/agents/eic_agent.md"
    eic.write_text(
        "---\ndescription: |\n  ---\nname: eic_agent\ntools: Read, Bash\n"
        "---\n\nbody\n", encoding="utf-8")
    assert any("declares Bash" in e
               for e in check(tmp_path) if "eic_agent" in e)


def test_bom_prefixed_bucket_a_bash_fails_closed(tmp_path):
    # A leading UTF-8 BOM makes `﻿---` fail the column-0 fence match, so
    # the file would read as frontmatter-less and skip invariant 2 — while a
    # real YAML reader strips the BOM and sees `tools: Read, Bash`. _read_raw
    # strips the BOM so the two agree; the smuggled Bash fails closed.
    make_tree(tmp_path)
    eic = tmp_path / "academic-paper-reviewer/agents/eic_agent.md"
    eic.write_bytes(
        "﻿---\nname: eic_agent\ntools: Read, Bash\n---\n\nbody\n"
        .encode("utf-8"))
    assert any("declares Bash" in e
               for e in check(tmp_path) if "eic_agent" in e)


def test_bom_prefixed_clean_file_passes(tmp_path):
    make_tree(tmp_path)
    eic = tmp_path / "academic-paper-reviewer/agents/eic_agent.md"
    eic.write_bytes(
        "﻿---\nname: eic_agent\ntools: Read, Grep\n---\n\nbody\n"
        .encode("utf-8"))
    assert not [e for e in check(tmp_path) if "eic_agent" in e]


def test_block_scalar_containing_tools_text_no_false_positive(tmp_path):
    # codex round-6 P2: a `description: |` block scalar whose body contains a
    # `tools: ...` line must NOT trip the byte-witness — that line is not the
    # `tools` KEY. The witness is anchored to the composed key's own line, so
    # a clean allowlisted file with such documentation passes.
    make_tree(tmp_path)
    src, _ = first_pair()
    p = tmp_path / src
    p.write_text(
        f"---\nname: research_architect_agent\n{PINNED_TOOLS_LINE}\n"
        "description: |\n  tools: this is documentation, not the key\n"
        "---\n\nbody\n", encoding="utf-8")
    assert errs_for(tmp_path, src) == []


def test_block_scalar_containing_triple_dash_is_not_a_fence(tmp_path):
    # The companion false-positive: a block scalar that legitimately contains
    # an indented `---` line must still parse the real keys below it and PASS
    # a clean file.
    make_tree(tmp_path)
    errs = bash_fixture(
        tmp_path,
        "name: eic_agent\ndescription: |\n  intro\n  --- not a fence\n"
        "tools: Read, Grep")
    assert errs == []


def test_escaped_tools_key_fires_byte_witness_on_allowlisted(tmp_path):
    # codex round-3 P2-1: replacing the pinned line with an escaped-key
    # spelling makes raw_lines empty; the byte witness must still fire
    # (require the verbatim pinned line), and the semantic check fires too.
    make_tree(tmp_path)
    src, _ = first_pair()
    p = tmp_path / src
    p.write_text(
        '---\nname: research_architect_agent\n"tool\\u0073": Read, Write, '
        "Edit, Grep, Glob\n---\n\nbody\n", encoding="utf-8")
    assert any("not byte-equal" in e for e in errs_for(tmp_path, src))


def test_duplicate_tools_on_bucket_a_fails_closed(tmp_path):
    # codex round-4 P2: last-wins would pick `Read, Grep` and pass, but a
    # first-wins parser would grant Bash. Duplicate-key resolution is
    # parser-dependent, so invariant 2 fails closed (as invariant 1 does).
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path,
                        "name: eic_agent\ntools: Read, Bash\ntools: Read, Grep")
    assert any("`tools` keys" in e and "parser-dependent" in e for e in errs)


def test_duplicate_tools_first_wins_bash_fails_closed(tmp_path):
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path,
                        "name: eic_agent\ntools: Read, Grep\ntools: Read, Bash")
    assert any("`tools` keys" in e and "parser-dependent" in e for e in errs)


def test_duplicate_name_one_bucket_a_fails_closed(tmp_path):
    # A duplicate `name` where one resolution is Bucket A: a non-Bucket-A
    # last-wins name would skip the file, hiding a Bucket A first-wins name +
    # Bash. Fail closed.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path,
                        "name: safe_agent\nname: eic_agent\ntools: Read, Bash")
    assert any("`name` keys" in e and "parser-dependent" in e for e in errs)


def test_duplicate_name_neither_bucket_a_passes(tmp_path):
    # If NO resolution is a Bucket A name, the file is out of scope whichever
    # way a parser resolves it — no need to fail closed.
    make_tree(tmp_path)
    errs = bash_fixture(tmp_path,
                        "name: safe_one\nname: safe_two\ntools: Read, Bash")
    assert errs == []


def test_nested_bucket_a_agent_declaring_bash_fails_closed(tmp_path):
    # codex round-7 P1: invariant 2 must reach nested agent files (rglob, not
    # glob) — the runtime guard keys on `name` regardless of path, so a
    # `agents/subdir/x.md` with a Bucket A name + Bash is a real exposure.
    make_tree(tmp_path)
    nested = (tmp_path
              / "academic-paper-reviewer/agents/subdir/eic_agent.md")
    nested.parent.mkdir(parents=True, exist_ok=True)
    nested.write_text("---\nname: eic_agent\ntools: Read, Bash\n---\nbody\n",
                      encoding="utf-8")
    assert any("declares Bash" in e for e in check(tmp_path)
               if "eic_agent" in e)


def test_directory_symlink_under_agent_dir_fails_closed(tmp_path):
    # codex round-8 P1: rglob does not descend into directory symlinks, so a
    # tracked `agents/nested -> ../payload` could hide a Bucket A agent
    # declaring Bash. Fail closed on the symlink itself.
    make_tree(tmp_path)
    agents = tmp_path / "academic-paper-reviewer/agents"
    agents.mkdir(parents=True, exist_ok=True)
    payload = tmp_path / "payload"
    payload.mkdir()
    (payload / "eic_agent.md").write_text(
        "---\nname: eic_agent\ntools: Read, Bash\n---\nbody\n",
        encoding="utf-8")
    try:
        (agents / "nested").symlink_to(payload, target_is_directory=True)
    except (OSError, NotImplementedError):
        import pytest
        pytest.skip("symlinks unavailable on this platform")
    assert any("directory symlink" in e for e in check(tmp_path))


def test_bare_cr_frontmatter_bucket_a_bash_fails_closed(tmp_path):
    # codex round-8 P1: bare `\r` (old-Mac) is a YAML line break, but a
    # split-on-`\n` fence scan reads the file as frontmatter-less and skips
    # it, hiding a Bucket A `tools: Bash`. splitlines() recognizes bare CR so
    # the declaration is caught.
    make_tree(tmp_path)
    eic = tmp_path / "academic-paper-reviewer/agents/eic_agent.md"
    eic.write_bytes(
        "---\rname: eic_agent\rtools: Read, Bash\r---\r".encode("utf-8"))
    assert any("declares Bash" in e for e in check(tmp_path)
               if "eic_agent" in e)


def test_unicode_line_break_before_tools_no_false_positive(tmp_path):
    # codex round-7 P2: YAML counts NEL (U+0085) / LS (U+2028) / PS (U+2029)
    # as line breaks but str.split("\n") does not. Placed in a quoted value
    # BEFORE the tools key, they shift YAML's start_mark.line off the
    # split("\n") index, so a line-based anchor would read the WRONG physical
    # line. The byte witness anchors via start_mark.index (byte offset), so
    # the clean allowlisted file is not falsely rejected and no non-verbatim
    # key line slips past.
    make_tree(tmp_path)
    src, _ = first_pair()
    p = tmp_path / src
    p.write_text(
        f"---\nname: research_architect_agent\n"
        'description: "ab c d"\n'
        f"{PINNED_TOOLS_LINE}\n---\n\nbody\n",
        encoding="utf-8")
    assert errs_for(tmp_path, src) == []



def test_non_bucket_a_agent_with_bash_passes(tmp_path):
    # Baked into the green fixture (pipeline_orchestrator_agent declares
    # Bash); assert it raises nothing on its own.
    make_tree(tmp_path)
    assert check(tmp_path) == []


def test_bucket_a_agent_without_tools_key_passes(tmp_path):
    # eic_agent in the green fixture has no tools key — inherit is fine;
    # the runtime guard still fences it.
    make_tree(tmp_path)
    assert not [e for e in check(tmp_path) if "eic_agent" in e]


def test_missing_manifest_fails_closed(tmp_path):
    make_tree(tmp_path)
    (tmp_path / MANIFEST).unlink()
    errs = check(tmp_path)
    assert any(MANIFEST in e and "failing closed" in e for e in errs)


def test_unparseable_manifest_fails_closed(tmp_path):
    make_tree(tmp_path)
    (tmp_path / MANIFEST).write_text("{not json", encoding="utf-8")
    errs = check(tmp_path)
    assert any(MANIFEST in e and "failing closed" in e for e in errs)


def test_valid_json_non_object_manifest_fails_closed(tmp_path):
    # A JSON array parses fine but has no `agents` mapping — must be a
    # curated diagnostic, not a traceback (codex round-1 P2).
    make_tree(tmp_path)
    (tmp_path / MANIFEST).write_text("[]", encoding="utf-8")
    errs = check(tmp_path)
    assert any(MANIFEST in e and "no `agents` mapping" in e for e in errs)


def test_non_mapping_agents_value_fails_closed(tmp_path):
    make_tree(tmp_path)
    (tmp_path / MANIFEST).write_text('{"agents": []}', encoding="utf-8")
    errs = check(tmp_path)
    assert any(MANIFEST in e and "no `agents` mapping" in e for e in errs)


# --- lock shape ------------------------------------------------------------------

def test_pinned_line_is_the_frozen_514_value():
    # Editing the allowlist is a deliberate security-surface change: it must
    # touch this lint in the same commit. This test is the second witness.
    assert PINNED_TOOLS_LINE == "tools: Read, Write, Edit, Grep, Glob"
    assert CANONICAL_TOOLS == ("Read", "Write", "Edit", "Grep", "Glob")


def test_allowlisted_files_are_the_three_pairs():
    assert set(ALLOWLISTED_FILES) == {
        "deep-research/agents/report_compiler_agent.md",
        "deep-research/agents/research_architect_agent.md",
        "deep-research/agents/synthesis_agent.md",
        "agents/report_compiler_agent.md",
        "agents/research_architect_agent.md",
        "agents/synthesis_agent.md",
    }
