Files
imbad0202__academic-researc…/scripts/check_v3_10_134_write_scope.py
T
Edward Cheng-I Wu e0f0c7b54a fix: materialize agents/ symlinks as real copies + mirror-sync lint (#413) (#421)
The three agents/ files were relative symlinks into deep-research/agents/
(v3.7.0 Phase 2.1); on Windows checkouts without core.symlinks and in
zip-download installs they materialise as one-line text files, silently
breaking the three plugin agents (#413, external audit).

Materialized as real byte-identical copies. New CI lint
scripts/check_agents_mirror_sync.py takes over the single-source guarantee
(hard-pinned mirror roster: set equality + regular-file-never-symlink +
byte-equality; symlink check runs before byte-equality). The two
symlink-dependent lints adapt: version-consistency invariant 8 excludes the
mirror dir from the agent count; write-scope I5 maps direct children of
root agents/ by name to their deep-research sources (codex P2: nested
agents/sub/agents/ files do NOT remap — fail-open guard, pinned by a
negative test). skills/ directory symlinks deliberately unchanged.

10 new mirror-sync tests, 3 mutations killed, 6 tests adapted/added across
the two existing suites; lint + pytest companion wired into CI. Dual-track
review: codex (1 P2, adopted) + security review (0 findings).

Closes #413

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:28:42 +08:00

287 lines
14 KiB
Python

#!/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/<same name>`. 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())