mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
e0f0c7b54a
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>
287 lines
14 KiB
Python
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())
|