Files
Edward Cheng-I Wu d22b1634d6 feat(lint): #524 defrift lock pins the #514 tools-allowlist content (#526)
* 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>
2026-07-13 16:27:44 +08:00

539 lines
25 KiB
Python
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 `ReadBash` as one token that an
NFKC-normalizing consumer would re-split to grant Bash (#524 r11) — and a
fullwidth-paren specifier `Bashgit:*` (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())