#!/usr/bin/env python3 """Static lint for the ARS #134 Slice 1 write-scope guard — three-way name cross-check. Spec: docs/design/2026-06-01-ars-134-conductor-rescope-deterministic-write-guard-spec.md (§3.4) Classification: docs/design/2026-05-18-ars-v3.9.2-agent-phase-classification.md THE FAIL-OPEN GUARD. The write-scope hook keys on the subagent's `agent_type`, which equals the agent's frontmatter `name`. If the manifest key, the classification-table Bucket A roster, and the on-disk frontmatter `name` ever drift apart (a rename, a manifest typo, a table edit), the hook silently FAILS OPEN for that agent — it would treat the agent as "not in the manifest" and allow every write. This lint catches that drift before it can happen, mirroring check_v3_9_2_phase_boundary.py's 23/16 split. Three invariants: I1 — Roster size. The Bucket A roster is exactly 23 agents (16 B/C/D exempt = 39 records / 38 unique names per the classification table; the manifest covers the 23 Bucket A names only). I2 — Three-way name set equality. The set of: (a) Bucket A agent file paths (the classification-table roster, single-sourced here as BUCKET_A_AGENT_FILES — identical to check_v3_9_2_phase_boundary.py), (b) manifest keys (scripts/ars_phase_scope_manifest.json -> agents), (c) on-disk frontmatter `name` fields read from each (a) file, must be IDENTICAL. Any element in one but not the others is a fail-open risk. I3 — Bucket B/C/D exclusion. None of the 16 exempt agents' frontmatter `name` may appear as a manifest key (a Bucket B/C/D agent in the manifest would impose a single-phase fence on a legitimately multi-phase agent). I4 — Manifest shape. Every manifest entry carries bucket == "A", a non-empty allowed_write_globs list, and a phase label. Exit codes: 0 on pass, 1 on any failure. """ from __future__ import annotations import json import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent MANIFEST_PATH = REPO_ROOT / "scripts" / "ars_phase_scope_manifest.json" # Single-sourced Bucket A roster (the classification-table Bucket A rows). IDENTICAL to # check_v3_9_2_phase_boundary.py's BUCKET_A_AGENTS — kept in lockstep by I1 (count) + # the symmetry of the two lints. If v3.9.x adds/removes a Bucket A agent, BOTH lists move. BUCKET_A_AGENT_FILES = [ # deep-research/agents/ (10) "deep-research/agents/research_question_agent.md", "deep-research/agents/research_architect_agent.md", "deep-research/agents/bibliography_agent.md", "deep-research/agents/source_verification_agent.md", "deep-research/agents/synthesis_agent.md", "deep-research/agents/timeline_extraction_agent.md", "deep-research/agents/editor_in_chief_agent.md", "deep-research/agents/ethics_review_agent.md", "deep-research/agents/risk_of_bias_agent.md", "deep-research/agents/meta_analysis_agent.md", # academic-paper/agents/ (7) "academic-paper/agents/literature_strategist_agent.md", "academic-paper/agents/structure_architect_agent.md", "academic-paper/agents/draft_writer_agent.md", "academic-paper/agents/citation_compliance_agent.md", "academic-paper/agents/abstract_bilingual_agent.md", "academic-paper/agents/peer_reviewer_agent.md", "academic-paper/agents/formatter_agent.md", # academic-paper-reviewer/agents/ (6) "academic-paper-reviewer/agents/eic_agent.md", "academic-paper-reviewer/agents/methodology_reviewer_agent.md", "academic-paper-reviewer/agents/domain_reviewer_agent.md", "academic-paper-reviewer/agents/perspective_reviewer_agent.md", "academic-paper-reviewer/agents/devils_advocate_reviewer_agent.md", "academic-paper-reviewer/agents/editorial_synthesizer_agent.md", ] # The 16 Bucket B/C/D agents that MUST NOT appear in the manifest. BUCKET_BCD_AGENT_FILES = [ "deep-research/agents/devils_advocate_agent.md", "deep-research/agents/report_compiler_agent.md", "academic-paper/agents/argument_builder_agent.md", "academic-paper/agents/visualization_agent.md", "deep-research/agents/socratic_mentor_agent.md", "academic-paper/agents/socratic_mentor_agent.md", "deep-research/agents/monitoring_agent.md", "academic-paper/agents/revision_coach_agent.md", "academic-pipeline/agents/integrity_verification_agent.md", "academic-pipeline/agents/collaboration_depth_agent.md", "academic-pipeline/agents/claim_ref_alignment_audit_agent.md", "shared/agents/compliance_agent.md", "academic-paper/agents/intake_agent.md", "academic-pipeline/agents/pipeline_orchestrator_agent.md", "academic-pipeline/agents/state_tracker_agent.md", "academic-paper-reviewer/agents/field_analyst_agent.md", ] _NAME_RE = re.compile(r"^name:\s*(.+?)\s*$", re.MULTILINE) # Line-anchored fence so a literal `---` inside a description value can't split the # block early (^---$ on its own line, tolerating trailing whitespace). _FENCE_RE = re.compile(r"^---[ \t]*$", re.MULTILINE) def read_frontmatter_name(rel_path: str) -> str | None: """Read the frontmatter `name:` field from the first YAML block of an agent file. Deliberately NOT `_skill_lint.parse_frontmatter`: this lint needs the precise line- anchored two-fence semantics below (a `---` inside a description value must NOT split the block, and a `name:` in prose must NOT masquerade as the binding — a review finding), and a missing/broken fence pair must surface as a drift (return None) rather than being parsed leniently. Keeping it stdlib-only also avoids coupling this fail-open guard to the skill-lint module / its pyyaml dependency. The block is flat key:value, so a regex suffices. """ path = REPO_ROOT / rel_path if not path.exists(): return None text = path.read_text(encoding="utf-8") fences = list(_FENCE_RE.finditer(text)) if len(fences) < 2: # No well-formed frontmatter fence pair. Return None (fail loud upstream) rather # than scanning the body — a `name:` in prose must NOT masquerade as the binding # (review finding). A missing/broken frontmatter is itself a drift the lint reports. return None block = text[fences[0].end():fences[1].start()] m = _NAME_RE.search(block) return m.group(1).strip().strip('"').strip("'") if m else None def load_manifest() -> dict: return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) def load_manifest_keys() -> set[str]: return set(load_manifest().get("agents", {}).keys()) def run_checks() -> list[str]: errors: list[str] = [] # I1 — roster size if len(BUCKET_A_AGENT_FILES) != 23: errors.append( f"I1: BUCKET_A_AGENT_FILES has {len(BUCKET_A_AGENT_FILES)} entries, expected 23 " "(classification doc: A=23). Update in lockstep with check_v3_9_2_phase_boundary.py." ) if len(BUCKET_BCD_AGENT_FILES) != 16: errors.append( f"I1: BUCKET_BCD_AGENT_FILES has {len(BUCKET_BCD_AGENT_FILES)} entries, expected 16." ) # I5 — roster exhaustiveness (NON-VACUOUS guard). Glob the actual filesystem for every # agent definition; assert the two hard-coded rosters together cover ALL of them. A new # agent file added without updating the rosters + manifest would otherwise be silently # treated as unconstrained (fail-open) by the hook — this catches that at lint time. # # `**/agents/*.md` (not `*/agents/*.md`) so an agent dir nested deeper than one level is # also covered — a one-level glob would silently miss a future `skill/sub/agents/x.md` # and reopen the fail-open hole (caught in review). Two real-layout subtleties # this must handle WITHOUT false-positives: # * The plugin-root `agents/` MIRROR dir (Claude Code plugin convention) holds real # byte-identical COPIES of per-skill agent files — copies since #413 (relative # symlinks broke Windows checkouts / zip installs); check_agents_mirror_sync.py # pins the byte-equality. A mirror file introduces no new agent name: map it BY # NAME to its `deep-research/agents/...` source and check THAT against the roster. # A name with no rostered source still falls through to undeclared — the mapping # is not an allowlist for anything dropped into `agents/`. LOAD-BEARING # assumption: every mirror sources from `deep-research/agents/`. If a # mirror of an agent living elsewhere (e.g. `academic-paper/agents/`) is ever # added, this rule and the MIRRORS roster in check_agents_mirror_sync.py would # silently disagree — extend BOTH in lockstep (deliberately re-derived here, not # imported: one-lint-one-invariant; the disagreement today fails CLOSED, pinned # by the non-deep-research-source negative test). # * Anything else compares on the CANONICAL (resolved) workspace-relative path: a # leftover symlink resolves to its rostered target (the pre-#413 shape, kept for # generality), while a genuinely NEW standalone .md at any depth resolves to # itself, is absent from the roster, and is flagged. That is exactly the # fail-open case we want to catch. declared = set(BUCKET_A_AGENT_FILES) | set(BUCKET_BCD_AGENT_FILES) undeclared = [] for md in REPO_ROOT.glob("**/agents/*.md"): if ".git" in md.parts: continue # .as_posix() so the comparison uses `/` on every OS (the rosters use `/`). rel = md.relative_to(REPO_ROOT).as_posix() if rel in declared: continue # directly rostered — skip the alias mapping (the common case) # Not directly rostered: map the two alias shapes documented above before # deciding it is undeclared. if rel.startswith("agents/") and rel.count("/") == 1: # Plugin-root mirror (#413) — DIRECT children only. A nested # agents/sub/agents/x.md is NOT a mirror (codex P2: remapping it # would fail open when its name collides with a rostered agent); # it falls through to the resolve path and flags as undeclared. canon = f"deep-research/agents/{md.name}" else: try: canon = md.resolve().relative_to(REPO_ROOT).as_posix() except ValueError: canon = rel # resolves outside the repo (unexpected) — compare on rel only if canon not in declared: undeclared.append(rel) undeclared = sorted(set(undeclared)) missing = sorted(f for f in declared if not (REPO_ROOT / f).exists()) if undeclared: errors.append( f"I5: agent file(s) {undeclared} exist on disk but are in NEITHER roster — the " "hook would fail OPEN for any new Bucket A agent among them. Add to the correct " "roster (+ the manifest if Bucket A)." ) if missing: errors.append( f"I5: roster lists file(s) {missing} that no longer exist on disk — stale entry." ) # Read on-disk names for the Bucket A roster. ondisk_names: set[str] = set() for rel in BUCKET_A_AGENT_FILES: nm = read_frontmatter_name(rel) if nm is None: errors.append(f"I2: could not read frontmatter `name:` from {rel}") else: ondisk_names.add(nm) manifest_keys = load_manifest_keys() # I2 — three-way set equality (on-disk names vs manifest keys) # (the classification-table roster is represented by BUCKET_A_AGENT_FILES; on-disk # names ARE the (a)↔(c) bridge, manifest_keys is (b).) if ondisk_names != manifest_keys: only_disk = sorted(ondisk_names - manifest_keys) only_manifest = sorted(manifest_keys - ondisk_names) if only_disk: errors.append( f"I2: agent frontmatter name(s) {only_disk} have NO manifest entry — the " "hook would fail OPEN for them (treat as unconstrained)." ) if only_manifest: errors.append( f"I2: manifest key(s) {only_manifest} match NO Bucket A agent frontmatter " "name — a typo/rename; the hook would never enforce them." ) # I3 — Bucket B/C/D exclusion bcd_names: set[str] = set() for rel in BUCKET_BCD_AGENT_FILES: nm = read_frontmatter_name(rel) if nm: bcd_names.add(nm) leaked = sorted(bcd_names & manifest_keys) if leaked: errors.append( f"I3: Bucket B/C/D agent name(s) {leaked} appear as manifest keys — a single-phase " "fence on a legitimately multi-phase/orthogonal agent. Remove from manifest." ) # I4 — manifest entry shape manifest = load_manifest() for key, entry in manifest.get("agents", {}).items(): if entry.get("bucket") != "A": errors.append(f"I4: manifest entry {key!r} has bucket={entry.get('bucket')!r}, expected 'A'.") globs = entry.get("allowed_write_globs") if not isinstance(globs, list) or not globs: errors.append(f"I4: manifest entry {key!r} has empty/invalid allowed_write_globs.") if not entry.get("phase"): errors.append(f"I4: manifest entry {key!r} missing a phase label.") return errors def main() -> int: errors = run_checks() if errors: print("FAIL — ARS #134 write-scope three-way name cross-check:") for e in errors: print(f" - {e}") return 1 print( f"PASS — {len(BUCKET_A_AGENT_FILES)} Bucket A agents: classification roster == " f"manifest keys == on-disk frontmatter names; {len(BUCKET_BCD_AGENT_FILES)} B/C/D " "exempt and absent from manifest." ) return 0 if __name__ == "__main__": sys.exit(main())