mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
e7e775a0e1
* feat(v3.6.7 Step 6 Phase 6.7): downstream agent partial inversion sweep + INV-1/2/3 lint
Implements Phase 6.7 deliverable per spec §10 (line 2391-2403):
Prompt edits per §6.2 sweep table:
- synthesis_agent.md line 164: remove "Cross-model audit follows ..."
sentence (Clause 2(b)+(d) violation); append canonical Clause 1 line
bullet to PATTERN PROTECTION block.
- research_architect_agent.md line 190: remove "Cross-model audit covers
these via dimension §3.5 ..." sentence (Clause 2(b)+(d) violation);
append canonical Clause 1 line.
- report_compiler_agent.md line 172: remove "Cross-model audit covers
these via dimension §3.7 ..." sentence; merge prior line 177 (trim
"The orchestrator runs codex audit afterward" tail) and line 178
("Output metadata must not claim audit-passed state") into the single
canonical Clause 1 line bullet.
Manifest (new): scripts/v3_6_7_inversion_manifest.json
- Three-file scope per §6.3; locks the v3.6.7 sweep boundary so
widening to a fourth file requires explicit §9 L2 resolution.
Lint extension: scripts/check_v3_6_7_pattern_protection.py
- INV-1: canonical Clause 1 line appears exactly once in each manifest
file's PATTERN PROTECTION block (presence + uniqueness).
- INV-2: zero hits across four Clause 2 disclosure regex patterns
(a) the orchestrator + audit, (b) cross-model audit follows/covers
+ codex_audit_multifile_template, (c) audit afterwards/will be run/
is dispatched, (d) downstream audit / this output is/will be audited.
All compiled with re.IGNORECASE per §6.3.
- INV-3: scans deep-research/agents/ + academic-pipeline/agents/ for
the canonical line; any hit outside the manifest fails (defends
against accidental sweep widening, guards §9 L2 deferred question).
- C3 regex consolidation: prior C1-C3 had two separate regexes for
(anti-fake-audit pair) and (output-metadata audit-passed prohibition)
bullets. Phase 6.7 merges them into a single whole-line regex matching
the canonical Clause 1 line so all three prohibition tokens
(DO NOT × 2 + must not) sit inside the matched span — the negation
post-filter no longer flags a sibling prohibition as a weakener
(B2 R3-001 span-restricted exemption).
Tests: scripts/test_check_v3_6_7_pattern_protection.py (+12 tests)
- INV-1 negative × 4: canonical line deletion (synthesis / architect /
compiler) + duplication (synthesis).
- INV-2 negative × 5: inject one canonical violation phrase per pattern
(a)/(b)/(c) and two for (d)'s alternation.
- INV-3 negative × 3: canonical line in non-manifest agent file
(bibliography_agent), manifest shrunk to two files, manifest extra
entry pointing to a file without the canonical line.
- Removed obsolete R3MutationTests.test_r3_001_orchestrator_does_not_run_fails
whose mutation source ("The orchestrator runs codex audit afterward.")
no longer exists post-sweep.
Verification gate per spec §10 line 2403:
- check_v3_6_7_pattern_protection.py: 12/12 passed (9 existing checks +
INV-1/2/3 aggregated)
- test suite: 40/40 tests green (28 existing - 1 obsolete + 13 new INV tests
including BaselineTest)
Phase 6.6 PR #65 was shipped standalone; Phase 6.7 follows as a separate
PR per spec §10 line 2401's bundling recommendation deferred (Phase 6.6
shipped first due to budget reconciliation patch in PR #64).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 tests): update C3 audit-passed deletion mutation source post-canonical-line merge
Phase 6.7 merged line 178 ("Output metadata must not claim audit-passed
state.") into the canonical Clause 1 line bullet. The standalone bullet
form (preceded by `\n- `) no longer exists, so the prior
test_c3_audit_passed_sentence_deleted_fails mutation source string is
not found and the test errors out. Update the mutation to delete just
the audit-passed segment from within the merged canonical line bullet;
the assertion (lint must fail) is unchanged.
Verified: 40/40 tests pass after this fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 R1): close 3 codex P2 INV bypass findings
Codex R1 review (model=gpt-5.5 + xhigh, --base main) surfaced three P2
findings against the initial INV-1/INV-2/INV-3 implementation. All three
allowed full lint PASS while the spec §6.3 invariant was violated.
R1-001 P2 — INV-2 wrapped disclosure bypass
- Pre-fix: 4 INV-2 patterns compiled with `re.IGNORECASE` only. `.*`
did not cross newlines, so a Markdown soft-wrap of a forbidden Clause
2 sentence (e.g. "the orchestrator" on one line, "audit" on the
next) reported PASS despite the disclosure being intact.
- Fix: compile all four INV-2 patterns with `re.IGNORECASE | re.DOTALL`
so `.` crosses newlines.
R1-002 P2 — INV-1 canonical-with-tail-weakener bypass
- Pre-fix: CANONICAL_CLAUSE_1_RE ended at `state\b`, so an agent prompt
could keep the canonical bullet's three sentences yet append a tail
weakener (` if feasible.`, `; this is recommended.`) and INV-1 still
matched the prefix. The spec §6.3 INV-1 rule requires the bullet
text to match verbatim, which means anchored to the sentence end.
- Fix: re-compile with `re.MULTILINE` and end the pattern at
`state\.\s*$` so the third sentence must be the bullet's terminal
content. Apply the parallel `state\.(?=\s|\Z)` anchor to the C3
whole-line regex inside the C1-C3 Check (defense in depth — the
pre-existing `_ALWAYS_NEGATION_PATTERNS` already lists `if feasible`
/ `when possible` / `where feasible` etc., but the regex anchor
closes the gap before negation post-filter is even consulted).
R1-003 P2 — Manifest copy-paste widening attack
- Pre-fix: `_load_inversion_manifest()` only validated that `scope ==
"v3.6.7-only"` and `files` was a list of strings. An attacker (or
well-intentioned editor) could append a fourth file to the manifest
AND copy the canonical bullet into that file's PATTERN PROTECTION
block. INV-1 then passed for all four files (canonical present
everywhere). INV-3 silently skipped the new file because it was now
in `manifest_set`. End result: full lint PASS, sweep silently
widened, spec §9 L2 deferred question marked decided without
resolution.
- Fix: introduce module-level `EXPECTED_MANIFEST_FILES` tuple frozen at
the v3.6.7 three-file scope. `_load_inversion_manifest()` now
validates `set(files) == set(EXPECTED_MANIFEST_FILES)` (exact match,
no superset / no drift) AND uniqueness (`len(files) ==
len(set(files))`). Per spec §6.3 line 1807, future v3.6.8+ widening
must land its own version-tagged manifest with its own scope tag,
not retroactively edit v3.6.7's manifest.
Tests added (4): each closure pair has at least one mutation regression
test in CodexR1MutationTests covering the bypass codex demonstrated:
- test_r1_p2_inv2_wrapped_disclosure_fails (R1-001)
- test_r1_p2_inv1_canonical_with_tail_weakener_fails (R1-002)
- test_r1_p2_manifest_widening_with_canonical_copy_fails (R1-003)
- test_r1_p2_manifest_duplicate_entry_fails (R1-003 defense in depth)
Verified: 44/44 tests pass (40 prior + 4 new R1 tests). 12/12 lint
checks pass on clean working tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 R2): close 2 codex P2 INV bullet-extraction findings
Codex R2 review (model=gpt-5.5 + xhigh, --base main, after R1 fixes
landed) surfaced two more P2 findings — both about the granularity
mismatch between the spec's per-bullet semantics and the regex's
substring/cross-bullet matching.
R2-001 P2 — INV-1 false negative on bullet-prefix weakener
- Pre-R2: CANONICAL_CLAUSE_1_RE was a regex search-anywhere with
re.MULTILINE + `\.\s*$`. A bullet of the form `- When feasible, DO
NOT simulate ...` carried the canonical sentences as a substring;
the regex still matched the suffix and INV-1 reported PASS. The
spec §6.3 INV-1 rule says "exactly one bullet whose text matches
the canonical Clause 1 line verbatim" — substring match is not
verbatim.
- Pre-R2 dual: the same anchor rejected harmless soft-wraps (e.g. a
Markdown reflow line break between `run` and `codex/external`).
- Fix: introduce `_iter_bullets()` helper that walks `^- ` markers,
collapses each bullet's whitespace runs (including newlines from
soft wraps) to single spaces, and yields normalized bullet text.
INV-1 now compares each bullet's normalized text byte-for-byte
against `CANONICAL_CLAUSE_1_TEXT`. Soft-wrap tolerance preserved;
prefix weakener rejected; CANONICAL_CLAUSE_1_RE retired.
R2-002 P2 — INV-2 false positive across bullets
- Pre-R2: INV-2(a) `\bthe orchestrator\b.*\baudit\b` (and INV-2(b))
compiled with re.IGNORECASE | re.DOTALL applied to raw block text.
The unbounded `.*` would match across bullet boundaries — a benign
`the orchestrator` mention in one bullet plus the canonical bullet's
`audit step` mention in another would false-positive even though
no single sentence said the orchestrator runs an audit.
- Fix: `_inv2_check_file` now iterates `_iter_bullets(block)` and
applies each pattern against one bullet's normalized text at a
time. The canonical Clause 1 bullet is skipped (it inherently
contains "audit step" / "audit-passed state" tokens but is not a
disclosure). Patterns now compile with re.IGNORECASE only — DOTALL
is unnecessary because soft-wrap tolerance comes from the
whitespace-normalization step.
R2-derived consistency: INV-3 also now operates at bullet
granularity. Scanning the whole file as bullet sequence eliminates
false positives where a non-manifest agent prompt happens to discuss
the canonical wording in prose (a heading or paragraph mention does
not count as the canonical bullet).
Tests added (4): each closure has at least one regression test in
CodexR2MutationTests, plus one positive test for the soft-wrap
tolerance side of R2-001 and one for the cross-bullet false-positive
side of R2-002. Defense-in-depth test confirms a real in-bullet
INV-2 violation still fails after the per-bullet refactor.
Verified: 48/48 tests pass (44 prior + 4 new R2 tests). 12/12 lint
checks pass on clean working tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 R3): close codex P2 INV-2 prose-paragraph regression
Codex R3 review (model=gpt-5.5 + xhigh, --base main, after R2 fixes
landed) surfaced one P2 — the R2 per-bullet refactor of INV-2 dropped
coverage of the intro paragraph, which is exactly where the Phase 6.7
sweep removed Clause 2 disclosures.
R3-001 P2 — INV-2 misses non-bullet prose disclosures
- Pre-R3: `_inv2_check_file` iterated only bullets (`_iter_bullets`).
But the three sentences removed by the §6.2 sweep
(`Cross-model audit follows ... codex_audit_multifile_template.md`)
were intro-paragraph prose, not bullets. The original forbidden
pipeline leak could be re-added to the intro paragraph and lint
silently passed all 12 checks.
- Fix: introduce `_iter_block_segments(block)` helper that yields
`(offset, kind, normalized_text)` for every matched-content
segment — both `prose` paragraphs (intro paragraph; future inline
paragraphs) AND `bullet` items. INV-2 now iterates segments,
whitespace-normalizing each (preserving R2's soft-wrap tolerance
and bounded-`.*` invariant), and applies the four patterns against
one segment at a time.
Heading line `## PATTERN PROTECTION (v3.6.7)` is excluded — it is
content marker, not contract content. The canonical Clause 1 bullet
is still skipped (it inherently carries `audit step` /
`audit-passed state` tokens; it is the required prohibition, not a
disclosure).
Tests added (1): `test_r3_p2_inv2_intro_paragraph_disclosure_fails`
re-introduces the synthesis_agent intro paragraph's original
`Cross-model audit follows ... codex_audit_multifile_template.md`
sentence and asserts INV-2 catches it as a `prose` violation. The
diagnostic includes the segment kind for debuggability.
Verified: 49/49 tests pass. 12/12 lint checks pass on clean working
tree. Manual probe confirms R3 attack vector (re-add Clause 2 prose
to intro paragraph) now fails with "[INV-2(b)] ... Clause 2
violation in prose: ...".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 R4): close codex P2 INV-2 post-bullet prose regression
Codex R4 review (model=gpt-5.5 + xhigh, --base main, after R3 fixes
landed) surfaced one more P2 — R3's segmentation only walked prose
paragraphs in `block[:first_bullet]`, missing prose appended after
the canonical bullet.
R4-001 P2 — INV-2 misses post-bullet prose disclosures
- Pre-R4: `_iter_block_segments` extracted prose paragraphs from
`block[:first_bullet]` only (intro paragraph + paragraphs before
the first `- ` marker), then appended bullet items. Any prose
appended AFTER the bullet list — e.g. a trailing paragraph saying
"The orchestrator runs codex audit afterward." after the canonical
bullet — fell outside the segmentation window and silently passed
the four INV-2 patterns.
- Fix: rewrite `_iter_block_segments` to paragraph-split the ENTIRE
block (`re.split(r"\n\s*\n", block)`) and classify each non-empty
paragraph as either a bullet group (starts with `- `, walked via
`_iter_bullets`) or a prose paragraph. Bullet-vs-prose discrimination
is uniform across the whole block, so:
* intro paragraph → prose
* first bullet group → bullets (one or more)
* inserted paragraph between bullet groups → prose
* trailing paragraph after last bullet → prose
The promise is now: every non-heading-non-empty character in the
block lands in one segment of the segmentation, no carve-out.
Tests added (1): `test_r4_p2_inv2_post_bullet_prose_disclosure_fails`
appends "The orchestrator runs codex audit afterward." as a prose
paragraph immediately after the canonical bullet in synthesis_agent
and asserts lint catches it as a Clause 2 prose violation.
Verified: 50/50 tests pass. 12/12 lint checks pass on clean working
tree. Manual probe confirms R4 attack vector now fails with
"[INV-2(a)] ... Clause 2 violation in prose: 'The orchestrator runs
codex audit afterward.'".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 R5): close codex P2 C3 regex soft-wrap intolerance
Codex R5 review (model=gpt-5.5 + xhigh, --base main, after R4 fixes
landed) surfaced one P2 — the C3 Check regex still used literal
spaces inside sentences while INV-1 had been refactored to
whitespace-tolerant exact compare. CI failed on harmless Markdown
soft-wraps inside the canonical bullet.
R5-001 P2 — C3 regex rejects soft-wrapped canonical bullet
- Pre-R5: C3 regex used `\s+` only between sentences but literal
single spaces inside each sentence. A soft-wrap inside a sentence
(e.g. between `run` and `codex/external`) caused C3 to FAIL even
though INV-1's bullet-text whitespace normalization correctly
accepted it. Result: CI failed on benign Markdown reflow despite
the v3.6.7 contract not being violated.
- Fix: every inter-token space inside the C3 regex now accepts
`\s+`. Soft-wrap tolerance is now uniform across:
1. INV-1 (`_iter_bullets` whitespace-normalization)
2. C3 Check regex (this fix)
The `\.(?=\s|\Z)` trailing anchor (R1 closure) is preserved so a
tail weakener like ` if feasible.` is still rejected.
Tests added (1): `test_r5_p2_compiler_softwrap_canonical_passes`
mutates report_compiler's canonical bullet to wrap between `run`
and `codex/external` and asserts lint passes (rc=0). Mirror of R2's
synthesis-side soft-wrap positive test, now covering the C3 surface.
Verified: 51/51 tests pass. 12/12 lint checks pass on clean working
tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 R6): close codex P2 INV-1 weakened-duplicate bypass
Codex R6 review (model=gpt-5.5 + xhigh, --base main, after R5 fixes
landed) surfaced one P2 — INV-1's "exactly one bullet equals canonical"
contract counted ONLY exact matches, leaving room for a duplicate
weakened variant alongside the exact canonical bullet.
R6-001 P2 — INV-1 ignores weakened duplicates
- Pre-R6: `_inv1_check_file` counted bullets whose normalized text
equaled CANONICAL_CLAUSE_1_TEXT and required count == 1. An attacker
could keep the exact canonical bullet AND add a second near-canonical
variant (`- When feasible, DO NOT simulate ...` / `... audit-passed
state if feasible.`); the exact-count stayed at 1, lint passed, and
the block was left with two contradictory bullets the agent might
internalize. The spec §6.3 INV-1 wording "exactly one bullet whose
text matches the canonical Clause 1 line verbatim" implicitly forbids
weakened duplicates.
- Fix: introduce `_is_clause_1_like` helper using fragment markers
(`do not simulate`, `do not claim to have run`, `audit-passed
state`) to identify any bullet that reads as a Clause 1 variant.
`_inv1_check_file` now collects two bullet sets:
* exact: bullets whose normalized text equals canonical
* weakened: Clause 1-like bullets that are NOT exact
Lint fails if `weakened` is non-empty (any near-duplicate present)
OR if `len(exact) != 1`. Diagnostic enumerates the offending
weakened bullets so the editor sees exactly which ones to fix.
The fragment markers are load-bearing pieces of the canonical
sentence — no other v3.6.7 PATTERN PROTECTION bullet legitimately
carries them, so the heuristic does not false-positive on the A1-A5 /
B1-B5 / C1-C2 obligation bullets.
Tests added (2): `test_r6_p2_inv1_weakened_duplicate_alongside_canonical_fails`
covers the prefix-weakener variant; `test_r6_p2_inv1_tail_weakened_duplicate_fails`
covers the tail-weakener variant (defense in depth).
Verified: 53/53 tests pass. 12/12 lint checks pass on clean working
tree. Manual probe confirms both R6 attack vectors now fail with
clear diagnostic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 R7): close codex P2 INV-3 weakened-Clause-1 scope-leak bypass
Codex R7 review (model=gpt-5.5 + xhigh, --base main, after R6 fixes
landed) surfaced one P2 — INV-3 had the same weakened-duplicate
asymmetry that R6 closed inside manifest files: INV-3 rejected only
exact canonical bullets in non-manifest prompts, so a near-canonical
bullet outside the manifest still widened the v3.6.7 prohibition
scope silently.
R7-001 P2 — INV-3 misses Clause 1-like bullets outside the manifest
- Pre-R7: `_inv3_check` checked `bt == CANONICAL_CLAUSE_1_TEXT` for
each bullet in non-manifest agent files. A near-canonical variant
(`- When feasible, DO NOT simulate ...`) was not equal-byte to
canonical, so INV-3 passed even though the same prohibition
vocabulary was being smuggled into a fourth file.
- Fix: replace exact equality with `_is_clause_1_like` (the helper
R6 added for INV-1's weakened-duplicate detection). The same
fragment markers (`do not simulate`, `do not claim to have run`,
`audit-passed state`) now flag any Clause 1-like bullet anywhere
in a non-manifest agent file. Diagnostic enumerates offending
bullets so the editor can identify exactly what to remove.
The asymmetry between INV-1 (R6) and INV-3 (R7) is now closed:
both use the same Clause 1-like heuristic, so neither path can be
bypassed by the weakened-duplicate attack class.
Tests added (1): `test_r7_p2_inv3_weakened_clause_1_in_non_manifest_fails`
appends a `When feasible, ...` near-canonical bullet to
bibliography_agent.md (not in manifest) and asserts INV-3 fails.
Verified: 54/54 tests pass. 12/12 lint checks pass on clean working
tree. Manual probe confirms R7 attack vector now fails with
"[INV-3] ... Offending bullet(s): ['When feasible, DO NOT simulate ...']".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 R8): close 2 codex P2 INV-3 over-broad / under-broad findings
Codex R8 review (model=gpt-5.5 + xhigh, --base main, after R7 fixes
landed) surfaced two P2 findings — a false-positive on generic
anti-fabrication guidance AND a false-negative on prose-form
canonical pasting.
R8-001 P2 — `_is_clause_1_like` over-broad on generic prohibition
- Pre-R8: any bullet containing `do not simulate` or `do not claim
to have run` was flagged Clause 1-like. These are common
anti-fabrication phrases used in many agent prompts. A legitimate
bibliography guidance bullet `- Do not simulate data or sources.`
would false-positive INV-3 and prevent ordinary anti-fabrication
policies from being added to non-manifest agents.
- Fix: split fragments into two tiers:
_CLAUSE_1_AUDIT_FRAGMENTS = ("audit step", "audit-passed state",
"codex/external review")
_CLAUSE_1_GENERIC_FRAGMENTS = ("do not simulate",
"do not claim to have run")
`_is_clause_1_like` now requires at least one audit-specific
fragment. Generic fragments alone no longer trip the check, so
unrelated anti-fabrication guidance can ride elsewhere.
R8-002 P2 — INV-3 misses prose-form canonical pasting
- Pre-R8: `_inv3_check` only walked `- ` bullets. A non-manifest
prompt that pasted the exact canonical sentence as a normal
paragraph (not a Markdown list item) silently passed lint, even
though the prohibition was widened.
- Fix: add a paragraph-level scan inside `_inv3_check`. For each
non-bullet, non-heading paragraph in a non-manifest agent file,
whitespace-normalize and compare against `CANONICAL_CLAUSE_1_TEXT`
byte-for-byte. Prose match uses exact equality (not the
Clause 1-like heuristic) so a *discussion* of the canonical
wording in prose (e.g. quoting it for context) only fails when
the line is actually re-introduced as contract content.
Diagnostic enumerates offenders separately as `bullet:` vs `prose:`
so the editor sees exactly which form to fix.
Tests added (2):
- `test_r8_p2_generic_do_not_simulate_in_non_manifest_passes`
injects `- Do not simulate data or sources.` into
bibliography_agent and asserts lint passes (no false positive).
- `test_r8_p2_inv3_canonical_as_prose_in_non_manifest_fails`
injects the canonical sentence as a prose paragraph (not a
bullet) into bibliography_agent and asserts lint fails with
`prose: ...` offender diagnostic.
Verified: 56/56 tests pass. 12/12 lint checks pass on clean working
tree. Two manual probes confirm both attack vectors land correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.6.7 Phase 6.7 R9): close 2 codex P2 INV-3 architectural rewind
Codex R9 review (model=gpt-5.5 + xhigh, --base main, after R8 fixes
landed) surfaced that R7+R8's extension of the Clause 1-like heuristic
to INV-3 was over-extension. Spec §6.3 INV-3 wording says "canonical
Clause 1 line found outside the manifest" — that means the actual
sentence, not Clause 1-like variants. Variants outside the manifest
are a v3.6.8+ concern, not a v3.6.7 INV-3 contract.
R9-001 P2 — INV-3 over-broad on audit-related bullets outside manifest
- Pre-R9: INV-3 applied `_is_clause_1_like` to every bullet in non-
manifest agents. A legitimate process-flow bullet like
`- Review each audit step before finalizing.` triggered the lint
even though it has no prohibition semantics. The heuristic was
designed for INV-1's manifest-internal weakened-duplicate detection
(where any `audit step` mention SHOULD equal canonical), not for
scope-widening detection in unrelated agents.
- Fix: INV-3 now matches bullets via byte-exact whitespace-normalized
equality with `CANONICAL_CLAUSE_1_TEXT`. The Clause 1-like heuristic
remains in `_is_clause_1_like` for INV-1's R6+R8 contracts.
R9-002 P2 — INV-3 prose scan missed canonical after heading with no blank line
- Pre-R9: prose scan used `re.split(r"\n\s*\n", text)` to extract
paragraphs and filtered out paragraphs starting with `## `. When
an editor pasted `## PATTERN PROTECTION (v3.6.7)\nDO NOT simulate
...` (heading immediately followed by canonical sentence with no
intervening blank line), the entire chunk was one paragraph that
started with `## ` and was filtered out — canonical sentence
silently slipped past INV-3.
- Fix: replace paragraph-split with line-level heading-strip plus
whitespace-collapse. All non-heading non-bullet lines join with
single spaces; the canonical sentence is detected as a substring.
Heading adjacency no longer bypasses the scan.
Test updates:
- Renamed `CodexR7MutationTests` → `CodexR7R9MutationTests` and
inverted the R7 test's expectation: a weakened Clause 1 variant
outside the manifest now PASSES (not fails). Documents the R7→R9
architectural rewind.
- Added `CodexR9MutationTests` with two regression tests covering
R9-001 (audit-step bullet not equal to canonical passes) and
R9-002 (canonical-after-heading-no-blank-line fails).
Verified: 57/57 tests pass. 12/12 lint checks pass on clean working
tree. Two manual probes confirm both R9 attack/non-attack vectors
land correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1367 lines
64 KiB
Python
1367 lines
64 KiB
Python
#!/usr/bin/env python3
|
||
"""Static audit for ARS v3.6.7 downstream-agent pattern protection.
|
||
|
||
Spec: docs/design/2026-04-29-ars-v3.6.7-downstream-agent-pattern-protection-spec.md
|
||
|
||
Greps the v3.6.7 reference files, audit template, and downstream agent prompts
|
||
for the keywords and obligation phrases that make each pattern-protection
|
||
clause detectable. Static only — does not validate runtime behaviour.
|
||
Behavioural validation belongs to spec §9 step 8 (live pipeline evaluation
|
||
case) and is out of scope here.
|
||
|
||
Falsifiability discipline (per feedback_lint_passes_but_prompt_silent.md):
|
||
- Agent-prompt checks scope grep to the `PATTERN PROTECTION (v3.6.7)` block
|
||
via `block_marker`. A keyword that lands outside the block in unrelated
|
||
prose does not count toward passing.
|
||
- Obligation-bearing patterns (forbidden / required / only-if) are enforced
|
||
via `must_contain_regex` so the prohibition is grep-detectable as a
|
||
contiguous fragment, not as two unrelated nouns elsewhere in the file.
|
||
|
||
Exit codes: 0 on pass, 1 on any failure.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
REF_DIR = REPO_ROOT / "shared" / "references"
|
||
TPL_DIR = REPO_ROOT / "shared" / "templates"
|
||
AGENT_DIR = REPO_ROOT / "deep-research" / "agents"
|
||
|
||
SYNTHESIS_AGENT = AGENT_DIR / "synthesis_agent.md"
|
||
ARCHITECT_AGENT = AGENT_DIR / "research_architect_agent.md"
|
||
COMPILER_AGENT = AGENT_DIR / "report_compiler_agent.md"
|
||
|
||
# Markdown heading pattern that closes a `block_marker` scope. A check's scope
|
||
# starts at the marker and ends at the next H1/H2/H3 heading or EOF.
|
||
_HEADING_RE = re.compile(r"^#{1,3} ", re.MULTILINE)
|
||
|
||
# Negation / weakening patterns that, if present in the sentence containing
|
||
# an obligation match, indicate the obligation is being denied or weakened
|
||
# rather than asserted. R2-004 flagged "does not enumerate fully" / "not non-
|
||
# negotiable"; R3-002 expanded the list with should not / fails to / instead
|
||
# of / rarely / sometimes / is unable to. The patterns split into two groups:
|
||
#
|
||
# - _GENERAL_NEGATION_PATTERNS: DO NOT-style imperative prohibitions and
|
||
# weakening modals. These reject most obligations BUT they would also
|
||
# reject a legitimate prohibition like C3's "DO NOT simulate". For
|
||
# prohibition-style obligations, callers pass `allow_prohibition=True` to
|
||
# skip this group.
|
||
# - _ALWAYS_NEGATION_PATTERNS: weakening verbs and adverbs that never
|
||
# constitute a valid prohibition signal (rarely / sometimes / fails to /
|
||
# etc.). These apply regardless of `allow_prohibition`.
|
||
_GENERAL_NEGATION_PATTERNS = [
|
||
# Subject + auxiliary negation that directly weakens an obligation.
|
||
# These are the verb-negation forms most commonly used to undo a rule
|
||
# ("X does not Y", "X must not Y"). They are excluded by default but
|
||
# exempted for prohibition-style obligations like C3 via
|
||
# `allow_prohibition`.
|
||
re.compile(r"\bdoes not\b", re.IGNORECASE),
|
||
re.compile(r"\bdo not\b", re.IGNORECASE),
|
||
re.compile(r"\bDO NOT\b"), # case-sensitive imperative form
|
||
re.compile(r"\bdoesn'?t\b", re.IGNORECASE),
|
||
re.compile(r"\bdon'?t\b", re.IGNORECASE),
|
||
re.compile(r"\bshould not\b", re.IGNORECASE),
|
||
re.compile(r"\bshouldn'?t\b", re.IGNORECASE),
|
||
re.compile(r"\bmust not\b", re.IGNORECASE),
|
||
re.compile(r"\bmustn'?t\b", re.IGNORECASE),
|
||
# Adjective-targeted negations: explicit "not <obligation-adjective>"
|
||
# forms that directly negate the contract vocabulary.
|
||
re.compile(r"\bnot\s+(?:non[- ]negotiable|enumerate|required|mandatory|forbidden|verbatim|reserved)\b", re.IGNORECASE),
|
||
re.compile(r"\bneed not\b", re.IGNORECASE),
|
||
re.compile(r"\bno\s+buffer\b", re.IGNORECASE),
|
||
re.compile(r"\bno\s+enumeration\b", re.IGNORECASE),
|
||
# NOTE: `cannot` and `can't` are intentionally NOT in this list. They
|
||
# are routinely used in benign conditional phrasing ("if X cannot fit,
|
||
# the compiler reports..."), where `cannot` describes a trigger
|
||
# condition rather than weakening an obligation. The other negation
|
||
# patterns are tight enough to catch genuine weakening even without
|
||
# `cannot`.
|
||
]
|
||
# Modal weakeners (`may`, `should`, `can`) downgrade mandatory obligations
|
||
# to advisory. They are scoped to a verb list so the bare token does not
|
||
# match unrelated prose ("you may want to see references/...", "this can
|
||
# be useful"). The verb list covers imperatives the v3.6.7 agent prompts
|
||
# actually use.
|
||
_MODAL_WEAKENED_VERBS = (
|
||
r"(?:invite|enumerate|preserve|drop|skip|substitute|paraphrase"
|
||
r"|wrap|include|default|defaults?|run|use|declare|pass(?:\s+through)?"
|
||
r"|claim|cite|fall\s+back|be\s+quoted|be\s+permitted|use\s+chapter)"
|
||
)
|
||
|
||
_ALWAYS_NEGATION_PATTERNS = [
|
||
re.compile(r"\bisn'?t\b", re.IGNORECASE),
|
||
re.compile(r"\baren'?t\b", re.IGNORECASE),
|
||
# `optional` only counts as a weakener when it directly modifies an
|
||
# obligation noun. The bare token can appear in unrelated context
|
||
# ("optional `approved_synonyms` field"), so we narrow this rule to
|
||
# `optional <obligation-noun>` forms.
|
||
re.compile(r"\boptional\s+(?:buffer|enumeration|preservation|inclusion|verbatim|hedge|hedges|enforcement|requirement|reservation)\b", re.IGNORECASE),
|
||
# B2 R4-001: "X is recommended" / "are recommended" downgrades a
|
||
# mandatory obligation to advisory. The narrowed verb list ensures
|
||
# this only flags weakening of actual contract verbs / nouns.
|
||
re.compile(
|
||
r"\b(?:is|are)\s+recommended\b",
|
||
re.IGNORECASE,
|
||
),
|
||
# B2 R4-002 helper: "are allowed" / "is allowed" turns a forbidden
|
||
# operation into an exception. Covers C2's mutated "deictic phrases
|
||
# are allowed when shorter" and B5's "Over-setting ... are allowed"
|
||
# when those structures slip past the per-rule regex.
|
||
re.compile(r"\b(?:is|are)\s+allowed\b", re.IGNORECASE),
|
||
# Modal/advisory weakener coverage for the obligation verb list.
|
||
# B2 R4-001 added `may`; R5-001 added `should` / `can` /
|
||
# `is/are permitted`; R6-001 added the future/conditional modals
|
||
# (`will`, `would`, `ought to`) and the advisory adverb framings
|
||
# (`ideally`, `preferably`, `We recommend that`).
|
||
re.compile(rf"\bmay\s+(?:not\s+)?{_MODAL_WEAKENED_VERBS}\b", re.IGNORECASE),
|
||
re.compile(rf"\bshould\s+(?:not\s+)?{_MODAL_WEAKENED_VERBS}\b", re.IGNORECASE),
|
||
re.compile(rf"\bcan\s+(?:not\s+)?{_MODAL_WEAKENED_VERBS}\b", re.IGNORECASE),
|
||
# B2 R6-001: future-tense `will` / `will not` directly contradicts a
|
||
# mandatory obligation when paired with the obligation verb.
|
||
re.compile(rf"\bwill\s+(?:not\s+)?{_MODAL_WEAKENED_VERBS}\b", re.IGNORECASE),
|
||
# B2 R6-001: conditional `would` framings turn an imperative into a
|
||
# hypothetical ("Compression would preserve" instead of "must preserve").
|
||
re.compile(rf"\bwould\s+(?:not\s+)?{_MODAL_WEAKENED_VERBS}\b", re.IGNORECASE),
|
||
# B2 R6-001: `ought to` is a modal-equivalent advisory.
|
||
re.compile(rf"\bought\s+to\s+(?:not\s+)?{_MODAL_WEAKENED_VERBS}\b", re.IGNORECASE),
|
||
# B2 R6-001: advisory adverb framings. "Ideally include X" or
|
||
# "Preferably enumerate fully" downgrades the obligation. The verb
|
||
# boundary is required so bare adverbs in unrelated context don't
|
||
# trigger.
|
||
re.compile(rf"\bideally\s+{_MODAL_WEAKENED_VERBS}\b", re.IGNORECASE),
|
||
re.compile(rf"\bpreferably\s+{_MODAL_WEAKENED_VERBS}\b", re.IGNORECASE),
|
||
# B2 R6-001: "We recommend that ..." reframing makes the obligation
|
||
# advisory. Anchor on the recommend-that structure to avoid false
|
||
# positives on legitimate references that just contain "recommend".
|
||
re.compile(r"\bWe\s+recommend\s+that\b", re.IGNORECASE),
|
||
# B2 R5-001: "is/are permitted" turns a forbidden operation into an
|
||
# exception ("over-setting is permitted when concise"). Mirrors the
|
||
# `is/are allowed` pattern.
|
||
re.compile(r"\b(?:is|are)\s+permitted\b", re.IGNORECASE),
|
||
re.compile(r"\bfails? to\b", re.IGNORECASE),
|
||
re.compile(r"\binstead of\b", re.IGNORECASE),
|
||
# `rarely` / `sometimes` / `occasionally` similarly need to be near
|
||
# an obligation verb to count as weakeners.
|
||
re.compile(r"\brarely\s+(?:enumerate|enforce|invoke|reserve|preserve|verify)", re.IGNORECASE),
|
||
re.compile(r"\bsometimes\s+(?:enumerate|enforce|invoke|reserve|preserve|verify)", re.IGNORECASE),
|
||
re.compile(r"\boccasionally\s+(?:enumerate|enforce|invoke|reserve|preserve|verify)", re.IGNORECASE),
|
||
re.compile(r"\bis unable to\b", re.IGNORECASE),
|
||
re.compile(r"\bare unable to\b", re.IGNORECASE),
|
||
re.compile(r"\bonly when convenient\b", re.IGNORECASE),
|
||
re.compile(r"\bif (?:space|time) (?:allows|permits)\b", re.IGNORECASE),
|
||
# Feasibility qualifiers (R6-001). "when possible" / "where possible"
|
||
# /etc. tail-attached to an obligation phrase silently downgrades it
|
||
# from mandatory to best-effort.
|
||
re.compile(r"\bwhen\s+possible\b", re.IGNORECASE),
|
||
re.compile(r"\bwhere\s+possible\b", re.IGNORECASE),
|
||
re.compile(r"\bwherever\s+feasible\b", re.IGNORECASE),
|
||
re.compile(r"\bif\s+practical\b", re.IGNORECASE),
|
||
re.compile(r"\bif\s+feasible\b", re.IGNORECASE),
|
||
re.compile(r"\bbest[- ]effort\b", re.IGNORECASE),
|
||
# Exception qualifiers (B2 R3-003). "except" / "unless" / "save when"
|
||
# carve out a hole in an otherwise-mandatory rule. Mutation evidence:
|
||
# "No subsetting except when concise" silently weakens B5. These are
|
||
# treated as always-rejecting because no legitimate v3.6.7 contract
|
||
# rule carries an opt-out clause; if a future spec rule needs a
|
||
# genuine exception (e.g. "X is forbidden, except in mode Y"), the
|
||
# check for that rule should structure the obligation regex to
|
||
# demand both halves explicitly rather than relying on free prose.
|
||
re.compile(r"\bexcept\s+(?:when|if|in|for|as|where)\b", re.IGNORECASE),
|
||
re.compile(r"\bunless\b", re.IGNORECASE),
|
||
re.compile(r"\bsave\s+when\b", re.IGNORECASE),
|
||
]
|
||
|
||
|
||
# Imperative/auxiliary verb-negation tokens that, when allow_prohibition is
|
||
# set, MAY appear in the matched obligation span but must still be rejected
|
||
# anywhere else in the bullet window. This is the "span-restricted exemption"
|
||
# (B2 R3-001): the obligation's own prohibition vocabulary ("DO NOT simulate",
|
||
# "must not claim audit-passed state", "does not paraphrase") rides verbatim
|
||
# in the matched span; a *second* prohibition elsewhere in the same bullet
|
||
# (e.g. trailing "this must not be enforced") still indicates weakening.
|
||
_PROHIBITION_VERB_PATTERN_TEXTS = {
|
||
r"\bDO NOT\b",
|
||
r"\bdo not\b",
|
||
r"\bdoes not\b",
|
||
r"\bdoesn'?t\b",
|
||
r"\bdon'?t\b",
|
||
r"\bmust not\b",
|
||
r"\bmustn'?t\b",
|
||
}
|
||
|
||
|
||
def _match_excludes_negation(
|
||
text_window: str,
|
||
allow_prohibition: bool = False,
|
||
matched_span: tuple[int, int] | None = None,
|
||
) -> bool:
|
||
"""Return True if the bullet window around an obligation match does NOT
|
||
contain any negation that would weaken it.
|
||
|
||
`allow_prohibition=True` exempts the literal prohibition tokens
|
||
(DO NOT / do not / does not / must not / etc.) **only when they fall
|
||
inside the matched obligation span**. A trailing "this must not be
|
||
enforced" outside the matched span still rejects (B2 R3-001).
|
||
|
||
The `_ALWAYS_NEGATION_PATTERNS` (rarely, sometimes, fails to, optional,
|
||
except, unless, etc.) apply regardless because no legitimate obligation
|
||
framing should rely on those.
|
||
|
||
`matched_span` is `(start, end)` relative to `text_window`. When None
|
||
or `allow_prohibition=False`, prohibition tokens anywhere in the
|
||
window count as weakeners.
|
||
"""
|
||
if any(p.search(text_window) for p in _ALWAYS_NEGATION_PATTERNS):
|
||
return False
|
||
for p in _GENERAL_NEGATION_PATTERNS:
|
||
is_prohibition_pattern = p.pattern in _PROHIBITION_VERB_PATTERN_TEXTS
|
||
for hit in p.finditer(text_window):
|
||
if (
|
||
allow_prohibition
|
||
and is_prohibition_pattern
|
||
and matched_span is not None
|
||
and hit.start() >= matched_span[0]
|
||
and hit.end() <= matched_span[1]
|
||
):
|
||
# Prohibition token sits inside the matched obligation
|
||
# span — it is the obligation's own vocabulary, not a
|
||
# weakener. Skip this hit.
|
||
continue
|
||
return False
|
||
return True
|
||
|
||
|
||
@dataclass
|
||
class Check:
|
||
pattern_id: str
|
||
description: str
|
||
target: Path
|
||
must_contain: list[str] = field(default_factory=list)
|
||
must_contain_regex: list[tuple[str, str] | tuple[str, str, bool]] = field(default_factory=list)
|
||
"""Each entry is `(label, pattern)` or `(label, pattern, allow_prohibition)`.
|
||
`allow_prohibition` is per-regex (not per-Check) so a check can mix
|
||
prohibition-style obligations (REF-3 verbatim preservation, C3 anti-
|
||
fake-audit guard, C3 audit-passed metadata) with assertion-style
|
||
obligations (C1 protected hedges non-negotiable) without the
|
||
prohibition exemption leaking across regexes (B2 codex R2-001)."""
|
||
block_marker: str | None = None
|
||
"""If set, scope all keyword/regex checks to the text between the marker
|
||
and the next H1/H2/H3 heading. Use for agent-prompt checks where the
|
||
PATTERN PROTECTION clause must be inside its own block, not scattered
|
||
elsewhere in the file."""
|
||
|
||
def _scoped_text(self, full_text: str) -> tuple[str | None, str]:
|
||
"""Return (scoped_text, error_message). scoped_text is None on failure."""
|
||
if self.block_marker is None:
|
||
return full_text, ""
|
||
marker_pos = full_text.lower().find(self.block_marker.lower())
|
||
if marker_pos == -1:
|
||
return None, f"block marker missing: {self.block_marker!r}"
|
||
# Find next heading after the marker; scope ends there.
|
||
rest = full_text[marker_pos:]
|
||
match = _HEADING_RE.search(rest, pos=len(self.block_marker))
|
||
scoped_end = marker_pos + (match.start() if match else len(rest))
|
||
return full_text[marker_pos:scoped_end], ""
|
||
|
||
def _display_path(self) -> str:
|
||
try:
|
||
return str(self.target.relative_to(REPO_ROOT))
|
||
except ValueError:
|
||
return str(self.target)
|
||
|
||
def run(self) -> tuple[bool, str]:
|
||
display = self._display_path()
|
||
if not self.target.exists():
|
||
return False, f"target file missing: {display}"
|
||
full = self.target.read_text(encoding="utf-8")
|
||
scoped, err = self._scoped_text(full)
|
||
if scoped is None:
|
||
return False, f"{display}: {err}"
|
||
scoped_lower = scoped.lower()
|
||
missing_substr = [s for s in self.must_contain if s.lower() not in scoped_lower]
|
||
missing_regex = []
|
||
for entry in self.must_contain_regex:
|
||
if len(entry) == 2:
|
||
label, pattern = entry
|
||
allow_prohibition = False
|
||
else:
|
||
label, pattern, allow_prohibition = entry
|
||
# First try to find an obligation match.
|
||
match = re.search(pattern, scoped, re.IGNORECASE | re.DOTALL)
|
||
if match is None:
|
||
missing_regex.append(label)
|
||
continue
|
||
# Reject if the bullet/paragraph containing the match carries
|
||
# a negation that weakens the obligation (per R2-004 + R3-001
|
||
# + R4-001). The check looks backward from the match start to
|
||
# the start of the current bullet (or paragraph start) and
|
||
# forward from the match end to the next bullet (or blank line
|
||
# / EOF). This catches trailing weakeners like
|
||
# "...obligation. This is not required."
|
||
iterator = re.finditer(pattern, scoped, re.IGNORECASE | re.DOTALL)
|
||
accepted = False
|
||
for m in iterator:
|
||
start, end = m.start(), m.end()
|
||
# Lookback to the start of this bullet/paragraph: previous
|
||
# blank line, list-bullet marker on its own line, or start
|
||
# of scoped text. Capped at 400 chars so very long bullets
|
||
# do not pull in unrelated negation from far above.
|
||
lookback_floor = max(0, start - 400)
|
||
lookback = scoped[lookback_floor:start]
|
||
blank_line = lookback.rfind("\n\n")
|
||
bullet_back_match = list(re.finditer(r"\n\s*-\s", lookback))
|
||
bullet_back = bullet_back_match[-1].start() if bullet_back_match else -1
|
||
last_break_back = max(blank_line, bullet_back)
|
||
bullet_start = (
|
||
lookback_floor + last_break_back + 1
|
||
if last_break_back >= 0
|
||
else lookback_floor
|
||
)
|
||
# Lookahead to the start of the next bullet, blank line,
|
||
# or EOF (capped 400 chars).
|
||
lookahead_ceiling = min(len(scoped), end + 400)
|
||
lookahead = scoped[end:lookahead_ceiling]
|
||
blank_fwd = lookahead.find("\n\n")
|
||
bullet_fwd_match = re.search(r"\n\s*-\s", lookahead)
|
||
bullet_fwd = bullet_fwd_match.start() if bullet_fwd_match else -1
|
||
fwd_breaks = [i for i in (blank_fwd, bullet_fwd) if i >= 0]
|
||
next_break = min(fwd_breaks) if fwd_breaks else -1
|
||
bullet_end = end + next_break if next_break >= 0 else lookahead_ceiling
|
||
window = scoped[bullet_start:bullet_end]
|
||
# Span of THIS obligation match within the window, so the
|
||
# negation filter can distinguish prohibition vocabulary
|
||
# that is the obligation itself ("DO NOT simulate") from
|
||
# a SECOND prohibition elsewhere in the bullet that
|
||
# weakens it (B2 R3-001).
|
||
match_in_window = (start - bullet_start, end - bullet_start)
|
||
if _match_excludes_negation(
|
||
window,
|
||
allow_prohibition=allow_prohibition,
|
||
matched_span=match_in_window,
|
||
):
|
||
accepted = True
|
||
break
|
||
if not accepted:
|
||
missing_regex.append(f"{label} (only negated forms found)")
|
||
problems = []
|
||
if missing_substr:
|
||
problems.append(f"missing keywords: {missing_substr}")
|
||
if missing_regex:
|
||
problems.append(f"missing obligation phrases: {missing_regex}")
|
||
if problems:
|
||
scope_note = f" within {self.block_marker!r} block" if self.block_marker else ""
|
||
return False, f"{display}{scope_note}: {'; '.join(problems)}"
|
||
return True, "OK"
|
||
|
||
|
||
def reference_file_checks() -> list[Check]:
|
||
"""Spec §7.1 — 4 reference files. No block scoping; whole-file grep."""
|
||
return [
|
||
Check(
|
||
pattern_id="REF-1 (B1)",
|
||
description="irb_terminology_glossary covers 4 IRB terms with operational distinctions",
|
||
target=REF_DIR / "irb_terminology_glossary.md",
|
||
must_contain=[
|
||
"Anonymity",
|
||
"Confidentiality",
|
||
"De-identification",
|
||
"Pseudonymization",
|
||
],
|
||
),
|
||
Check(
|
||
pattern_id="REF-2 (B2)",
|
||
description="psychometric_terminology_glossary distinguishes true reverse-coded vs contrast",
|
||
target=REF_DIR / "psychometric_terminology_glossary.md",
|
||
must_contain=[
|
||
"true reverse-coded",
|
||
"contrast item",
|
||
"acquiescence",
|
||
"recall bias",
|
||
],
|
||
),
|
||
Check(
|
||
pattern_id="REF-3 (C1)",
|
||
description="protected_hedging_phrases defines upstream-marked hedge protocol with 5 contract rules",
|
||
target=REF_DIR / "protected_hedging_phrases.md",
|
||
must_contain=[
|
||
"protected hedging phrases",
|
||
"upstream calibration",
|
||
"word budget",
|
||
],
|
||
must_contain_regex=[
|
||
# Rule 1: conservative inclusion — "include the phrase" is
|
||
# the operative directive (R5-002 mutation showed
|
||
# "ask upstream for advice" was previously accepted).
|
||
(
|
||
"Rule: Conservative inclusion",
|
||
r"\bConservative inclusion\b.{0,200}\bWhen in doubt\b.{0,150}\binclude\s+the\s+phrase\b",
|
||
),
|
||
# Rule 2: anchor every entry — "where … and why" is the
|
||
# specific obligation; bare "must cite" was previously
|
||
# accepted with weakened content.
|
||
(
|
||
"Rule: Anchor every entry (where + why)",
|
||
r"\bAnchor every entry\b.{0,200}\bmust\s+cite\s+where\b.{0,200}\bwhy\b",
|
||
),
|
||
# Rule 3: no duplicates — bullet body must assert
|
||
# "One entry per phrase" then back-reference the count.
|
||
# R5-002 showed bare "One entry per phrase" alone could be
|
||
# suffixed with "is optional" and still pass; require the
|
||
# full "One entry per phrase. The compiler counts ... once"
|
||
# sequence so a tail weakener cannot land between them.
|
||
# Allow Markdown markup (`**`) between the heading word and
|
||
# the body sentence.
|
||
(
|
||
"Rule: No duplicates",
|
||
r"\bNo duplicates\b[\s.*\-]{0,10}One entry per phrase\.\s+The compiler counts[^.]{0,150}\bonce\b",
|
||
),
|
||
# Rule 4: verbatim preservation — both the title and the
|
||
# imperative body ("does not paraphrase") must appear. The
|
||
# body uses "does not paraphrase / substitute" as the
|
||
# prohibition expressing the obligation, so this specific
|
||
# regex needs prohibition exemption (was per-Check before
|
||
# B2 R2-001; now per-regex so other regexes in this Check
|
||
# still reject inverted forms).
|
||
(
|
||
"Rule: Verbatim preservation",
|
||
r"\bVerbatim preservation\b.{0,250}\brides\s+verbatim\b.{0,300}\bdoes\s+not\s+paraphrase\b",
|
||
True, # allow_prohibition: prohibition-style obligation body
|
||
),
|
||
# Rule 5: failure surface — must say "rather than dropping",
|
||
# not "while dropping" (R5-002 mutation flipped this).
|
||
(
|
||
"Rule: Conflict reporting (no silent drop)",
|
||
r"\breports the conflict\b[^.\n]{0,100}\brather than\s+dropping a protected hedge\b",
|
||
),
|
||
],
|
||
),
|
||
Check(
|
||
pattern_id="REF-4 (word-count)",
|
||
description="word_count_conventions specifies whitespace-split + 3-5% buffer",
|
||
target=REF_DIR / "word_count_conventions.md",
|
||
must_contain=[
|
||
"whitespace",
|
||
"split()",
|
||
"3–5%",
|
||
"hyphenated",
|
||
],
|
||
),
|
||
]
|
||
|
||
|
||
def template_file_checks() -> list[Check]:
|
||
"""Spec §7.2 — audit prompt template."""
|
||
return [
|
||
Check(
|
||
pattern_id="TPL-1 (D1)",
|
||
description="codex_audit_multifile_template enumerates 7 audit dimensions",
|
||
target=TPL_DIR / "codex_audit_multifile_template.md",
|
||
must_contain=[
|
||
"cross-ref",
|
||
"hallucination",
|
||
"primary-source integrity",
|
||
"internal coherence",
|
||
"instrument quality",
|
||
"Round-N framing",
|
||
"COI adequacy",
|
||
],
|
||
),
|
||
# The report_compiler bundle's Section 4(f) is mandatory three-part:
|
||
# (i) word-count cap-minus-buffer, (ii) protected-hedge verbatim,
|
||
# (iii) abstract no less hedged than body. R1-006 upgraded this from
|
||
# an example to a mandatory contract; lint must verify the contract
|
||
# rides verbatim in the template, not just the prose around it.
|
||
# Scoped to the `report_compiler_agent bundle` clause so scattered
|
||
# text elsewhere in the template cannot satisfy the contract (R3-004).
|
||
Check(
|
||
pattern_id="TPL-2 (4f-compiler)",
|
||
description="audit template encodes mandatory three-part (f) check for report_compiler bundles",
|
||
target=TPL_DIR / "codex_audit_multifile_template.md",
|
||
block_marker="report_compiler_agent bundle (mandatory three-part check)",
|
||
must_contain_regex=[
|
||
# Sub-check (i): whitespace-split cap minus 3-5% buffer
|
||
(
|
||
"4f sub-check (i) word-count algorithm + buffer",
|
||
r"len\(body\.split\(\)\).{0,200}\b3[-–]5%\s+buffer\b",
|
||
),
|
||
# Sub-check (ii): protected_hedges appear verbatim
|
||
# unconditionally. R5-003 mutation showed bare
|
||
# "appear verbatim when possible" was previously accepted;
|
||
# the obligation must be unconditional ("appears verbatim
|
||
# in the abstract" without "when possible" / "if space").
|
||
# The negation post-filter already rejects "when convenient"
|
||
# / "if space allows", so the regex requires the
|
||
# imperative-tense phrasing.
|
||
(
|
||
"4f sub-check (ii) protected_hedges verbatim (unconditional)",
|
||
r"every entry of upstream\s+`?protected_hedges`?[^\n]{0,200}\bappears? verbatim in the abstract\b",
|
||
),
|
||
# Sub-check (iii): "no claim in the abstract is less hedged
|
||
# than its anchor in the body" — anchored on "no claim" so
|
||
# an inverted form ("every claim ... is less hedged") fails.
|
||
(
|
||
"4f sub-check (iii) less-hedged-than-body prohibition",
|
||
r"\bno claim in the abstract is less hedged than its anchor in the body\b",
|
||
),
|
||
# P1 severity assignment for any sub-check failure
|
||
(
|
||
"4f failures severity P1",
|
||
r"\bFailure of any sub-check is a P1 finding\b",
|
||
),
|
||
],
|
||
),
|
||
]
|
||
|
||
|
||
# Block marker every agent-prompt check scopes to. Defined once so the value
|
||
# stays in sync across agents.
|
||
PROTECTION_BLOCK = "PATTERN PROTECTION (v3.6.7)"
|
||
|
||
|
||
def synthesis_agent_checks() -> list[Check]:
|
||
"""Spec §6.1 — synthesis_agent A1-A5 protection.
|
||
|
||
Scoped to the PATTERN PROTECTION (v3.6.7) block so keyword presence
|
||
elsewhere in the agent prompt does not count toward passing. All five
|
||
rules use must_contain_regex (not raw must_contain tokens) so the
|
||
weakening filter rejects mutations like "pending verification language
|
||
is optional" (B2 R3-002).
|
||
"""
|
||
return [
|
||
Check(
|
||
pattern_id="A1-A5",
|
||
description="synthesis_agent carries 5 narrative-side protection clauses",
|
||
target=SYNTHESIS_AGENT,
|
||
block_marker=PROTECTION_BLOCK,
|
||
must_contain_regex=[
|
||
# A1 — cross-section consistency self-check. Bullet must
|
||
# carry both the imperative pre-list step and the
|
||
# consistency self-check before output. "are recommended"
|
||
# / "may run" / "optional" weakeners are caught by the
|
||
# negation filter (verb list + `is/are recommended` in
|
||
# _ALWAYS_NEGATION_PATTERNS, B2 R4-001 expansion).
|
||
(
|
||
"A1 effect inventory pre-list + cross-section self-check before output",
|
||
r"\bpre-list\b[^.\n]{0,200}\beffect inventory\b[^.\n]{0,200}\brun\s+a\s+cross-section consistency self-check\b[^.\n]{0,100}\bbefore output\b",
|
||
),
|
||
# A2 — pending-verification hedge. "wrap claims in explicit
|
||
# hedge" is the imperative; mutating to "may wrap" or
|
||
# "pending verification language is optional" trips the
|
||
# may-verb weakener (R4-001) and the optional-noun weakener
|
||
# respectively.
|
||
(
|
||
"A2 pending-verification hedge wrap",
|
||
r"\bpending verification\b[^.\n]{0,200}\bwrap claims\b[^.\n]{0,200}\bexplicit hedge\b",
|
||
),
|
||
# A3 — anchor justification. "include a one-line anchor
|
||
# justification" is the imperative; "may include" trips
|
||
# the may-verb weakener (R4-001).
|
||
(
|
||
"A3 one-line anchor justification (include)",
|
||
r"\binclude\s+a\s+one[- ]line\s+anchor justification\b",
|
||
),
|
||
# A4 — quote scope boundary AND surrounding-context handling.
|
||
# Spec §6.1 says "surrounding context paraphrased and
|
||
# unquoted". Without enforcing the second clause, an agent
|
||
# can satisfy "Verbatim quotes only within ..." while
|
||
# mutating context handling to "may be quoted" (R4-002).
|
||
# Two regexes so dropping either half fails lint.
|
||
(
|
||
"A4 verbatim quotes only within verified phrase boundary",
|
||
r"\bVerbatim quotes\s+only\s+within\s+the\s+verified phrase boundary\b",
|
||
),
|
||
(
|
||
"A4 surrounding context paraphrased and unquoted",
|
||
r"\bsurrounding context\s+paraphrased\s+and\s+unquoted\b",
|
||
),
|
||
# A5 — declarative claims about un-provided documents are
|
||
# forbidden AND the conditional-language fallback is
|
||
# required. Spec §6.1 pairs the two: conditional language
|
||
# / explicit gap acknowledgment is the constructive duty,
|
||
# the prohibition is the negative duty. Both must ride.
|
||
(
|
||
"A5 conditional language fallback for un-provided documents",
|
||
# Bullet contains "e.g., ..." parenthetical, so match
|
||
# across periods within the bullet line.
|
||
r"\bun-provided[^\n]{0,300}\buse\s+conditional language\b[^\n]{0,300}\bexplicit gap acknowledgment\b",
|
||
),
|
||
(
|
||
"A5 sentence-bounded injunction",
|
||
r"declarative claims? about un-provided[^.\n]{0,200}\bare forbidden\b",
|
||
),
|
||
],
|
||
)
|
||
]
|
||
|
||
|
||
def architect_agent_checks() -> list[Check]:
|
||
"""Spec §6.2 — research_architect_agent (survey designer mode) B1-B5
|
||
protection. B1, B2, B5 use must_contain_regex with imperative verbs
|
||
anchored so a mutation like "passing through the IRB glossary is
|
||
optional" or "construct equivalence justification is recommended"
|
||
is rejected by the weakening filter (B2 R3-002).
|
||
"""
|
||
return [
|
||
Check(
|
||
pattern_id="B1-B5",
|
||
description="research_architect_agent (survey designer) carries 5 instrument-side protection clauses",
|
||
target=ARCHITECT_AGENT,
|
||
block_marker=PROTECTION_BLOCK,
|
||
must_contain_regex=[
|
||
# B1 — IRB terminology pass-through. "must pass through ...
|
||
# before output" is the imperative; the glossary back-pointer
|
||
# is part of the obligation.
|
||
(
|
||
"B1 IRB terminology pass-through (must, before output)",
|
||
r"\bmust\s+pass\s+through\b[^.\n]{0,200}\birb_terminology_glossary\.md\b[^.\n]{0,200}\bbefore output\b",
|
||
),
|
||
# B2 — reverse-coded construct equivalence. "include a one-
|
||
# line construct-equivalence justification" is the imperative
|
||
# that survives mutations like "construct-equivalence
|
||
# justification is recommended".
|
||
(
|
||
"B2 reverse-coded construct-equivalence justification",
|
||
r"\breverse-coded\b[^.\n]{0,200}\binclude\s+a\s+one[- ]line\s+construct-equivalence justification\b",
|
||
),
|
||
# B3 — retrospective items default to event-anchored;
|
||
# calendar-anchored is conditional on a shared event date.
|
||
# Spec wording naturally spans two sentences ("default to..."
|
||
# then "calendar... only when..."), so the gap allows one
|
||
# sentence boundary; the 'only when' tail must sit in the
|
||
# same sentence as 'calendar-anchored' to bind the conditional.
|
||
(
|
||
"B3 retrospective default + calendar conditional",
|
||
r"event-anchored[^.\n]{0,200}\.[^.\n]{0,100}\bcalendar[- ]anchored[^.\n]{0,200}\bonly when\b",
|
||
),
|
||
# B4 — three-part obligation per spec §6.2:
|
||
# (i) item phrasing must be neutral/balanced,
|
||
# (ii) chapter argument vocabulary forbidden in items,
|
||
# (iii) open-text prompts invite all valences.
|
||
# Splitting into three regexes so dropping any one half
|
||
# fails lint (R4-002 mutation evidence: "may use chapter
|
||
# argument vocabulary" replaced (i)+(ii) and bare valences
|
||
# match still passed).
|
||
(
|
||
"B4 item phrasing neutral/balanced (must)",
|
||
r"\bItem phrasing\s+must\s+be\s+neutral[/-]balanced\b",
|
||
),
|
||
(
|
||
"B4 chapter argument vocabulary forbidden",
|
||
r"\bChapter argument vocabulary\s+is\s+forbidden\s+in\s+instrument items\b",
|
||
),
|
||
(
|
||
"B4 open-text invites all valences",
|
||
r"\b(?:all valences|positive,? negative,? (?:or|and) neutral)\b",
|
||
),
|
||
# B5 — option lists must declare primary-source list AND
|
||
# enumerate fully AND prohibit subsetting AND prohibit
|
||
# over-setting AND prohibit scope cross-contamination.
|
||
# All five are the contract per spec §6.2; matching any
|
||
# subset is a silent gap (R4-002 mutation: "Over-setting
|
||
# and scope cross-contamination are allowed" passed even
|
||
# though "No subsetting" remained).
|
||
(
|
||
"B5 primary-source list + enumerate fully",
|
||
r"\bprimary-source list\b[^.\n]{0,100}\benumerate(?:s|d)?\s+fully\b",
|
||
),
|
||
(
|
||
"B5 no subsetting prohibition",
|
||
r"\bno\s+subsetting\b",
|
||
),
|
||
(
|
||
"B5 no over-setting prohibition",
|
||
r"\bno\s+over-setting\b",
|
||
),
|
||
(
|
||
"B5 no scope cross-contamination prohibition",
|
||
r"\bno\s+scope cross-contamination\b",
|
||
),
|
||
],
|
||
)
|
||
]
|
||
|
||
|
||
def compiler_agent_checks() -> list[Check]:
|
||
"""Spec §6.3 — report_compiler_agent (abstract-only mode) C1-C3 protection."""
|
||
return [
|
||
Check(
|
||
pattern_id="C1-C3",
|
||
description="report_compiler_agent (abstract-only) carries 3 publication-side protection clauses incl. anti-fake-audit guard",
|
||
target=COMPILER_AGENT,
|
||
block_marker=PROTECTION_BLOCK,
|
||
must_contain_regex=[
|
||
# C1 word-count algorithm + buffer. Spec §6.3 pairs the
|
||
# whitespace-split rule with "Reserve 3–5% buffer below
|
||
# hard cap"; both must ride. R4-002 mutation showed
|
||
# buffer deletion still passed.
|
||
(
|
||
"C1 whitespace-split convention (uses)",
|
||
r"\bWord budget\s+uses\s+whitespace-split\s+convention\b",
|
||
),
|
||
(
|
||
"C1 reserve 3-5% buffer below hard cap",
|
||
r"\bReserve\s+3[-–]5%\s+buffer\s+below\s+hard cap\b",
|
||
),
|
||
# C2 temporal disambiguation: imperative + full triple of
|
||
# acceptable forms (year range, past-tense disambiguating
|
||
# verb, "former" prefix). R4-002 evidence: dropping the
|
||
# past-tense + former prefix still passed because only
|
||
# "explicit year range" was lint-required.
|
||
(
|
||
"C2 reflexivity disclosure must use explicit year range",
|
||
r"\bReflexivity disclosure\s+must\s+use\s+explicit temporal bounds\b[^.\n]{0,200}\bexplicit year range\b",
|
||
),
|
||
(
|
||
"C2 past-tense disambiguating verb form",
|
||
r"\bpast-tense disambiguating verb\b",
|
||
),
|
||
(
|
||
"C2 'former' prefix form",
|
||
r"['\"]former['\"]\s+prefix\b",
|
||
),
|
||
# C2 deictic forbidden — paired with above so a mutation
|
||
# that drops the prohibition still fails.
|
||
(
|
||
"C2 deictic temporal phrases forbidden",
|
||
r"\bDeictic temporal phrases\b[^.\n]{0,200}\bare forbidden\b",
|
||
),
|
||
# C1 — protected hedges are budget-protected / non-negotiable
|
||
# / verbatim. Sentence-bounded; negation post-filter rejects
|
||
# "are not non-negotiable" (R2-004) AND must reject inverted
|
||
# "Compression must not preserve protected hedging phrases"
|
||
# (B2 R2-001), so this regex does NOT take prohibition
|
||
# exemption — it is an assertion-style obligation.
|
||
(
|
||
"C1 protected hedges non-negotiable",
|
||
r"protected\s+hedg(?:e|ing)\s+phrases[^.\n]{0,200}\b(?:budget[- ]protected|non-negotiable|verbatim)\b",
|
||
),
|
||
# C3 — canonical Clause 1 line per Step 6 §6.2. The line is
|
||
# one bullet carrying three prohibitions in fixed order:
|
||
# "DO NOT simulate any audit step. DO NOT claim to have run
|
||
# codex/external review. Output metadata must not claim
|
||
# audit-passed state." Phase 6.7 merged the prior two C3
|
||
# regexes (anti-fake-audit pair + output-metadata) into a
|
||
# single line so the bullet is whole-line verbatim — INV-1
|
||
# below enforces presence/uniqueness across all three
|
||
# in-scope prompts; this regex stays here to keep C1-C3
|
||
# mutation coverage intact (R2-001 inverted-must-not, R3-001
|
||
# trailing weakener, R4-001 advisory framing). Pattern is
|
||
# whole-line so all three prohibition tokens (DO NOT × 2 +
|
||
# must not) sit inside the matched span and the negation
|
||
# post-filter does not flag a sibling prohibition as a
|
||
# weakener (B2 R3-001 span-restricted exemption).
|
||
(
|
||
"C3 canonical Clause 1 line (whole-line verbatim)",
|
||
# Whitespace-tolerant: every inter-token space inside
|
||
# the canonical line accepts `\s+` so a Markdown
|
||
# soft-wrap anywhere inside a sentence (e.g. between
|
||
# `run` and `codex/external`) is treated as the
|
||
# same canonical bullet — matches INV-1's
|
||
# whitespace-normalization contract (codex R5 P2
|
||
# closure). The trailing `\.(?=\s|\Z)` anchor still
|
||
# rejects tail weakeners like ` if feasible.` (R1
|
||
# P2 closure).
|
||
r"\bDO\s+NOT\s+simulate\s+any\s+audit\s+step\.\s+"
|
||
r"DO\s+NOT\s+claim\s+to\s+have\s+run\s+codex/external\s+review\.\s+"
|
||
r"Output\s+metadata\s+must\s+not\s+claim\s+audit-passed\s+state\.(?=\s|\Z)",
|
||
True, # allow_prohibition: this IS the prohibition
|
||
),
|
||
],
|
||
)
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Inversion sweep checks (Phase 6.7 — spec §6 partial inversion rule)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Spec §6.3 defines INV-1/INV-2/INV-3 as the lint enforcement of the
|
||
# §6.2 sweep. These run alongside the keyword/regex Check pipeline above
|
||
# but operate at file-list granularity (manifest-driven) rather than
|
||
# per-Check, so they are implemented as standalone functions returning
|
||
# (pattern_id, description, ok, message) tuples.
|
||
#
|
||
# - INV-1: each manifest file's PATTERN PROTECTION block carries the
|
||
# canonical Clause 1 line exactly once (presence + uniqueness).
|
||
# - INV-2: each manifest file's PATTERN PROTECTION block contains zero
|
||
# sentences matching any of the four Clause 2 violation patterns.
|
||
# - INV-3: no agent prompt file outside the manifest carries the canonical
|
||
# Clause 1 line (defends against accidental sweep widening per §9 L2).
|
||
|
||
INVERSION_MANIFEST = REPO_ROOT / "scripts" / "v3_6_7_inversion_manifest.json"
|
||
|
||
# Canonical Clause 1 line, byte-aligned with spec §6.2 line 1767. Whitespace
|
||
# inside is tolerant (collapse whitespace runs to a single space) so a future
|
||
# Markdown reflow that wraps the bullet across two lines does not break the
|
||
# match; presence is what matters.
|
||
CANONICAL_CLAUSE_1_TEXT = (
|
||
"DO NOT simulate any audit step. "
|
||
"DO NOT claim to have run codex/external review. "
|
||
"Output metadata must not claim audit-passed state."
|
||
)
|
||
|
||
# Spec §6.3 INV-2 regex set (a)-(d). Patterns are written as Python regex
|
||
# literals here (`|` for alternation, no Markdown escaping) — the spec
|
||
# table renders them as `\|` because raw `|` is the Markdown table-column
|
||
# delimiter; the lint reads the underlying regex, not the rendered cell.
|
||
# All four compile with re.IGNORECASE only — DOTALL is not needed because
|
||
# patterns are applied to bullet text after whitespace normalization
|
||
# (newlines collapsed to single spaces), which preserves Markdown
|
||
# soft-wrap tolerance (codex R1 P2 closure) while bounding the `.*`
|
||
# wildcard to a single bullet (codex R2 P2 closure: prior IGNORECASE |
|
||
# DOTALL applied to raw block text let INV-2(a)/(b) match across
|
||
# unrelated bullets).
|
||
INV2_PATTERNS = [
|
||
("INV-2(a)", re.compile(r"\bthe orchestrator\b.*\baudit\b", re.IGNORECASE)),
|
||
(
|
||
"INV-2(b)",
|
||
re.compile(
|
||
r"\bcross-model audit (?:follows|covers)\b.*codex_audit_multifile_template",
|
||
re.IGNORECASE,
|
||
),
|
||
),
|
||
(
|
||
"INV-2(c)",
|
||
re.compile(
|
||
r"\baudit (?:afterwards?|will be run|is dispatched)\b",
|
||
re.IGNORECASE,
|
||
),
|
||
),
|
||
(
|
||
"INV-2(d)",
|
||
re.compile(
|
||
r"\bdownstream audit\b|\bthis output (?:is|will be) audited\b",
|
||
re.IGNORECASE,
|
||
),
|
||
),
|
||
]
|
||
|
||
# Frozen v3.6.7 manifest contents per spec §6.3. The manifest file at
|
||
# scripts/v3_6_7_inversion_manifest.json is the data; this constant is
|
||
# the schema's expected value, used by `_load_inversion_manifest` to
|
||
# refuse drifted manifests at lint time. Spec §6.3 line 1807 reserves
|
||
# manifest widening for explicit v3.6.8+ work that lands "its own
|
||
# version-tagged manifest rather than retroactively widening v3.6.7's";
|
||
# this constant locks the v3.6.7 scope so a copy-paste widening
|
||
# (codex R1 P2 closure: add fourth file + canonical bullet → INV-1
|
||
# passes for all 4, INV-3 skips the new file because it is in
|
||
# manifest_set, full pass) can no longer slip past lint.
|
||
EXPECTED_MANIFEST_FILES = (
|
||
"deep-research/agents/synthesis_agent.md",
|
||
"deep-research/agents/research_architect_agent.md",
|
||
"deep-research/agents/report_compiler_agent.md",
|
||
)
|
||
|
||
|
||
def _load_inversion_manifest() -> tuple[list[str], str | None]:
|
||
"""Return (file_list, error_message). file_list paths are repo-relative.
|
||
|
||
Validates that the manifest carries exactly the three v3.6.7-scoped
|
||
file paths (per spec §6.3 + EXPECTED_MANIFEST_FILES). Drift, addition,
|
||
deletion, or duplication of entries is rejected — widening to a
|
||
fourth file requires landing a v3.6.8+ manifest with its own scope
|
||
tag, not retroactive edits to this manifest (per spec §6.3 line
|
||
1807).
|
||
"""
|
||
if not INVERSION_MANIFEST.exists():
|
||
return [], f"manifest missing: scripts/v3_6_7_inversion_manifest.json"
|
||
try:
|
||
import json
|
||
data = json.loads(INVERSION_MANIFEST.read_text(encoding="utf-8"))
|
||
except (json.JSONDecodeError, OSError) as exc:
|
||
return [], f"manifest unreadable: {exc}"
|
||
if data.get("scope") != "v3.6.7-only":
|
||
return [], f"manifest 'scope' must be 'v3.6.7-only', got {data.get('scope')!r}"
|
||
files = data.get("files")
|
||
if not isinstance(files, list) or not all(isinstance(p, str) for p in files):
|
||
return [], "manifest 'files' must be a list of strings"
|
||
if len(files) != len(set(files)):
|
||
dupes = sorted({p for p in files if files.count(p) > 1})
|
||
return [], f"manifest 'files' contains duplicate entries: {dupes}"
|
||
if set(files) != set(EXPECTED_MANIFEST_FILES):
|
||
expected = sorted(EXPECTED_MANIFEST_FILES)
|
||
actual = sorted(files)
|
||
return [], (
|
||
f"manifest 'files' must match the v3.6.7 frozen scope. "
|
||
f"Expected (sorted): {expected}. Got (sorted): {actual}. "
|
||
f"To widen scope to additional agents, land a v3.6.8+ "
|
||
f"manifest with its own scope tag per spec §6.3 line 1807; "
|
||
f"do not edit v3.6.7's manifest retroactively."
|
||
)
|
||
return files, None
|
||
|
||
|
||
def _extract_block(text: str, marker: str) -> str | None:
|
||
"""Return PATTERN PROTECTION block text, or None if marker missing."""
|
||
pos = text.lower().find(marker.lower())
|
||
if pos == -1:
|
||
return None
|
||
rest = text[pos:]
|
||
match = _HEADING_RE.search(rest, pos=len(marker))
|
||
end = match.start() if match else len(rest)
|
||
return rest[:end]
|
||
|
||
|
||
# Bullet line — Markdown list item starting with `- ` at the start of a
|
||
# line, optionally indented. Captures the bullet text including any
|
||
# soft-wrapped continuation lines (lines indented more than the bullet
|
||
# itself, by Markdown convention typically two spaces). Bullet text ends
|
||
# at the next bullet, blank line, or block boundary.
|
||
_BULLET_START_RE = re.compile(r"^(\s*)-\s+", re.MULTILINE)
|
||
|
||
|
||
def _iter_bullets(block: str) -> list[tuple[int, str]]:
|
||
"""Walk a PATTERN PROTECTION block and yield (line_offset, bullet_text)
|
||
pairs where bullet_text is the bullet's content (without the leading
|
||
`- ` marker) with all whitespace runs (including newlines from soft
|
||
wraps) collapsed to a single space.
|
||
|
||
Bullet boundaries: each bullet runs from its `- ` marker to the next
|
||
bullet, blank line (`\\n\\n`), or end of block. This makes INV-1
|
||
(canonical-line-as-bullet) and INV-2 (no-Clause-2-disclosure) operate
|
||
at bullet granularity, which is what the spec §6.3 means by "exactly
|
||
one bullet whose text matches the canonical Clause 1 line verbatim".
|
||
|
||
`line_offset` is the 0-indexed character position of the bullet's
|
||
`-` marker inside `block` (used to compute file-line diagnostics).
|
||
"""
|
||
starts = list(_BULLET_START_RE.finditer(block))
|
||
bullets: list[tuple[int, str]] = []
|
||
for i, m in enumerate(starts):
|
||
bullet_start = m.start()
|
||
content_start = m.end()
|
||
if i + 1 < len(starts):
|
||
bullet_end = starts[i + 1].start()
|
||
else:
|
||
bullet_end = len(block)
|
||
# Trim at the first blank line within the bullet — a blank line
|
||
# ends the bullet even if no further `- ` appears in the block.
|
||
candidate = block[content_start:bullet_end]
|
||
blank = candidate.find("\n\n")
|
||
if blank >= 0:
|
||
candidate = candidate[:blank]
|
||
normalized = " ".join(candidate.split())
|
||
bullets.append((bullet_start, normalized))
|
||
return bullets
|
||
|
||
|
||
# Audit-specific fragment markers — load-bearing pieces of the canonical
|
||
# Clause 1 sentence that uniquely tie a bullet to the v3.6.7 audit
|
||
# prohibition (not generic anti-fabrication guidance). Any one of these
|
||
# is sufficient to classify a bullet as Clause 1-like.
|
||
_CLAUSE_1_AUDIT_FRAGMENTS = (
|
||
"audit step",
|
||
"audit-passed state",
|
||
"codex/external review",
|
||
)
|
||
|
||
# Generic prohibition fragments that, ALONE, are common anti-fabrication
|
||
# language and DO NOT imply the v3.6.7 audit prohibition (codex R8 P2:
|
||
# `- Do not simulate data or sources.` is legitimate non-audit
|
||
# guidance). A bullet carrying one of these must ALSO carry an
|
||
# audit-specific fragment to be classified as Clause 1-like.
|
||
_CLAUSE_1_GENERIC_FRAGMENTS = (
|
||
"do not simulate",
|
||
"do not claim to have run",
|
||
)
|
||
|
||
|
||
def _is_clause_1_like(bullet_text: str) -> bool:
|
||
"""True if bullet_text reads as a (possibly weakened) variant of
|
||
the canonical Clause 1 line.
|
||
|
||
A bullet is flagged Clause 1-like iff it carries at least one
|
||
audit-specific fragment (audit step / audit-passed state /
|
||
codex/external review). Generic prohibition fragments (`do not
|
||
simulate`, `do not claim to have run`) alone are insufficient —
|
||
they appear in legitimate non-audit anti-fabrication guidance.
|
||
Codex R8 P2 closure: tighten so non-manifest prompts can carry
|
||
`- Do not simulate data or sources.` without tripping INV-3."""
|
||
lowered = bullet_text.lower()
|
||
return any(frag in lowered for frag in _CLAUSE_1_AUDIT_FRAGMENTS)
|
||
|
||
|
||
def _inv1_check_file(rel_path: str) -> tuple[bool, str]:
|
||
"""INV-1: canonical Clause 1 line appears as exactly one bullet in
|
||
the PATTERN PROTECTION block, AND no other Clause 1-like bullet
|
||
exists. The bullet's whitespace-normalized text MUST equal the
|
||
canonical Clause 1 text byte-for-byte; prefix weakeners (`When
|
||
feasible, DO NOT simulate ...`), tail weakeners (`... audit-passed
|
||
state if feasible.`), or any near-canonical duplicate all fail.
|
||
Returns (ok, message).
|
||
|
||
Codex R2 P2 closure (substring match → bullet-extract + exact
|
||
compare); R6 P2 closure (weakened-duplicate bypass — keep canonical
|
||
bullet, add `- When feasible, DO NOT simulate ...` second bullet,
|
||
exact-count stays 1 and lint passed). The R6 closure flags every
|
||
Clause 1-like bullet via _CLAUSE_1_LIKE_FRAGMENTS and demands each
|
||
one is byte-exact canonical."""
|
||
target = REPO_ROOT / rel_path
|
||
if not target.exists():
|
||
return False, f"file missing: {rel_path}"
|
||
block = _extract_block(target.read_text(encoding="utf-8"), PROTECTION_BLOCK)
|
||
if block is None:
|
||
return False, (
|
||
f"{rel_path}: PATTERN PROTECTION block missing "
|
||
f"(marker {PROTECTION_BLOCK!r} not found)"
|
||
)
|
||
bullets = list(_iter_bullets(block))
|
||
exact = [t for _o, t in bullets if t == CANONICAL_CLAUSE_1_TEXT]
|
||
weakened = [
|
||
t for _o, t in bullets
|
||
if _is_clause_1_like(t) and t != CANONICAL_CLAUSE_1_TEXT
|
||
]
|
||
if weakened:
|
||
return False, (
|
||
f"{rel_path}: PATTERN PROTECTION block contains "
|
||
f"{len(weakened)} Clause 1-like bullet(s) that do not "
|
||
f"match the canonical text byte-for-byte. Each must equal "
|
||
f"the canonical wording verbatim or be removed. Offending "
|
||
f"bullet(s): {weakened!r}. Expected canonical wording: "
|
||
f"{CANONICAL_CLAUSE_1_TEXT!r}"
|
||
)
|
||
if len(exact) != 1:
|
||
return False, (
|
||
f"{rel_path}: PATTERN PROTECTION block has {len(exact)} "
|
||
f"bullet(s) whose normalized text equals the canonical "
|
||
f"Clause 1 line; expected exactly 1. Expected wording: "
|
||
f"{CANONICAL_CLAUSE_1_TEXT!r}"
|
||
)
|
||
return True, "OK"
|
||
|
||
|
||
def _iter_block_segments(block: str) -> list[tuple[int, str, str]]:
|
||
"""Yield (offset, kind, normalized_text) tuples for every
|
||
matched-content segment inside a PATTERN PROTECTION block. `kind`
|
||
is `"bullet"` for `^- ` list items or `"prose"` for non-bullet
|
||
paragraphs (intro paragraph, paragraphs interleaved between bullet
|
||
runs, paragraphs appended after the last bullet — anywhere in the
|
||
block).
|
||
|
||
Whitespace-normalised so soft-wrapped Markdown lines collapse into
|
||
single-space sentences. Codex R3 + R4 P2 closures: scanning must
|
||
cover ALL prose in the block, not just the intro paragraph (R3) or
|
||
just the pre-first-bullet region (R4). The spec §6.2 sweep promises
|
||
"zero Clause 2 violation in PATTERN PROTECTION block"; carving out
|
||
any sub-region (post-bullet trailers, between-bullet inserts) gives
|
||
a regression vector.
|
||
|
||
Algorithm: paragraph-split the block on blank lines. Each paragraph
|
||
starting with `- ` is treated as a bullet group and walked via
|
||
`_iter_bullets`; each non-bullet paragraph (excluding the section
|
||
heading) is reported as `"prose"`. This handles bullets-then-prose,
|
||
prose-then-bullets, and mixed orderings uniformly.
|
||
"""
|
||
segments: list[tuple[int, str, str]] = []
|
||
cursor = 0
|
||
paragraphs = re.split(r"\n\s*\n", block)
|
||
for paragraph in paragraphs:
|
||
offset = block.find(paragraph, cursor)
|
||
if offset < 0:
|
||
offset = cursor
|
||
# Advance the cursor past this paragraph and its trailing
|
||
# blank-line separator (`\n\n`) for the next find().
|
||
cursor = offset + len(paragraph) + 2
|
||
stripped = paragraph.strip()
|
||
if not stripped:
|
||
continue
|
||
# The block heading line (`## PATTERN PROTECTION (v3.6.7)`)
|
||
# is content marker, not contract content.
|
||
if stripped.startswith("## "):
|
||
continue
|
||
if stripped.startswith("- "):
|
||
# Bullet group — may be a single bullet or a wrapped multi-
|
||
# line bullet; delegate to `_iter_bullets` which knows how
|
||
# to walk `- ` markers and collapse soft wraps.
|
||
for bullet_local_offset, bullet_text in _iter_bullets(paragraph):
|
||
segments.append((offset + bullet_local_offset, "bullet", bullet_text))
|
||
else:
|
||
normalized = " ".join(stripped.split())
|
||
segments.append((offset, "prose", normalized))
|
||
return segments
|
||
|
||
|
||
def _inv2_check_file(rel_path: str) -> tuple[bool, list[str]]:
|
||
"""INV-2: zero Clause 2 violation hits across the four regex
|
||
patterns (a)-(d), evaluated per segment (intro prose paragraphs +
|
||
bullets, both whitespace-normalized). Returns (ok, error_messages).
|
||
|
||
Per-segment evaluation closes codex R2 P2 (cross-bullet `.*`
|
||
over-reach) AND codex R3 P2 (intro-paragraph regression): each
|
||
pattern runs against a single bullet OR a single prose paragraph,
|
||
never spanning unrelated text. Soft-wrap tolerance is preserved by
|
||
pre-normalizing each segment's whitespace."""
|
||
target = REPO_ROOT / rel_path
|
||
if not target.exists():
|
||
return False, [f"file missing: {rel_path}"]
|
||
full = target.read_text(encoding="utf-8")
|
||
block = _extract_block(full, PROTECTION_BLOCK)
|
||
if block is None:
|
||
return False, [
|
||
f"{rel_path}: PATTERN PROTECTION block missing "
|
||
f"(marker {PROTECTION_BLOCK!r} not found)"
|
||
]
|
||
errors: list[str] = []
|
||
block_offset = full.find(block)
|
||
for segment_offset, kind, normalized in _iter_block_segments(block):
|
||
# Skip the canonical Clause 1 bullet — it inherently contains
|
||
# "audit step" / "audit-passed state" tokens but is the
|
||
# required prohibition, not a disclosure.
|
||
if kind == "bullet" and normalized == CANONICAL_CLAUSE_1_TEXT:
|
||
continue
|
||
for label, pat in INV2_PATTERNS:
|
||
m = pat.search(normalized)
|
||
if m is None:
|
||
continue
|
||
absolute_pos = (block_offset + segment_offset) if block_offset >= 0 else segment_offset
|
||
line_no = full.count("\n", 0, absolute_pos) + 1
|
||
errors.append(
|
||
f"{rel_path}:{line_no}: {label} Clause 2 violation in {kind}: "
|
||
f"{normalized!r}. Sentence must be removed per "
|
||
f"docs/design/2026-04-30-ars-v3.6.7-step-6-orchestrator-hooks-spec.md §6.2."
|
||
)
|
||
# One label per segment is enough; further labels on the
|
||
# same segment would be redundant.
|
||
break
|
||
return (len(errors) == 0), errors
|
||
|
||
|
||
# Directories scanned by INV-3. Spec §6.3 limits scope to agent prompt
|
||
# files under deep-research/agents/ and academic-pipeline/agents/. docs/,
|
||
# scripts/, and tests/ are excluded — the canonical line legitimately
|
||
# appears in the spec, the manifest checker, and test fixtures, and
|
||
# scanning those would self-fail.
|
||
INV3_SCAN_DIRS = [
|
||
REPO_ROOT / "deep-research" / "agents",
|
||
REPO_ROOT / "academic-pipeline" / "agents",
|
||
]
|
||
|
||
|
||
def _inv3_check(manifest_files: list[str]) -> tuple[bool, list[str]]:
|
||
"""INV-3: the EXACT canonical Clause 1 line MUST NOT appear in any
|
||
agent prompt outside the manifest. Detects:
|
||
(a) Bullets whose whitespace-normalized text equals
|
||
`CANONICAL_CLAUSE_1_TEXT` byte-for-byte.
|
||
(b) Prose runs whose whitespace-normalized text equals
|
||
`CANONICAL_CLAUSE_1_TEXT` byte-for-byte. Prose runs are
|
||
identified by sliding a 3-sentence window across non-bullet
|
||
text after stripping `^#` heading lines.
|
||
Returns (ok, error_messages).
|
||
|
||
Codex R9 P2 closures (architectural rewind on R7+R8 over-extension):
|
||
- The Clause 1-like heuristic is a manifest-internal weakened-
|
||
duplicate guard (INV-1), NOT a manifest-external detector.
|
||
Applied to non-manifest agents, it false-positives any bullet
|
||
mentioning `audit step` for unrelated reasons (e.g. process-
|
||
flow guidance like `- Review each audit step before
|
||
finalizing.`). Spec §6.3 INV-3 wording says "canonical Clause 1
|
||
line found outside the manifest" — that means the actual
|
||
sentence, not a Clause 1-like variant. Variants outside the
|
||
manifest are a separate concern that v3.6.7 does not lint.
|
||
- Prose detection no longer relies on `re.split(r"\\n\\s*\\n", ...)`
|
||
paragraphs (which mis-handle a heading immediately followed by
|
||
the canonical sentence with no blank line). Use line-level
|
||
heading-strip + whitespace-collapse + a 3-sentence sliding
|
||
window so the canonical sentence is detectable regardless of
|
||
surrounding Markdown structure.
|
||
"""
|
||
manifest_set = {str(REPO_ROOT / p) for p in manifest_files}
|
||
errors: list[str] = []
|
||
for d in INV3_SCAN_DIRS:
|
||
if not d.is_dir():
|
||
continue
|
||
for path in sorted(d.glob("*.md")):
|
||
if str(path) in manifest_set:
|
||
continue
|
||
text = path.read_text(encoding="utf-8")
|
||
offending_bullets = [
|
||
bt
|
||
for _o, bt in _iter_bullets(text)
|
||
if bt == CANONICAL_CLAUSE_1_TEXT
|
||
]
|
||
# Line-level heading strip + whitespace collapse on the
|
||
# rest. Heading lines (start with `#`) and bullet lines
|
||
# (`- `) are excluded; remaining lines are joined with a
|
||
# single space. The canonical sentence is detected as a
|
||
# substring with proper sentence boundaries.
|
||
non_bullet_non_heading = "\n".join(
|
||
line for line in text.splitlines()
|
||
if not line.lstrip().startswith("#")
|
||
and not line.lstrip().startswith("- ")
|
||
)
|
||
normalized_prose = " ".join(non_bullet_non_heading.split())
|
||
offending_prose: list[str] = []
|
||
if CANONICAL_CLAUSE_1_TEXT in normalized_prose:
|
||
offending_prose.append(CANONICAL_CLAUSE_1_TEXT)
|
||
if offending_bullets or offending_prose:
|
||
rel = path.relative_to(REPO_ROOT)
|
||
offenders = []
|
||
for bt in offending_bullets:
|
||
offenders.append(f"bullet: {bt!r}")
|
||
for pr in offending_prose:
|
||
offenders.append(f"prose: {pr!r}")
|
||
errors.append(
|
||
f"{rel}: canonical Clause 1 line found outside the "
|
||
f"v3.6.7 inversion manifest. If this is intentional "
|
||
f"widening, land a v3.6.8+ scope-tagged manifest per "
|
||
f"spec §6.3 line 1807 and open the §9 L2 question; "
|
||
f"do not retroactively widen v3.6.7's manifest. "
|
||
f"Offender(s): {offenders!r}"
|
||
)
|
||
return (len(errors) == 0), errors
|
||
|
||
|
||
def inversion_sweep_results() -> list[tuple[str, str, bool, str]]:
|
||
"""Run INV-1/INV-2/INV-3. Returns list of
|
||
(pattern_id, description, ok, message) tuples — one row per check ID,
|
||
aggregated across files for INV-2 and INV-3."""
|
||
results: list[tuple[str, str, bool, str]] = []
|
||
files, err = _load_inversion_manifest()
|
||
if err is not None:
|
||
results.append(("INV-manifest", "v3.6.7 inversion manifest readable", False, err))
|
||
return results
|
||
|
||
# INV-1: per-file presence/uniqueness check, aggregated.
|
||
inv1_errors: list[str] = []
|
||
for f in files:
|
||
ok, msg = _inv1_check_file(f)
|
||
if not ok:
|
||
inv1_errors.append(msg)
|
||
results.append((
|
||
"INV-1",
|
||
f"canonical Clause 1 line present exactly once in each of {len(files)} manifest file(s)",
|
||
len(inv1_errors) == 0,
|
||
"OK" if not inv1_errors else "; ".join(inv1_errors),
|
||
))
|
||
|
||
# INV-2: aggregate Clause 2 violation hits across all manifest files.
|
||
inv2_errors: list[str] = []
|
||
for f in files:
|
||
ok, errs = _inv2_check_file(f)
|
||
if not ok:
|
||
inv2_errors.extend(errs)
|
||
results.append((
|
||
"INV-2",
|
||
"no Clause 2 disclosure phrases (a)-(d) inside PATTERN PROTECTION blocks",
|
||
len(inv2_errors) == 0,
|
||
"OK" if not inv2_errors else "; ".join(inv2_errors),
|
||
))
|
||
|
||
# INV-3: canonical line restricted to manifest files.
|
||
ok, errs = _inv3_check(files)
|
||
results.append((
|
||
"INV-3",
|
||
f"canonical Clause 1 line confined to {len(files)} manifest file(s)",
|
||
ok,
|
||
"OK" if ok else "; ".join(errs),
|
||
))
|
||
return results
|
||
|
||
|
||
# Environment variable controlling whether agent-prompt checks run.
|
||
#
|
||
# Spec §9 ships v3.6.7 across multiple steps. Step 1 (this PR) shipped the
|
||
# 4 reference files + audit template + this lint. Step 2 (this PR / Step 1+2
|
||
# bundle) lands the actual PATTERN PROTECTION (v3.6.7) blocks in the three
|
||
# downstream agent prompts. With Step 2 in, the agent-prompt checks are
|
||
# default-on so CI enforces the contract.
|
||
#
|
||
# Set ARS_V3_6_7_AGENT_CHECKS=0 to skip agent-prompt checks (e.g. for a
|
||
# repo bisect that crosses a pre-Step-2 commit, or for partial test runs).
|
||
_AGENT_CHECKS_ENV = "ARS_V3_6_7_AGENT_CHECKS"
|
||
|
||
|
||
def _agent_checks_enabled() -> bool:
|
||
return os.environ.get(_AGENT_CHECKS_ENV, "1") == "1"
|
||
|
||
|
||
def all_checks() -> list[Check]:
|
||
checks = [
|
||
*reference_file_checks(),
|
||
*template_file_checks(),
|
||
]
|
||
if _agent_checks_enabled():
|
||
checks.extend([
|
||
*synthesis_agent_checks(),
|
||
*architect_agent_checks(),
|
||
*compiler_agent_checks(),
|
||
])
|
||
return checks
|
||
|
||
|
||
def main(argv: list[str]) -> int:
|
||
checks = all_checks()
|
||
passed: list[tuple[str, str]] = []
|
||
failed: list[tuple[str, str, str]] = []
|
||
|
||
for check in checks:
|
||
ok, msg = check.run()
|
||
entry = (check.pattern_id, check.description)
|
||
if ok:
|
||
passed.append(entry)
|
||
else:
|
||
failed.append((check.pattern_id, check.description, msg))
|
||
|
||
inv_results: list[tuple[str, str, bool, str]] = []
|
||
if _agent_checks_enabled():
|
||
inv_results = inversion_sweep_results()
|
||
for pid, desc, ok, msg in inv_results:
|
||
entry = (pid, desc)
|
||
if ok:
|
||
passed.append(entry)
|
||
else:
|
||
failed.append((pid, desc, msg))
|
||
|
||
total = len(checks) + len(inv_results)
|
||
deferred_note = ""
|
||
if not _agent_checks_enabled():
|
||
deferred_note = " (agent-prompt checks skipped — ARS_V3_6_7_AGENT_CHECKS=0)"
|
||
summary = (
|
||
f"v3.6.7 pattern-protection static audit: {len(passed)}/{total} "
|
||
f"checks passed{deferred_note}"
|
||
)
|
||
print(summary)
|
||
print()
|
||
|
||
if passed:
|
||
print("PASS:")
|
||
for pid, desc in passed:
|
||
print(f" [{pid}] {desc}")
|
||
print()
|
||
|
||
if failed:
|
||
# Failures go to stderr so CI harnesses that route stderr to a failure
|
||
# channel (matching scripts/check_corpus_consumer_protocol.py) surface
|
||
# the diagnostics correctly.
|
||
print("FAIL:", file=sys.stderr)
|
||
for pid, desc, msg in failed:
|
||
print(f" [{pid}] {desc}", file=sys.stderr)
|
||
print(f" → {msg}", file=sys.stderr)
|
||
print(file=sys.stderr)
|
||
print(
|
||
f"{len(failed)} check(s) failed. See spec for protection clause wording:",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
" docs/design/2026-04-29-ars-v3.6.7-downstream-agent-pattern-protection-spec.md",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
" docs/design/2026-04-30-ars-v3.6.7-step-6-orchestrator-hooks-spec.md (INV-1/2/3)",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv))
|