mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
d22b1634d6
* feat(lint): #524 defrift lock pins the #514 tools-allowlist content
New scripts/check_tools_allowlist.py + 18 mutation tests, wired into
spec-consistency.yml and the unified pytest manifest. Invariant 1 pins
the exact `tools: Read, Write, Edit, Grep, Glob` frontmatter line on all
six #514 surfaces — the symmetric source+mirror Bash re-add that
previously passed every CI gate green (mirror-sync pins the pair, never
the value; the runtime guard keys on name, never frontmatter) now fails.
Invariant 2 reconciles frontmatter against the runtime channel: a Bucket
A agent must not advertise Bash in a tools key, fail-closed on a
missing/unparseable manifest. Placement of invariant 2 in this lint
follows the #524 proposal (considered and declined: a write-scope-lint
I6, to avoid touching the frozen I1-I5 suite for an optional guard).
Plus the #524 checklist docs: one-line allowlist mention in
docs/PERFORMANCE.md en/zh-TW § plugin agents and the announce script's
plugin-agents line, and the CHANGELOG [Unreleased] backfill covering
#514/#521 (retrospective, by @madtriceps), #523, and this change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-2 — YAML-semantic invariant 2, duplicate-key/CRLF pins, curated manifest diagnostics
Addresses the codex gpt-5.6-sol xhigh round-1 findings (P1 convergent
with the first-party security pass): invariant 2 now parses frontmatter
as YAML, so quoted values, quoted names, flow/block lists, inline
comments, and Bash(...) specifiers no longer evade the Bucket A
reconciliation (BashOutput stays unflagged — exact base-name match);
Bucket A files with unparseable YAML or unrecognized tools shapes fail
closed. Invariant 1 gains a broadened tools-key count (a quoted
"tools": duplicate cannot hide behind the pinned line) and a semantic
YAML belt (last-wins divergence fails even if a line trick slips the
count), and reads raw bytes so a symmetric LF->CRLF conversion is
drift. Valid-JSON non-object manifests get a curated fail-closed
diagnostic instead of a traceback. Suite grows 18 -> 31 with a failing
witness per new branch; CHANGELOG wording corrected accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-3 — node-tree merge/alias authority, col-0 fence, byte-witness tightening
Closes the codex gpt-5.6-sol xhigh round-3 P1s, both rooted in the same
lesson (text parsing is not a sound YAML authority):
- P1-1: an indented `---` inside a `description: |` block scalar was read
as the closing frontmatter fence, truncating the block and hiding a
Bucket A `name` + `tools: Read, Bash` below it. The fence is now a
column-0 `---` only (optional retained CR).
- P1-2: merge/alias detection was a text scan that a `#` inside a quoted
flow key and a `<<` inside a flow mapping both evaded. Replaced with a
composed-node-tree walk: a merge key carries the yaml merge tag on its
scalar node, an alias surfaces as the same node object reached twice
(shared identity). No text parsing.
Also: P2-1 byte-witness now requires the verbatim pinned line as the sole
raw tools line (an escaped/tagged/folded re-spelling that empties
raw_lines still fires). P2-2 (`!!binary`/`!!str` tag tricks) fixed by the
simplified `_scalar_py` — construct from the original composed node so its
resolved tag is honored (a `!!binary` list member becomes bytes, rejected
as a non-string shape; `!!str null` stays the string 'null'). Dropped the
redundant resolve()+reconstruct and the now-unused re import. Suite 39 ->
42 with witnesses for the indented fence, flow-merge-quoted-hash, escaped
duplicate key, and the block-scalar-triple-dash false-positive. CHANGELOG
corrected (duplicate-preserving, 42 tests, three review rounds).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 self-probe — strip leading BOM so a BOM-prefixed agent can't skip invariant 2
A UTF-8 BOM before the first `---` made the column-0 fence match fail, so
the file read as frontmatter-less and invariant 2 skipped it — while a real
YAML reader (and Claude Code) strips the BOM and sees the frontmatter. A
BOM-prefixed Bucket A agent could smuggle `tools: Read, Bash` through that
skip. _read_raw now strips a leading BOM so the lint and the real consumer
agree. Suite 42 -> 44 (BOM-Bash fail-closed + BOM-clean pass witnesses).
Found by first-party self-probe before the codex round-4 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-4 — fail closed on parser-dependent duplicate name/tools in invariant 2
codex gpt-5.6-sol xhigh round-4 P2: invariant 2 resolved name/tools with
last-wins (values[-1]), but duplicate YAML keys are parser-dependent. A
Bucket A file with `tools: Read, Bash` then `tools: Read, Grep` passed
(last-wins picks Grep) while a first-wins consumer would grant Bash;
symmetrically a duplicate `name` where only one resolution is Bucket A
could skip the file. Invariant 2 now rejects a duplicate `tools` on any
in-scope file and a duplicate `name` where any resolution is a Bucket A
key — fail closed, matching invariant 1's existing duplicate-tools
rejection. (round-4's other finding, the leading BOM, was already fixed in
645cfa2 — round-4 reviewed the pre-BOM-fix commit and noted the worktree
already carried the fix.) Suite 44 -> 48 with last-wins/first-wins/dup-name
witnesses. CHANGELOG updated (four rounds, 48 tests, full fail-open list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-5 — merge tag on any node type + RecursionError fails closed
codex gpt-5.6-sol xhigh round-5 found two residual issues:
- P1: _uses_merge_or_alias checked the merge tag only inside the ScalarNode
branch, but PyYAML applies merge semantics for a merge-tagged key of ANY
type — `? !!merge [x]` is a merge-tagged SequenceNode key that injects
`tools: [Read, Bash]` while the scalar-only check returned false. The tag
is now checked on every node before dispatching on type.
- P2: the recursive node walk (and yaml.compose) could raise RecursionError
on pathologically deep valid YAML, crashing the lint with a traceback.
Both now map to fail-closed (compose RecursionError -> None -> error;
walk RecursionError -> True).
Suite 51 -> 53 with merge-tagged-complex-key (seq + map) and
deep-nesting-fails-closed witnesses. CHANGELOG updated (five rounds, 53
tests, full fail-open list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* test(lint): #524 round-6 self-probe witnesses — no residual fail-open or false-positive
Adds regression anchors for the round-6 adversarial probes, all confirmed
correct without code change: lowercase `bash` / `BashOutput` are distinct
non-shell tool names (not flagged); `yaml.compose` does not intern
identical scalars, so the alias-by-shared-identity detector has no
false-positive on a clean file repeating a value; a non-string manifest
`agents` key does not break reconciliation of the real Bucket A agents.
Suite 53 -> 56.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* test(lint): #524 make recursion tests Python-version-independent (CI 3.14 fix)
The deep-nesting test assumed 400 flow-sequence levels trip RecursionError,
but Python 3.14 tolerates far deeper nesting than 3.11, so compose
succeeded and the (clean, merge/alias-free) file legitimately passed —
red-lighting the test on CI's 3.14 while local 3.11 was green. A depth
that does not exceed the interpreter limit is a harmless file and SHOULD
pass. Split into: (1) does-not-crash (contract = check() returns, any
depth), (2) compose-RecursionError->None via monkeypatch, (3)
walk-RecursionError->fail-closed via an iterable that raises on iteration.
All three are deterministic and version-independent. Suite 56 -> 58.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-6 — anchor byte-witness to composed key line (block-scalar false-positive)
codex gpt-5.6-sol xhigh round-6 found one non-blocking false-positive (no
fail-open): _raw_tools_lines text-scanned every frontmatter line for
`tools:`, so a clean allowlisted file whose `description: |` block scalar
documents a `tools: ...` line produced a spurious second match and failed
CI. Replaced with _raw_tools_line anchored to the composed `tools` key
node's start_mark.line (+ _key_nodes helper) — a `tools:`-looking line
inside a block scalar is no longer mistaken for the key. Semantics for
real drift (CRLF, whitespace, escaped/tagged spelling) unchanged; the
node-tree semantic check remains the security floor. Suite 58 -> 59.
CHANGELOG updated (six rounds, 59 tests, convergence: r6 = one
false-positive fixed, zero fail-open).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-7 — rglob nested agents + byte-witness anchored to start_mark.index
codex gpt-5.6-sol xhigh round-7 found two real issues:
- P1 (fail-open): invariant 2's glob("*.md") only scanned immediate
children of each AGENT_DIRS entry, so a nested agents/subdir/x.md with a
Bucket A name + Bash would be missed while the runtime guard (keying on
name regardless of path) would still fence it inconsistently. Now
rglob("*.md"). No nested agent files exist today; this is a forward
guard.
- P2 (byte-witness weakening / false-positive): the witness read the raw
line via start_mark.line indexed into split("\n"), but YAML counts the
Unicode line breaks NEL (U+0085) / LS (U+2028) / PS (U+2029) that
split("\n") does not — the two diverge, which could false-reject an exact
line or let a non-verbatim key line pass. Now anchored to
start_mark.index (byte offset), sliced to the surrounding physical-\n
line, so the two agree by construction. The node-tree semantic check
remains the security floor regardless.
Suite 59 -> 61 with nested-Bucket-A-Bash and Unicode-line-break witnesses.
CHANGELOG updated (seven rounds, 61 tests, full fail-open + false-positive
list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-8 — fail closed on directory symlinks + bare-CR frontmatter
codex gpt-5.6-sol xhigh round-8 found two more fail-opens:
- P1: rglob does not descend into directory symlinks, so a tracked
agents/nested -> ../payload could hide a Bucket A .md declaring Bash.
Invariant 2 now fails closed on any directory symlink under an agent dir
(these hand-authored trees have no reason for one). Real repo has none.
- P1: _frontmatter split only on \n, so bare-\r (old-Mac) frontmatter —
which YAML parses fine — was read as absent and skipped, hiding a Bucket
A tools: Bash. Fences are now found with splitlines() (recognizes \n,
\r\n, bare \r, and Unicode breaks); the block is still the byte-faithful
substring so compose sees true bytes and the byte-witness (start_mark.index)
stays exact — a bare-CR/CRLF file still fires the witness as drift.
Suite 61 -> 63 with dir-symlink and bare-CR witnesses. CHANGELOG updated
(eight rounds, 63 tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-9 — fold invisible/homoglyph tool tokens so padded Bash can't slip
codex gpt-5.6-sol xhigh round-9 (via its own probe) surfaced a real
fail-open in invariant 2: the Bash check is an exact-string membership
test (`"Bash" in declared`), and str.strip() does NOT remove Unicode
format characters (category Cf). So a fenced Bucket A agent declaring
tools: Read, Bash (BOM U+FEFF padding)
tools: Read, Bash (zero-width space U+200B)
tools: Read, Bash (fullwidth homoglyph)
kept the token distinct from `Bash`, passed the lint GREEN, yet reads as
Bash to the eye and to any NFKC-normalizing consumer — exactly the
symmetric-re-add bypass the lock exists to stop.
Fix: new _fold_token strips Cf format chars then NFKC-normalizes each base
tool token before comparison. All six real tool names + the Bash(git:*)
permission form are pure ASCII and pass through byte-identical, so no
legitimate value is altered. Folding lives in _normalized_tools, so it
protects BOTH invariants; invariant 1's additive byte-witness is confirmed
to STILL fire on an invisible-char canonical value (folding the semantic
check did not open a hole there).
Suite 63 -> 68: BOM / ZWSP / ZWNJ / fullwidth padded-Bash witnesses + a
byte-witness-still-fires regression for the invariant-1 companion. CHANGELOG
updated (nine rounds, 68 tests, invisible-char fail-open listed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-10 — fold before the permission-specifier split so fullwidth-paren Bash can't slip
codex gpt-5.6-sol xhigh round-10 found an ordering corollary of the r9
fold: _fold ran AFTER the ASCII "(" split, so a fullwidth-paren specifier
tools: Read, Bash(git:*) (U+FF08 / U+FF09)
was never split (ASCII "(" misses the fullwidth "("), leaving the whole
"Bash(git:*)" as the base token; NFKC then folded it to "Bash(git:*)"
(one token) which != "Bash", so the membership test missed it and the
lint passed green — while an NFKC-normalizing runtime consumer would read
a valid Bash(git:*) grant.
Fix: fold each item FIRST (renamed _fold_token -> _fold, now content-only,
no internal strip), THEN split on ASCII "(" and strip. Fullwidth parens
fold to ASCII before the split, so Bash(git:*) and Bash(git:*) both
reduce to "Bash". ASCII Bash(git:*), the canonical five, and BashOutput
are all unchanged.
Suite 68 -> 70: fullwidth-paren specifier + fullwidth-name-and-paren
witnesses. CHANGELOG updated (ten rounds, 70 tests, ordering corollary
listed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* fix(lint): #524 round-11 — fold the whole tools value before ANY split so fullwidth-comma Bash can't slip
codex gpt-5.6-sol xhigh round-11 found the comma-side twin of the r10
paren corollary: _fold still ran per-item AFTER value.split(","), so a
fullwidth comma
tools: Read,Bash (U+FF0C)
kept "Read,Bash" as one item through the ASCII comma split; folding the
item then yielded "Read,Bash" (one token) != "Bash", so the membership
test missed it — while an NFKC-normalizing consumer would split on the
folded comma and grant Bash.
Root cause is the r10 class generalized: splitting on ASCII separators
before folding. Fix: fold ONCE up front — the whole string in the comma
form (so U+FF0C and U+FE50 become "," before split), and each list member
whole in the list form (a fullwidth comma inside a YAML list element is
not a separator, so it correctly stays one token). Then split on "," and
"(". ASCII values, the canonical five, and BashOutput are all unchanged.
Suite 70 -> 73: fullwidth-comma + small-comma + combined
comma/name/paren witnesses. CHANGELOG updated (eleven rounds, 73 tests,
both separator corollaries listed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
* test(lint): #524 round-12 — pin the separator-fold convergence boundary as a non-bug
Round-12 review: codex's automated pass tripped its own cybersecurity
content filter mid-run (it went spelunking in the Claude Code binary) and
returned no verdict, so I resolved convergence by first-party analysis
instead.
Exhaustively scanned the residual surface the r9-r11 fold left open:
(A) every NFKC-stable alternate separator (ideographic/Arabic commas
U+3001/U+060C, division/fraction slashes, semicolons, middle dots) — none
can isolate a bare "Bash" token, because they do NOT fold to the ASCII
","/"(" the split (or any NFKC-normalizing consumer) honors, so
"Read、Bash" stays ONE non-Bash token for everyone (proven: it is a single
YAML scalar, and this lint splits only on ASCII ","); (B) zero codepoints
NFKC-decompose INTO "ash" (no false-positive/hiding risk); (C) the
per-letter compat variants are already folded whole; (D) zero non-Cf
codepoints fold to empty (no token-merging attack). The fullwidth
comma/paren cases fail precisely because NFKC folds THEM into ASCII
separators; the stable ones cannot, so not flagging them is correct.
Added test_nfkc_stable_alt_separator_is_not_a_bash_grant documenting this
boundary as a NON-bug (suite 73 -> 74) so a future maintainer does not
"fix" it by over-broadening the separator set into false positives.
CHANGELOG updated (twelve rounds, 74 tests, convergence boundary recorded).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPGvAbphbuZ4mhx2Ketj7s
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
539 lines
25 KiB
Python
Executable File
539 lines
25 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Lint: pin the #514 tools-allowlist CONTENT on the three plugin agents (#524).
|
||
|
||
#521 shipped `tools: Read, Write, Edit, Grep, Glob` on the three top-level
|
||
plugin agents (deep-research sources + agents/ mirrors), but no lint read a
|
||
frontmatter `tools` key: check_agents_mirror_sync.py pins mirror==source
|
||
byte-equality (the PAIR, never the VALUE), and the runtime write-scope guard
|
||
(scripts/ars_write_scope_guard.py) keys on agent NAME, never frontmatter. A
|
||
future PR editing a source+mirror pair symmetrically back to `..., Bash` (or
|
||
dropping `Grep`, or typoing a tool name) would pass every CI gate green —
|
||
exactly the drift class the repo's defrift locks exist to catch (cf. the
|
||
v3.15 locks). This lint pins the VALUE.
|
||
|
||
Design: YAML is the authority, not a line scan. Claude Code defines these
|
||
files as YAML frontmatter, and a raw-line/regex scan can never see every
|
||
YAML-legal spelling of a key or value (quoted, tagged, aliased, escaped,
|
||
`\\u0073`-escaped, tab-indented, flow/block list, `Bash(...)` specifier). So
|
||
every semantic decision is read from a DUPLICATE-PRESERVING YAML node tree
|
||
(`yaml.compose`, which — unlike `safe_load` — keeps a shadowed duplicate key
|
||
visible AND resolves an alias into shared node identity). Any frontmatter
|
||
that will not compose to a mapping, OR uses a merge key (`<<`) / alias
|
||
(constructs that inject or share keys the literal-key scan cannot see), is a
|
||
fail-closed ERROR, never a skip. The frontmatter fence is a COLUMN-0 `---`
|
||
only, so an indented `---` inside a block scalar cannot truncate the block
|
||
and hide keys below it. The exact PINNED_TOOLS_LINE raw-line check is kept
|
||
ON TOP as an additive, stricter witness (it also pins byte-level form:
|
||
CR-sensitive, so a symmetric LF→CRLF conversion is drift; and it fires when
|
||
the verbatim pinned line is absent), but it can only ADD findings, never
|
||
subtract them — the semantic node-tree check stands alone as the security
|
||
floor.
|
||
|
||
Invariants:
|
||
1. Every file in ALLOWLISTED_FILES exists, its frontmatter composes to a
|
||
YAML mapping with EXACTLY ONE `tools` key (counted from the
|
||
duplicate-preserving node tree, so a shadowed `"tools":` / `"tool\\u0073":`
|
||
duplicate is caught), and that key's value normalizes to exactly the
|
||
canonical five tools. On top, the single raw `tools:` frontmatter line
|
||
must be byte-equal to PINNED_TOOLS_LINE. Changing the allowlist is a
|
||
deliberate security-surface change: edit the agent files AND this
|
||
lint's PINNED_TOOLS_LINE in the same commit (standard lock semantics).
|
||
2. Frontmatter/guard reconciliation: any agent file under AGENT_DIRS whose
|
||
frontmatter `name` is a Bucket A key in
|
||
scripts/ars_phase_scope_manifest.json must NOT declare Bash in a
|
||
`tools:` key — in ANY YAML-legal form. `Bash` is matched as an exact
|
||
base tool name (`BashOutput` is a different tool and is not flagged;
|
||
`Bash(git:*)` normalizes to `Bash` and IS). The runtime guard denies
|
||
Bucket A agents ALL Bash (zero fail-open); a frontmatter advertising
|
||
Bash would silently widen capability in hook-less installs while
|
||
contradicting the guard in hook-active ones. Agents with no `tools:`
|
||
key inherit and are untouched (the runtime guard still fences them).
|
||
Fail-closed: an agent file whose frontmatter will not compose to a
|
||
mapping is treated as a POSSIBLE Bucket A member and errors (we cannot
|
||
read its `name` to clear it), and a Bucket A agent whose `tools` value
|
||
has an unrecognized shape (non-string list member, mapping) errors.
|
||
|
||
The manifest is load-bearing for invariant 2, so a missing, unparseable, or
|
||
non-mapping manifest FAILS the lint (fail-closed) rather than skipping.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
import unicodedata
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
# The exact frontmatter line shipped by #521 (frozen #514 spec). Single
|
||
# source of truth for the VALUE — a symmetric source+mirror edit cannot
|
||
# change it without touching this lint in the same commit.
|
||
PINNED_TOOLS_LINE = "tools: Read, Write, Edit, Grep, Glob"
|
||
CANONICAL_TOOLS = ("Read", "Write", "Edit", "Grep", "Glob")
|
||
|
||
# The six #514 surfaces: three canonical sources + three agents/ mirrors
|
||
# (mirror==source byte-equality is check_agents_mirror_sync.py's job; the
|
||
# mirrors are still listed here so THIS lint stays correct even if that one
|
||
# is skipped or edited — deliberately re-derived, not imported, per the
|
||
# repo's independent-second-witness lint convention).
|
||
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",
|
||
)
|
||
|
||
# Every directory that holds agent prompt files (invariant 2 scan surface).
|
||
AGENT_DIRS = (
|
||
"deep-research/agents",
|
||
"academic-paper/agents",
|
||
"academic-paper-reviewer/agents",
|
||
"academic-pipeline/agents",
|
||
"shared/agents",
|
||
"agents",
|
||
)
|
||
|
||
MANIFEST = "scripts/ars_phase_scope_manifest.json"
|
||
|
||
|
||
def _read_raw(path: Path) -> str:
|
||
"""Read WITHOUT universal-newline translation, so a CRLF file keeps its
|
||
`\\r` bytes and cannot satisfy an exact LF line pin. A leading UTF-8 BOM
|
||
is stripped: a YAML reader (and Claude Code) skips it, so leaving it on
|
||
would make `\\ufeff---` fail the column-0 fence match and the whole file
|
||
read as frontmatter-less — a fail-open that lets a BOM-prefixed Bucket A
|
||
agent smuggle Bash past invariant 2's skip-when-no-frontmatter branch."""
|
||
return path.read_bytes().decode("utf-8").lstrip("")
|
||
|
||
|
||
def _frontmatter(text: str) -> str | None:
|
||
"""The raw YAML frontmatter block (the byte-faithful substring between the
|
||
two `---` fences), or None when the file has no frontmatter.
|
||
|
||
Line breaks are found with `str.splitlines`, which recognizes EVERY YAML
|
||
line break — `\\n`, `\\r\\n`, and a bare `\\r` (old-Mac) plus the Unicode
|
||
breaks NEL/LS/PS — so a bare-CR-delimited file is not silently read as
|
||
frontmatter-less and skipped (a real fail-open, #524 r8). A fence is
|
||
exactly `---` at column 0: an INDENTED `---` is block-scalar content, not
|
||
a fence (matching it would truncate the block and hide keys). The block
|
||
returned is the raw substring (original break bytes intact) so `compose`
|
||
sees the true bytes and the byte-witness — anchored to `start_mark.index`
|
||
— stays byte-faithful; a bare `\\r`/CRLF file therefore still fires the
|
||
byte-witness as drift."""
|
||
lines = text.splitlines(keepends=True)
|
||
if not lines or lines[0].splitlines()[0] != "---":
|
||
return None
|
||
# Byte offset just past the opening fence line (start of the block body).
|
||
body_start = len(lines[0])
|
||
offset = body_start
|
||
for raw in lines[1:]:
|
||
content = raw.splitlines()[0] if raw.splitlines() else raw
|
||
if content == "---": # column-0 closing fence
|
||
return text[body_start:offset]
|
||
offset += len(raw)
|
||
return None
|
||
|
||
|
||
def _mapping_node(block: str) -> yaml.MappingNode | None:
|
||
"""The frontmatter composed to a DUPLICATE-PRESERVING mapping node, or
|
||
None when it is not a mapping / will not compose / is too deeply nested
|
||
to compose safely. Unlike `safe_load`, `compose` keeps a shadowed
|
||
duplicate key visible in the node tree. A RecursionError on pathological
|
||
nesting maps to None so the caller fails closed rather than crashing."""
|
||
try:
|
||
node = yaml.compose(block, Loader=yaml.SafeLoader)
|
||
except (yaml.YAMLError, RecursionError):
|
||
return None
|
||
return node if isinstance(node, yaml.MappingNode) else None
|
||
|
||
|
||
_UNRESOLVED = object()
|
||
|
||
|
||
def _scalar_py(scalar: yaml.ScalarNode) -> object:
|
||
"""The Python value a scalar node resolves to, so `"tool\\u0073"`,
|
||
`'tools'`, and `tools` all compare equal. `compose` already stamps each
|
||
node with its resolved tag, so the SafeLoader constructs it exactly as
|
||
`safe_load` would (quotes, `\\u` escapes, explicit `!!` tags, block/flow
|
||
styles all honored). Any tag the safe constructor cannot build (a `<<`
|
||
merge scalar, an unknown `!Tag`) yields _UNRESOLVED so callers fail
|
||
closed rather than crash."""
|
||
try:
|
||
return yaml.SafeLoader("").construct_object(scalar)
|
||
except yaml.YAMLError:
|
||
return _UNRESOLVED
|
||
|
||
|
||
_MERGE_TAG = "tag:yaml.org,2002:merge"
|
||
|
||
|
||
def _uses_merge_or_alias(node: yaml.Node) -> bool:
|
||
"""True if the composed frontmatter uses a YAML merge key (`<<`) or an
|
||
alias (`*name`). safe_load resolves both — a `<<: *base` injects a
|
||
`tools`/`name` the literal-key scan never sees, and an alias shares a
|
||
value node — so their presence makes the duplicate-preserving key scan
|
||
unsound. Detected from the COMPOSED NODE TREE, not text (text scanning is
|
||
not a sound YAML authority — the reviews' recurring lesson): the `merge`
|
||
tag can land on a node of ANY type (`<<:` is a scalar key, but `? !!merge
|
||
[x]` is a merge-tagged sequence/mapping key), so we check the tag on
|
||
every node before dispatching on its type; an alias surfaces as the SAME
|
||
node object reachable by two paths (compose resolves `*a` to shared
|
||
identity). These hand-authored files never need either, so we fail closed
|
||
rather than reimplement merge resolution. A RecursionError on pathological
|
||
nesting also fails closed (True) — we could not prove the tree clean."""
|
||
seen: set[int] = set()
|
||
|
||
def walk(nd: yaml.Node) -> bool:
|
||
if nd is None:
|
||
return False
|
||
if nd.tag == _MERGE_TAG: # merge tag on ANY node type (key or value)
|
||
return True
|
||
if id(nd) in seen:
|
||
return True # reached twice = an alias shares this node
|
||
seen.add(id(nd))
|
||
if isinstance(nd, yaml.SequenceNode):
|
||
return any(walk(i) for i in nd.value)
|
||
if isinstance(nd, yaml.MappingNode):
|
||
return any(walk(k) or walk(v) for k, v in nd.value)
|
||
return False
|
||
|
||
try:
|
||
return walk(node)
|
||
except RecursionError:
|
||
return True
|
||
|
||
|
||
def _key_values(node: yaml.MappingNode, key: str) -> list[yaml.Node]:
|
||
"""Every value node whose key resolves to `key`, duplicates included
|
||
(the node tree preserves a shadowed key that safe_load would collapse)."""
|
||
return [v for k, v in node.value
|
||
if isinstance(k, yaml.ScalarNode) and _scalar_py(k) == key]
|
||
|
||
|
||
def _key_nodes(node: yaml.MappingNode, key: str) -> list[yaml.ScalarNode]:
|
||
"""Every KEY node that resolves to `key` (for line marks), duplicates
|
||
included."""
|
||
return [k for k, v in node.value
|
||
if isinstance(k, yaml.ScalarNode) and _scalar_py(k) == key]
|
||
|
||
|
||
def _node_to_py(value_node: yaml.Node) -> object:
|
||
"""A value node converted to its Python value, or the sentinel
|
||
_UNRESOLVED when it is not a plain scalar/sequence of scalars (a nested
|
||
list, a mapping member, or a mapping value — all unrecognized shapes)."""
|
||
if isinstance(value_node, yaml.ScalarNode):
|
||
return _scalar_py(value_node)
|
||
if isinstance(value_node, yaml.SequenceNode):
|
||
if not all(isinstance(i, yaml.ScalarNode) for i in value_node.value):
|
||
return _UNRESOLVED # nested list / mapping member
|
||
return [_scalar_py(i) for i in value_node.value]
|
||
return _UNRESOLVED
|
||
|
||
|
||
def _fold(text: str) -> str:
|
||
"""Text folded so an invisible/compatibility re-spelling collapses onto its
|
||
plain ASCII form. Removes Unicode format characters (category `Cf` —
|
||
BOM/`\\ufeff`, zero-width space/`\\u200b`, zero-width non-joiner/`\\u200c`,
|
||
etc.), which `str.strip()` does NOT remove, so `\\ufeffBash\\ufeff` would
|
||
otherwise survive as a token distinct from `Bash` and slip the `"Bash" in
|
||
declared` membership test (#524 r9) — a fenced Bucket A agent could declare
|
||
a zero-width-padded Bash and pass. Then NFKC-normalizes to fold
|
||
compatibility homoglyphs (fullwidth, etc.) onto ASCII. All six real tool
|
||
names + the `Bash(git:*)` permission form are pure ASCII and fold to
|
||
themselves, so no legitimate value is altered."""
|
||
stripped = "".join(c for c in text if unicodedata.category(c) != "Cf")
|
||
return unicodedata.normalize("NFKC", stripped)
|
||
|
||
|
||
def _normalized_tools(value: object) -> list[str] | None:
|
||
"""A `tools` value normalized to base tool names, or None when the shape
|
||
is unrecognized. Accepts the comma-string form and a list of strings; a
|
||
`Bash(git:*)`-style permission specifier normalizes to `Bash`. Folding
|
||
(`_fold`) happens BEFORE any split, on the whole string (or on each list
|
||
member whole), so every compatibility separator becomes its ASCII form
|
||
first: a fullwidth comma `,` (U+FF0C) in the string form — which
|
||
`split(",")` would miss, leaving `Read,Bash` as one token that an
|
||
NFKC-normalizing consumer would re-split to grant Bash (#524 r11) — and a
|
||
fullwidth-paren specifier `Bash(git:*)` (U+FF08/U+FF09) that the ASCII `(`
|
||
split would miss (#524 r10) both reduce correctly. Splitting before folding
|
||
reintroduces either hole. `value` comes from `_node_to_py`, which already
|
||
collapses a non-scalar list member to `_UNRESOLVED`, so a list reaching
|
||
here is all-strings; any non-str/list value (including `_UNRESOLVED`) is
|
||
unrecognized."""
|
||
if isinstance(value, str):
|
||
items: list[str] = _fold(value).split(",")
|
||
elif isinstance(value, list) and all(isinstance(i, str) for i in value):
|
||
# A YAML list is already tokenized; fold each member whole (a
|
||
# fullwidth comma inside a member is not a YAML separator, so it stays
|
||
# one token — correctly, since no list consumer re-splits an element).
|
||
items = [_fold(i) for i in value]
|
||
else:
|
||
return None
|
||
out = []
|
||
for item in items:
|
||
base = item.split("(", 1)[0].strip()
|
||
if base:
|
||
out.append(base)
|
||
return out
|
||
|
||
|
||
def _raw_tools_line(block: str, key_node: yaml.ScalarNode) -> str | None:
|
||
"""The raw frontmatter line the composed `tools` key SITS ON, for the
|
||
byte-exact PINNED_TOOLS_LINE witness. Anchored to the node's
|
||
`start_mark.index` — the character offset into `block` — sliced to the
|
||
surrounding physical-`\\n` line, NOT `start_mark.line` (which counts
|
||
YAML's Unicode line breaks NEL/LS/PS that `split("\\n")` does not, so the
|
||
two indexings can diverge and select the wrong line — #524 r7). Using the
|
||
byte offset makes the two agree by construction. Anchoring (not a text
|
||
scan) also means a `tools:`-looking line inside a block scalar — e.g. a
|
||
`description: |` documenting the tools — is never mistaken for the key
|
||
line (round-6 false-positive). An ADDITIVE (still CI-gating) layer on top
|
||
of the node-tree check: it only adds findings a capability-equivalent
|
||
re-spelling would slip past (CRLF, trailing/interior whitespace, exact
|
||
spelling), never clears the semantic check. Returns None when the offset
|
||
is out of range."""
|
||
idx = key_node.start_mark.index
|
||
if not (0 <= idx <= len(block)):
|
||
return None
|
||
start = block.rfind("\n", 0, idx) + 1 # char after the prev newline
|
||
end = block.find("\n", idx) # next newline, or end
|
||
return block[start:] if end == -1 else block[start:end]
|
||
|
||
|
||
def _tools_value(node: yaml.MappingNode) -> object:
|
||
"""The effective (last-wins) `tools` Python value from the node tree, or
|
||
_UNRESOLVED when the key is absent or its value has an unreadable shape."""
|
||
values = _key_values(node, "tools")
|
||
if not values:
|
||
return _UNRESOLVED
|
||
return _node_to_py(values[-1]) # YAML last-wins
|
||
|
||
|
||
def _name_value(node: yaml.MappingNode) -> str:
|
||
"""The effective `name` from the node tree ('' when absent/non-scalar)."""
|
||
values = _key_values(node, "name")
|
||
if not values:
|
||
return ""
|
||
py = _node_to_py(values[-1])
|
||
return str(py).strip() if isinstance(py, str) else ""
|
||
|
||
|
||
def _bucket_a_names(root: Path) -> tuple[set[str] | None, str | None]:
|
||
"""Bucket A agent names from the manifest, or (None, error)."""
|
||
mp = root / MANIFEST
|
||
try:
|
||
data = json.loads(mp.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError) as exc:
|
||
return None, (
|
||
f"{MANIFEST}: unreadable or unparseable ({exc}) — invariant 2 "
|
||
"(frontmatter/guard reconciliation) cannot run; failing closed."
|
||
)
|
||
agents = data.get("agents") if isinstance(data, dict) else None
|
||
if not isinstance(agents, dict):
|
||
return None, (
|
||
f"{MANIFEST}: no `agents` mapping — invariant 2 cannot run; "
|
||
"failing closed."
|
||
)
|
||
return set(agents), None
|
||
|
||
|
||
def check(root: Path) -> list[str]:
|
||
errors: list[str] = []
|
||
|
||
# --- invariant 1: exactly-one canonical `tools` on the six files ---------
|
||
for rel in ALLOWLISTED_FILES:
|
||
path = root / rel
|
||
if not path.is_file():
|
||
errors.append(
|
||
f"{rel}: allowlisted agent file is missing — the #514 "
|
||
"surface changed; update ALLOWLISTED_FILES in "
|
||
"check_tools_allowlist.py deliberately or restore the file."
|
||
)
|
||
continue
|
||
block = _frontmatter(_read_raw(path))
|
||
if block is None:
|
||
errors.append(f"{rel}: no YAML frontmatter block found.")
|
||
continue
|
||
node = _mapping_node(block)
|
||
if node is None:
|
||
errors.append(
|
||
f"{rel}: frontmatter does not compose to a YAML mapping — "
|
||
"the tools allowlist cannot be verified; failing closed."
|
||
)
|
||
continue
|
||
if _uses_merge_or_alias(node):
|
||
errors.append(
|
||
f"{rel}: frontmatter uses a YAML merge key / alias — these "
|
||
"resolve to keys the pin cannot see (a `<<` can inject a "
|
||
"`tools` value); not supported in agent frontmatter, "
|
||
"failing closed."
|
||
)
|
||
continue
|
||
tools_nodes = _key_values(node, "tools")
|
||
if not tools_nodes:
|
||
errors.append(
|
||
f"{rel}: frontmatter has no `tools:` key — the #514 "
|
||
"allowlist was dropped (a silent capability widening: the "
|
||
"agent would inherit ALL tools). Restore "
|
||
f"`{PINNED_TOOLS_LINE}`."
|
||
)
|
||
elif len(tools_nodes) > 1:
|
||
errors.append(
|
||
f"{rel}: {len(tools_nodes)} `tools` keys in frontmatter "
|
||
"(duplicate-preserving parse — quoted / escaped variants "
|
||
"included) — exactly one expected; a duplicate key overrides "
|
||
"the pinned value under YAML last-wins resolution."
|
||
)
|
||
else:
|
||
normalized = _normalized_tools(_node_to_py(tools_nodes[0]))
|
||
if normalized != list(CANONICAL_TOOLS):
|
||
errors.append(
|
||
f"{rel}: the `tools` value diverges from the canonical "
|
||
f"{', '.join(CANONICAL_TOOLS)} — the effective allowlist "
|
||
"is not what #514 froze."
|
||
)
|
||
# Byte-exact witness ON TOP (also pins CRLF / spelling). Additive
|
||
# layer: it can only add findings, never clear the semantic check.
|
||
# Anchored to the composed `tools` key's own line (not a text scan),
|
||
# so a `tools:`-looking line inside a block scalar is not mistaken for
|
||
# it. Only meaningful for the exactly-one-key case; the missing /
|
||
# duplicate cases already fired via the semantic branch above. A
|
||
# non-verbatim line (escaped/tagged/folded spelling, or CRLF /
|
||
# whitespace drift) fires — the semantic check independently catches
|
||
# the value-changing subset.
|
||
key_nodes = _key_nodes(node, "tools")
|
||
if len(key_nodes) == 1:
|
||
raw_line = _raw_tools_line(block, key_nodes[0])
|
||
if raw_line != PINNED_TOOLS_LINE:
|
||
found = raw_line if raw_line is not None else \
|
||
"(tools key not on its own line)"
|
||
errors.append(
|
||
f"{rel}: the `tools` line is not byte-equal to the frozen "
|
||
f"#514 form.\n expected: {PINNED_TOOLS_LINE}\n "
|
||
f"found: {found!r}\n Changing the allowlist is a "
|
||
"deliberate security-surface change: update "
|
||
"PINNED_TOOLS_LINE in check_tools_allowlist.py in the "
|
||
"same commit."
|
||
)
|
||
|
||
# --- invariant 2: no Bucket A agent declares Bash -------------------------
|
||
bucket_a, manifest_err = _bucket_a_names(root)
|
||
if manifest_err:
|
||
errors.append(manifest_err)
|
||
return errors
|
||
for rel_dir in AGENT_DIRS:
|
||
d = root / rel_dir
|
||
if not d.is_dir():
|
||
continue
|
||
# rglob does NOT descend into directory symlinks, so a tracked
|
||
# `agents/nested -> ../payload` could hide a Bucket A `.md` declaring
|
||
# Bash (#524 r8). Fail closed on any directory symlink under an agent
|
||
# dir — these hand-authored trees have no reason for one, and
|
||
# reconciliation cannot see through it.
|
||
for sub in sorted(d.rglob("*")):
|
||
if sub.is_symlink() and sub.is_dir():
|
||
errors.append(
|
||
f"{sub.relative_to(root).as_posix()}: directory symlink "
|
||
"under an agent dir — rglob does not descend into it, so a "
|
||
"Bucket A agent declaring Bash could hide behind it; "
|
||
"failing closed. Replace with real files."
|
||
)
|
||
# rglob, not glob: a nested `agents/subdir/x.md` could carry a Bucket
|
||
# A `name` + Bash and the runtime guard keys on name regardless of
|
||
# path, so the reconciliation must reach nested files too (#524 r7).
|
||
for path in sorted(d.rglob("*.md")):
|
||
rel = path.relative_to(root).as_posix()
|
||
block = _frontmatter(_read_raw(path))
|
||
if block is None:
|
||
continue
|
||
node = _mapping_node(block)
|
||
if node is None:
|
||
# Cannot read `name` to clear it — treat as a possible
|
||
# Bucket A member and fail closed.
|
||
errors.append(
|
||
f"{rel}: agent frontmatter does not compose to a YAML "
|
||
"mapping — cannot confirm it is not a Bucket A agent "
|
||
"advertising Bash; failing closed."
|
||
)
|
||
continue
|
||
if _uses_merge_or_alias(node):
|
||
# A `<<`/alias could inject `tools` or rewrite `name`
|
||
# invisibly to the literal-key scan — fail closed rather than
|
||
# clear the file on a name it may not really carry.
|
||
errors.append(
|
||
f"{rel}: agent frontmatter uses a YAML merge key / alias "
|
||
"— cannot soundly confirm it is not a Bucket A agent "
|
||
"advertising Bash; failing closed."
|
||
)
|
||
continue
|
||
# Duplicate `name`/`tools` handling is parser-dependent (last-wins
|
||
# here, but another consumer may take first-wins). If ANY resolved
|
||
# `name` is a Bucket A key, the file is in scope — and if it also
|
||
# carries a duplicate `tools`, one resolution could hide Bash
|
||
# behind the other. Fail closed rather than pick a winner, exactly
|
||
# as invariant 1 rejects a duplicate `tools`.
|
||
name_nodes = _key_values(node, "name")
|
||
resolved_names = {_node_to_py(n) for n in name_nodes}
|
||
if not resolved_names & bucket_a:
|
||
continue
|
||
if len(name_nodes) > 1:
|
||
errors.append(
|
||
f"{rel}: {len(name_nodes)} `name` keys in frontmatter, one "
|
||
"resolving to a Bucket A agent — duplicate-key resolution "
|
||
"is parser-dependent; failing closed."
|
||
)
|
||
continue
|
||
if len(_key_values(node, "tools")) > 1:
|
||
errors.append(
|
||
f"{rel}: Bucket A agent has {len(_key_values(node, 'tools'))} "
|
||
"`tools` keys — duplicate-key resolution is "
|
||
"parser-dependent and one could hide Bash behind another; "
|
||
"failing closed."
|
||
)
|
||
continue
|
||
tools_value = _tools_value(node)
|
||
if tools_value is _UNRESOLVED:
|
||
if _key_values(node, "tools"):
|
||
errors.append(
|
||
f"{rel}: Bucket A agent has a `tools` value of "
|
||
"unrecognized shape — cannot verify it excludes "
|
||
"Bash; failing closed."
|
||
)
|
||
continue
|
||
declared = _normalized_tools(tools_value)
|
||
if declared is None:
|
||
errors.append(
|
||
f"{rel}: Bucket A agent has a `tools` value of "
|
||
"unrecognized shape — cannot verify it excludes Bash; "
|
||
"failing closed."
|
||
)
|
||
elif "Bash" in declared:
|
||
errors.append(
|
||
f"{rel}: frontmatter declares Bash but this is a Bucket "
|
||
f"A agent in {MANIFEST} — the runtime guard denies "
|
||
"Bucket A agents ALL Bash (zero fail-open), so this "
|
||
"grant is either dead (hook-active) or a silent widening "
|
||
"(hook-less). Remove Bash from the tools list."
|
||
)
|
||
|
||
return errors
|
||
|
||
|
||
def main() -> int:
|
||
errors = check(REPO_ROOT)
|
||
if errors:
|
||
print("tools allowlist check failed (#524):")
|
||
for err in errors:
|
||
print(f"- {err}")
|
||
return 1
|
||
print("tools allowlist check passed.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|