Files
imbad0202__academic-researc…/scripts/check_v3_6_7_pattern_protection.py
Edward Cheng-I Wu e7e775a0e1 feat(v3.6.7 Step 6 Phase 6.7): downstream agent partial inversion sweep + INV-1/2/3 lint (10 codex rounds, 0 P1) (#66)
* 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>
2026-05-05 12:42:36 +08:00

1367 lines
64 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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
"""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()",
"35%",
"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 35% 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))