Skip to content

fp-check - merge with concept-prover and online-triage, add evals - #243

Open
GrosQuildu wants to merge 25 commits into
mainfrom
fp-check-triage-merge
Open

fp-check - merge with concept-prover and online-triage, add evals#243
GrosQuildu wants to merge 25 commits into
mainfrom
fp-check-triage-merge

Conversation

@GrosQuildu

@GrosQuildu GrosQuildu commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Rewrites fp-check into workflows, merges it with concept-prover and online-triage (as optional steps); adds tests and evals.

Based on measurements it works as good as the old fp-check and concept-prover run together, but saves tokens and provides single unified skill.

What it does

Decides whether a suspected security bug is real, and says why. You hand it a
finding; it returns TRUE POSITIVE, FALSE POSITIVE or NEEDS MORE INFO
with the evidence behind the answer.

Three stages, in order:

Stage When What it does
0 Batch optional derives the shared context once, runs Stage 1 per finding with it, accounts for every finding by id, and then checks the pairs that are only exploitable together
1 Static always traces the path from entry point to sink, one agent per validation layer, then checks recovery, upstream fixes, impact and severity, and finally six gates
2 Online on request the project's published policy, bounty scope, past reports and duplicates. Refuses to answer offline rather than guessing from memory
3 PoC on request builds a real exploit, runs it, then five agents that did not build it try to reject it

Stage 1 always reaches a verdict on its own. Stages 2 and 3 can only narrow or
correct it.

The design point: every gate is a pure function in the workflow script, not an
instruction an agent is asked to follow. The script returns a status you cannot
argue with. That distinction is the whole reason the plugin exists — the same
rules, written as prose in a prompt, were not obeyed.

Two examples of what that buys: a finding already fixed upstream is retracted and
must cite a commit, and an integration or external root cause is capped at Medium
by arithmetic rather than by asking the model to be modest.

Stop Hooks Remove

The two Stop/SubagentStop prompt hooks were dropped rather than fixed because the completeness check they performed is now enforced deterministically by the workflow gates, while every observation we have of the hooks themselves is negative — invalid JSON on every session end, firing in sessions that never used the plugin, blocking correct standard-path output.

What was measured

Question: does loading the plugin produce better security conclusions than the
same prompt with no plugin at all?

Method: claude plugin eval with an ablation baseline — the identical prompt
is run twice, once with the plugin loaded and once with no plugin at all, and the
difference between the two scores is the delta. The two configurations are called
the with-arm and the no-plugin arm.

Because a language model gives a different answer each time, every case is run
3 times per arm — that is what n=3 means. So: 7 cases × 3 runs × 2 arms =
42 runs, and a case's score is the mean of its 3 runs. A case scores 1.000 only
if all three runs got it right.

Each case scaffolds a small realistic codebase and reports a finding the way a user
would; some reports are correct, some are wrong in a specific way. Every case is
graded by an LLM judge reading the final answer, plus at least one deterministic
regex or tool-use check — never an LLM grader alone, because it will happily pass a
run that described doing the work instead of doing it. Both arms and the judge
run on Sonnet.

The seven cases

Each one is a finding reported to the tool, and each has a specific right answer:

Case The report The right answer
integration-cap a real ledger bug, reported as Critical real, but the attacker does not control the upstream value, so severity caps at Medium
blocked-attack-path SQL built by string concatenation false positive — two validators reject the metacharacters injection needs
already-fixed a timing oracle reported against v1.4.0 retracted — the fix landed one layer up in the caller; only git history shows it
dead-route shell injection in a handler false positive — no route in the routing table reaches that handler
inflated-impact a panic claimed as a remote process crash real but over-stated — Go's net/http recovers per connection, so one connection drops and the server keeps serving
wrong-parameter shell=True and attacker input in one file false positive — there is no path between the two; the report joined the wrong sink to the wrong source
should-not-fire "explain what this function does, I am writing API docs" do nothing — this is not a security request, and the tool must not activate

Which stages were measured

Only Stage 1 is measured across the board. All seven prompts pin "work
offline"
, so:

Stage Coverage
1 Static measured on all 7 cases, both arms — this is what +0.281 describes
3 PoC six prompts ask for a PoC, but Stage 3 only runs on a TRUE_POSITIVE, and only integration-cap reaches one. So it is exercised by one case
2 Online never measured. Its one case does not discriminate — a plain session scores 1.000 on it — and it needs a suite built on real public advisories, which does not exist yet

So this is strong evidence for the static path, thin evidence for the PoC path, and
no evidence for the online path.

Results

v2.0.0 — 42/42 runs clean, $29.47, 2h26m

with plugin no plugin delta
Mean over 7 cases 1.000 0.719 +0.281

Per case:

Case with without delta
integration-cap 1.000 0.333 +0.667
blocked-attack-path 1.000 0.400 +0.600
already-fixed 1.000 0.500 +0.500
dead-route 1.000 0.800 +0.200
inflated-impact 1.000 1.000 0.000
should-not-fire 1.000 1.000 0.000
wrong-parameter 1.000 1.000 0.000

Free test layers: 358 node, 382 pytest, 44 bats, validator clean. Mutation gate
146 mutations run, 0 survived — each covered behaviour is broken in a sandbox
and the suite is required to go red, so the tests are known to test something.

Conclusions

It works, and the effect is concentrated. Four of seven cases carry the whole
delta; three contribute nothing because a plain session already scores 1.000 on
them. The wins are exactly where gates are enforced in code: rejecting an
unreachable sink, retracting an already-fixed bug, capping an inflated severity.

Judge progress by the with-arm score, not by the delta. The delta is a
subtraction: with − without. Both halves move between sweeps, so the delta can
rise because the plugin improved or because the no-plugin baseline happened to
score worse that day — and you cannot tell which from the delta alone.

Across the four sweeps the no-plugin baseline scored 0.656, 0.700, 0.652, 0.719.
Nothing about the plugin changed those; that spread is just run-to-run variation at
3 runs per case. The with-arm over the same four sweeps went 0.868 → 0.908 → 0.919
1.000 — that one only moves when the plugin changes, so it is the number that
actually tracks progress. It is also a more modest claim than the deltas suggest.

The suite is now saturated, and that is the binding limit. All 21 with-arm runs
scored exactly 1.000. There is no headroom left to measure an improvement in, so
the next change to this plugin cannot be evaluated by this suite — it will report
roughly +0.28 whatever the change does. A harder case is owed before another sweep
is worth paying for.

t and others added 18 commits August 6, 2026 11:54
Every git call in this file passes `cwd=` and means it, but all of them
inherited the caller's environment. pre-commit sets GIT_DIR and GIT_INDEX_FILE
when it runs a hook, which broke the self-test two different ways:

- `git add -A` in the scratch repo honoured the caller's GIT_INDEX_FILE and
  wrote the scratch tree into it, so the developer's index came back reporting
  every tracked file as deleted — 1131 spurious staged deletions from one
  `validator self-test` hook run, worktree untouched, nothing saying why.
- `changed_plugins()` resolved HEAD through the caller's GIT_DIR, so the
  scratch repo's own base commit was unknown there: "fatal: Invalid symmetric
  difference expression <sha>...HEAD".

`_git_env()` strips GIT_DIR/GIT_INDEX* for all four call sites, and
`unsigned=True` additionally forces commit.gpgsign=false for the two scratch
commits — inherited signing made them try to use the developer's key and fail
for a reason unrelated to the code under test.

Verified: passes 31 assertions with both GIT_DIR and GIT_INDEX_FILE leaked in,
and leaves the index untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four plugins ship plugins/*/workflows/ and not one has a test file. Nothing in
this repo ran `node --test`, so a *.test.mjs logic/wiring suite would have been
inert on arrival; and `python-tests` passed only `--with pytest`, so a contract
test importing yaml or jsonschema errors during collection rather than failing
informatively — and a collection error in one directory reads as "nothing to
run here".

- new `workflow-tests` target and CI job: `node --test` over each workflow
  plugin's tests/*.test.mjs, parsing TAP rather than trusting the exit code
  (node reports a file containing no test() calls as ONE PASSING TEST named
  after its path, so five empty files report five passes and exit 0), plus a
  guard that a plugin shipping workflows/ has logic tests at all. The four
  existing ones are recorded in WORKFLOW_TESTS_DEBT — debt, not an exemption.
- pyyaml and jsonschema added to both the Makefile target and the CI pip
  install, with a comment on each pointing at the other.

Verified: the missing-tests guard fails a probe plugin that ships workflows/
with no tests, and the debt list downgrades exactly the four named plugins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fp-check's six gates, its routing and its verdict were prose in reference files
that an agent was asked to honour. concept-prover measured what happens when the
same rules are workflow code instead: over seven identical eval cases it took
three outright, 3/3 against 0/3 each, and every one of those wins came from a
gate that returned a status the orchestrator could not argue with.

So fp-check keeps its scope, its bug-class routing, its 13 questions, its
math-bounds proof and its cheap standard path — the last of which never escalated
on any of the seven cases and still matched a full pipeline at 2.3x less cost —
and the enforcement mechanism is ported rather than approximated.

Three stages behind two questions asked once up front:

  triage-static.js   always. brocard pre-gate -> one agent per validation layer
                     -> recovery -> already-fixed search -> impact and severity
                     -> adversarial pass -> the six gates
  triage-online.js   on request. policy, scope, past bugs, downstream users
  triage-poc.js      on request. build, execute, five independent challenges,
                     confidence band, report

Gates that are now pure functions, each of which was a bug that shipped:
missingArgs, selectRoute, dismissedByBrocard, upstreamFixStands, decideGate,
missingPrecondition, capSeverity, decideVerdict, isAcceptableBuild,
artifactProblem, tallyChallenges, alreadyFixedStands, confidenceBand,
reportProblem, severityCapViolation, offlineProblem, scopeHalt, summaryProblem.

Harvested from vulnerability-triage-brocards, which stays where it is: brocards
2, 4 and 6 as a cheap pre-gate that no existing stage covered, brocard 5's
downstream rider, the dismissal-side rationalization guards to balance
11-against-2-for, and NEEDS MORE INFO as an explicit third verdict. That last one
is not cosmetic — reading "the claim as stated is unproven" as "no impact exists"
killed a real finding and scored the case below the arm with no plugin at all.

Two deliberate divergences from concept-prover, both recorded at the call site:
capSeverity CORRECTS an over-rated severity in Stage 1, where there is no artifact
to correct and the user is owed a verdict, while Stage 3 keeps the blocking
severityCapViolation because by then the number is written into a report file;
and build and review are one script, so the eight-field hand-off that drifted
twice between them no longer crosses a dispatch boundary.

Also: hooks/hooks.json deleted (an LLM auditing an LLM for phase completeness is
redundant once the gates return a status, and worse, it can disagree with the
code); agents/ deleted, since the workflow scripts carry their own prompts and
three undispatched agent definitions are rot; standard-verification.md and
deep-verification.md folded into SKILL.md's routing section; recovery-mechanisms
corrected where it claimed net/http returns a 500 on a handler panic — it closes
the connection and writes no status, and an eval grader demanding otherwise
scored six correct answers as failures.

The test apparatus moved with it, not just evals/: 296 pytest + 221 node + 36
bats green.
- test_workflow_contract.py re-pointed; GATE_FIELD_CONTRACTS gained rows for
  every new gate, which immediately caught two fields a gate branched on that no
  schema required.
- extract.mjs gained loadFns() so a gate calling a sibling can be graded without
  duplicating its logic into the test.
- new static.test.mjs (49) and online.test.mjs (32) cover the new gates; Stage 2
  had no tests at all before this.
- test_regrade.py skips with an earned, conditional reason: its capture is a
  recording of concept-prover, and a zero-guard test fails the build if anyone
  promotes a new capture without re-pointing it.
- poc-lint.bats pointed at the old plugin's script path, so relocating it alone
  would have left the suite exercising a file that is not there.
- poc-lint.sh, mutation-gate.sh and poc-lint.bats carry an explicit SC2016
  refusal: their single-quoted strings are grep patterns, deferred `eval`
  commands, and `$XXM` placeholder fixtures respectively, and this repo's
  pre-commit applies no severity floor.

mutation-gate.sh and capture-runs.sh still reference the retired paths and are
re-pointed in the next commit; neither runs in CI.

Version 1.0.3 -> 2.0.0: the verdict vocabulary, the dispatch surface and the
hooks all changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n gate

Two things the relocation left owed, and the gate found a defect that nothing
else could have.

**The eval cases now pin both stage answers.** `claude plugin eval` is
non-interactive, so there is nobody to answer an AskUserQuestion: the plugin
either hangs until the timeout or falls through to its default, and both defaults
are no. A suite that does not state the answers measures the static stage alone
and reports a plausible delta as though all three had run — the same silent
failure as the missing Workflow opt-in, which cost a $17.90 sweep.

The handoff's suggested table pinned five cases to "no PoC". That is not what
shipped, and the reason matters: six of the seven prompts OPEN with "Write a PoC
for this ...", which is the answer, stated the way a real request states it.
Pinning `no` on top would have put a flat contradiction in the prompt, and a
contradicted instruction measures neither configuration. Keeping `yes` also makes
the harder test — Stage 1 has to refuse to reach the PoC stage even though the
user asked for one, which is exactly where the baseline fails: measured, every
no-plugin run on two of these cases built a working exploit against the sink and
reported a confirmed vulnerability. Cost is unaffected, because the PoC stage is
gated on a TRUE_POSITIVE and five of the six never produce one.

The offline answer is added to all seven; `should-not-fire`, whose ask is
documentation rather than a PoC, gets the explicit no.

Three new tests, each verified to reject what it exists to reject:
test_every_prompt_pins_both_stage_answers (also fails a prompt that pins BOTH
directions), test_the_pinned_answers_are_plugin_neutral, and
test_at_least_one_case_pins_the_poc_stage_on — without which a suite pinning "no"
everywhere is cheap, green, and never exercises the build, the five challenges or
the confidence band.

**The mutation gate: 119 run, 0 survived, 12 deferred (131 total).** Eleven
patterns went stale against the new source and are re-pointed; two whose target
left the codebase are re-aimed at what enforces the same rule now, rather than
deleted; one new mutation covers the online stage's actionable-status gate, which
had none.

The 12 Layer 3 mutations are DEFERRED, via a new `defer_mutation` that counts and
names them. They break the recorded run `test_regrade.py` grades, and that module
skips because its capture is a recording of concept-prover — a skipped pytest
exits 0, which this harness reads as "the mutation survived". Leaving them as
run_mutation reported 12 phantom coverage gaps; deleting them would have shrunk
the gate from 131 to 119 with nothing saying so.

**The defect the gate found, which is the reason to run it.** `decideGate` gained
the history verdict as a fourth positional argument, and
test_every_non_PROCEED_status_carries_a_reason passes its cases as tuples. The
layer count landed in the history slot, `attemptedLayers` became undefined,
`undefined - 1` is NaN, `NaN !== 0` is true — so all ten rows returned BLOCKED at
the mis-attribution branch without reaching the branch each was written for. Ten
green assertions, nothing graded. What exposed it was a mutation on a *different*
function's evidence fallback. The fix adds a zero guard: the rows must between
them reach six distinct statuses, because ten rows returning the same one is what
a silently broken argument list looks like.

tests/README.md keeps its measured history attributed to the scripts that
produced it — editing a recorded result to name today's files destroys the only
thing it is evidence of — with a header saying so, and the operational sections
re-pointed. fp-check has not been measured yet; those numbers are the baseline it
has to beat.

make check: self-test, lint, shell, bats, workflow-tests and validate all pass.
python-tests fails in constant-time-analysis, which needs the aarch64 cross
sysroot CI installs; verified identical on a stashed tree, and untouched by this
branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`skills/fp-check/agents/openai.yaml` carried only an icon pair and a brand
colour, and the SVG it pointed at was referenced by nothing else. Nothing in the
plugin reads either.

Worth knowing before this is copied: 74 plugins in this repo carry the same
pair, so fp-check is now the only one without them. If that convention is load
bearing for a Codex/OpenAI runtime this is the commit to revert.

Validator still resolves all 355 references; 291 pytest pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… exposed

Audited all eleven. One real defect, one duplication, one class of staleness,
two files upgraded, one left alone deliberately.

**The defect: bug class decided the route by wording, not by meaning.**
SKILL.md sends the orchestrator to bug-class-verification.md for
`finding.bugClass`, and `selectRoute` matched on keywords that did not include
that file's own headings. So "Memory Corruption" — the heading it reads there —
took the cheap route with no algebraic bounds proof and no API-contract pass,
while "buffer overflow", the same finding written differently, took the deep one.
Also standard: use-after-free, type confusion, OOB write, algorithmic complexity.

Four of the nine classes now escalate (Memory Corruption, Integer Issues, Race
Conditions, Denial of Service), on the principle that they need deep because for
them the work deep adds *is* the verification rather than a second opinion on it.
The reference states the route per class, and two contract tests pin the two
together: one fails if a class there has no routing decision — the silent
`standard` default is what let this happen — and one extracts `selectRoute` and
runs it against those exact strings.

**gate-reviews.md was duplicated and stale.** The verdict prompt spelled out all
six gates AND told the agent to go read the file, so the criteria existed twice
with nothing keeping them in step; and the file's verdict format predated the
third verdict, still describing TRUE/FALSE POSITIVE only. It now has one job —
the six gates as a summary, and the verdict formats for whoever relays the result
— and says plainly that the prompt and `decideVerdict` are authoritative. The
prompt no longer sends anyone to it.

**Retired vocabulary fp-check no longer defines**: 13 references to "Phase 1-6"
and "Principle 5", concept-prover terms with no definition anywhere here.

**evidence-templates.md: two of four templates were dead.** The pseudocode-PoC
skeleton and the devil's-advocate write-up form belonged to a pipeline that no
longer exists — Stage 3 builds and executes real PoCs, and the adversarial pass
returns a schema-checked object. A template nothing consumes invites the artefact
it describes instead of the one the stage wants. The algebra template stays and
gained the two rules that decide whether a proof is worth anything: cite where
each premise comes from, and an unestablished range is UNCERTAIN rather than
"unbounded, therefore vulnerable". The data-flow template is re-aimed at the
orchestrator enumerating `layers[]`, which is the highest-consequence dispatch
field.

**recovery-mechanisms.md gained 12 runtime rows and a third answer.** It had 13
rows and no asyncio, no threading, no Kubernetes, no systemd, no supervisors, no
tokio, no serverless — all of which decide this question routinely. The new
section is the one that was missing entirely: recovered *and still a finding*.
A cheap-to-retrigger crash under a restart backoff is an availability finding, not
a Low; a supervisor restores the process, not the in-flight state; one lost
asyncio task is contained unless it was the only thing draining the queue.

**checkpoints.md: audited, not trimmed.** 467 lines and the most duplication-prone,
but its value is the calibration material — the evidence-by-impact-class table, the
root-cause classification, the design-intent indicators — and cutting that to save
lines trades the useful half for the measurable one. What I did instead was check
its stated outcomes against what the code returns, and three had drifted: it said
an UNCERTAIN layer "HALTS" (the code returns NEEDS_MORE_INFO, and the distinction
is why the third verdict exists), it said a failed 2.4 "downgrades severity" (the
code splits DISPROVEN from NOT_VERIFIED and downgrades neither), and its 5.2
section did not mention that the cap is code and behaves differently in the two
stages. A reference an agent reads that contradicts the code that decides is the
exact failure this port was about.

Left alone: false-positive-patterns, validation-dimensions, brocards,
poc-anti-patterns, test-integration. safety-guidelines now says in its first line
that no agent reads it — it is consumed by the orchestrator building `envelope`.

293 pytest + 221 node + 36 bats pass; mutation gate 119 run, 0 survived, 12
deferred; validator resolves all 355 references.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… too tight

Both found by a $0.32 trace probe before the paid sweep, which is what the
preflight exists for: four of five sweeps in this plugin's history were invalid
and each produced a plausible-looking number.

**The description did not trigger on the suite's own prompts.** Six of the seven
cases open with "Write a PoC for this ...", and the rewritten description covered
"is this real / exploitable / in scope / already fixed" but had dropped exactly
that phrasing — concept-prover's carried it explicitly. The probe showed
Skill 0 / Workflow 0: Glob, Read, Write, Bash, which is baseline behaviour with a
plugin loaded. Restored, framed so it stays honest about what the skill does with
such a request: a PoC ask needs the attack path verified first, which is the work.

**The timeouts were set for a pipeline that never ran.** The probe took 580s on
the CHEAPEST case against a 900s wall. A run that dies at the wall scores zero and
is indistinguishable from a wrong answer — that is how two runs dragged a case to
-0.44 in an earlier sweep. blocked-attack-path, dead-route and wrong-parameter go
to 1800s, matching the cases that already dispatched. should-not-fire stays at
300s deliberately: it must not dispatch at all.

Also recorded for whoever runs this next: **target the plugin by name, not by
path.** A path target does not register the skill for activation — Skill 0 across
two probes — while `fp-check@trailofbits` gave Skill 1, Workflow 1,
AskUserQuestion 0 and a blocking TaskOutput. tests/README.md says to target the
working tree, and that advice was written when a name resolved to a stale
installed copy; it is only safe now because `diff -r` proves the cache is
byte-identical to the tree. Both conditions have to hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…f 3 gates

42 runs, 7 cases, 3 runs per arm, sonnet both arms and judge, CLI 2.1.223,
$31.71, 189 minutes. Result checked in as
tests/fixtures/eval-result-2026-08-06-fp-check-v2-7case.json (paths scrubbed).

                       cp    old-fp   NEW      cp Δ    old Δ   NEW Δ
  already-fixed        3/3   0/3      3/3      +0.50   +0.28   +0.72
  blocked-attack-path  3/3   0/3      1/3      +0.60   -0.40   +0.07
  dead-route           1/3   2/3      3/3      +0.07   +0.40   +0.40
  inflated-impact      1/3   3/3      3/3      -0.44   -0.22   +0.00
  integration-cap      3/3   0/3      0/3      +0.67   +0.00   +0.00
  should-not-fire      3/3   3/3      3/3      +0.00   +0.00   +0.00
  wrong-parameter      2/3   3/3      3/3      -0.20   +0.00   +0.00
  mean delta         +0.170  +0.008  +0.170
  outcome tally       16/21   11/21   16/21
  cost               $39.96  $21.49  $31.71

**It ties concept-prover and does not beat it.** §3 required `> +0.170`; this is
+0.170 exactly, on the same tally, 21% cheaper, with reliability 4x better
(1 of 21 with-plugin runs produced no answer, against 4 of 21 — 4.8% vs 19%).
Two of the three cases §3 says MUST reach 3/3 did not. **So step 9 does not
fire: concept-prover is not retired.**

Wins that are attributable. `already-fixed` +0.72 and 3/3 is the best result any
arm has posted on it — `upstreamFixStands` retracting on a referenced commit,
which the old fp-check had no gate for at all (0/3). `dead-route` 3/3 beats both
predecessors. `inflated-impact` is repaired: it was -0.44 for concept-prover and
-0.22 for old fp-check, and is now +0.00 with both arms at 3/3.

Failures, honestly attributed:

**`blocked-attack-path` 1/3 — one run lost to a defect in this commit's fix.**
`dismissedByBrocard` checked "did every agent answer" BEFORE reading a DISMISS, so
one flaky agent overrode an answer another had already reached: brocard 5 returned
a clean DISMISS, a sibling hit a connection error, the gate returned
NEEDS_MORE_INFO about a finding already disposed of, and the run then burned the
case's full 1800s. That is the same rule `decideGate` already applies when a
blocking layer outranks a dead recovery agent, and I did not carry it across. Now
fixed, with a test over every (dismissed, dead) pair plus the inverse so it cannot
be over-applied into a fail-open. The other lost run wrote a PoC while correctly
concluding the endpoint is not injectable, which the criteria forbid.

**`integration-cap` 0/3 — UNATTRIBUTED, and this is the important gap.** All three
runs reported Critical or "Medium-High" on an integration root cause the cap puts
at Medium, and all three failed `names-the-integration-root-cause`. Two hypotheses
and no evidence to separate them, because the sweep ran without --keep-temp and
the traces are gone:

  (a) the documented case-design hole — every run in every previous sweep found the
      `qty` variant, which is internal and which the cap therefore correctly does
      not touch. tests/README.md predicted this case would penalise correct
      reasoning and left it open deliberately.
  (b) the cap fires and the ORCHESTRATOR overrides it. `capSeverity` corrects the
      return value; nothing forces the final answer to carry the corrected number.
      If that is what happened, the mechanism is advisory again one level up — the
      exact failure mode this port exists to remove, relocated rather than fixed.

(b) would be a real defect and is cheap to settle: one `--keep-temp` run,
~$1.50, grepping the trace for `severityCorrection`. Not run here because the
authorisation was for the sweep. Recorded as the next thing to do.

Two side effects of the run, both restored: the `trailofbits` marketplace source
was replaced by the worktree so the installed cache could be made byte-identical
to it, and fp-check was installed at user scope. Marketplace restored to
/Users/gros/ToB/tools/tob/skills; plugin uninstalled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aw severity

Found by auditing the first sweep's logs, and it explains more of integration-cap
than either hypothesis I committed earlier.

`capSeverity` ran at the end of the Impact phase, AFTER both post-impact early
exits — the `result !== 'VERIFIED'` guard and `missingPrecondition` — and both
return the `impact` object verbatim. So a finding that exited at either one handed
the orchestrator the impact agent's own uncapped `severity` with no correction and
no note. The second exit fires PRECISELY when the root cause is integration or
external with the precondition unstated, which is the most likely non-passing
outcome for exactly the findings the cap exists to bound. Its own comment already
said "the severity cap has nothing to cap against" without noticing that the
payload still carried a number.

The cap now runs immediately after the impact agent returns, before any exit, and
every return carries `severity`/`severityCorrection` from it. The raw agent value
stays visible under `impact.severity` so the correction is auditable rather than
overwritten. Pinned by a test over all three exits.

Also corrects my earlier commit message on two points, from evidence:

**Hypothesis (a) is falsified, not merely unsupported.** I attributed
integration-cap's 0/3 partly to the documented `qty`-variant case-design hole. All
three of concept-prover's 3/3 runs on this same case said "Severity: Medium",
passed `names-the-integration-root-cause`, and raised the variant in NONE of them
(`eval-result-2026-08-05-7case.json`). Across all six historical fixtures the
variant appears in 2 runs, both no-plugin, and in 0 with-plugin runs. The case is
answerable as designed; that hole is latent, not what fired.

**The real cause is upstream of both hypotheses: the enforcement chain was never
entered.** Five mechanisms have zero firings across all 18 with-arm runs —
`upstreamFixStands`, `capSeverity`, `decideVerdict`, `severityCapViolation`, and
recovery as a decisive step — because the brocard pre-gate decided 11 of 18 and
returns hard. It is first, it is cheapest, and on integration findings its
brocard-5 test ("does THIS project's documentation describe this?") is structurally
unanswerable, since the governing document is an external service contract that is
not in the repo. Two runs said exactly that and returned NEEDS_MORE_INFO; Stage 3
then refused on the missing TRUE_POSITIVE, and the orchestrator built the PoC by
hand and reported Critical. That is a design problem in gate ORDER, not in any
gate, and it is not fixed here — it needs a decision about whether an
unanswerable-offline brocard should abort the stage or defer to the impact stage.

Not a regression, for the record: the brocard DISMISS-before-liveness ordering the
audit flagged on blocked-attack-path run0 is the defect fixed in the previous
commit. The sweep ran 2.0.2, which still had liveness first. That run is what
motivated the fix, not evidence against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two changes proposed after the first sweep. Together they remove the most
expensive structural defect it found, in which the cheapest gate in the plugin
held a veto over the work that could have answered its question.

**1. Only a DISMISS is terminal.** `dismissedByBrocard` became `triageBrocards`,
returning `{dismissal, unresolved}`. It gave "this test dismisses the finding" and
"this test cannot decide" the same power to end the stage, and they are not the
same statement — the second is precisely what the expensive stages downstream
exist to resolve.

Aborting also did not produce a safe non-answer; it produced an UNGUARDED one. The
pre-gate stopped, Stage 3 refused for want of a TRUE_POSITIVE, and the
orchestrator — still holding a user request for a PoC — built one outside every
gate and reported an uncapped Critical. Fail-closed at the gate became fail-open
one level up, at 4-5x the baseline cost for an identical score. Measured on 3 of 18
runs, against 8 of 8 correct on the DISMISS path, and it starved five downstream
mechanisms to zero firings because the pre-gate decided 11 of 18 runs.

An unresolved question is now carried: surfaced to the impact and verdict prompts,
returned in the payload, and enforced in code by `decideVerdict`, which blocks a
TRUE POSITIVE on it and reports the missing fact. A dead agent is carried the same
way rather than being fatal, so one flaky agent degrades the verdict instead of
destroying the run. The finding gets the full analysis either way, so the severity
is capped and the reachability verified before the answer is given.

**2. Brocard 5 answers PASS when the governing document is not in the repo**, and
names the document it would need. It asks what THIS project documents; a governing
spec, an upstream service contract and a downstream consumer's guidance are all
outside its reach, so answering NEEDS_MORE_INFO on one made the test structurally
unanswerable for every integration-rooted finding — which is what fired on
integration-cap.

Breaking for anyone reading the payload: the return now carries `openQuestions`,
and a Stage 1 result can be NEEDS_MORE_INFO with all six gates passing.

228 node (+4) and 293 pytest pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…its own delta

Three subagents on non-overlapping surfaces — gate logic, orchestrator-facing
docs, eval suite. Every fix has a test that fails without it. Version 2.1.0.

## The finding that changes how the baseline reads

**`names-the-integration-root-cause` was a delta manufactured by vocabulary.**
`capSeverity` emits "...a integration root cause requires an external failure to
trigger...", and the bare token `integration` was the grader pattern's first
alternative. Across six sweeps **0 of 18 baseline runs ever emitted it**, and all
four with-plugin passes sit on sweeps where the pipeline dispatched. That is the
same construction `test_the_workflow_opt_in_is_plugin_neutral` forbids in prompts,
one layer down: the with-plugin arm could pass by relaying the plugin's own words.

It is not *proven*, because the deciding text sits inside the CLI's evidence
elision — but it means **concept-prover's +0.667 on integration-cap, the single
largest per-case delta in the numbers-to-beat table, may be partly an artifact.**
`integration` is now accepted only where it attributes (`integration root cause` /
`failure` / `precondition`), so relaying the note still passes because that is a
genuine attribution, while a stray "integration test" no longer does; widened to
the semantic family so the baseline can earn it. This is deliberately NOT a strict
widening: the 2026-08-05 with-arm reads 0/3 under it where the CLI recorded 3/3.
Any comparison across that grader is now apples-to-oranges, and saying so is the
point.

## Fail-open and lost-answer bugs in the gates

- **`decideGate`: a dead sibling LAYER agent outranked a live BLOCKS** — the same
  calibration error one level down from where it was fixed two commits ago. Layers
  are conjunctive: one BLOCKS settles reachability whatever the dead one would have
  said. Mis-attribution still outranks everything, since a BLOCKS read out of a
  mis-attributed list could dismiss a live finding.
- **A dead deep-route proof agent read as "did not block"** — all three could die
  and the finding still reached TRUE_POSITIVE. The escalation was paid for and
  enforced nowhere.
- **`HISTORY_SCHEMA.complete` optional while a terminal gate branched on it**, so a
  PARTIALLY fixed — still live — finding was retracted.
- **`triage-online`: `reachability` was unguarded** and its `.evidence` feeds two
  later prompts, so a dead agent threw a TypeError instead of returning a status.
- **`DUPLICATE` was discarded by the summary gate**, which ran first.
- **Sources beyond the cap vanished from every consumer**, so an unsearched venue
  read as a clean one.
- **`OUT_OF_SCOPE` on Stage 2's actionable list was unreachable** and refused four
  lines later by a message naming it as valid.
- **The artifact gate preempted the already-fixed override**, turning "already
  patched, retract" into a status the Completion Gate says to re-dispatch.

## `DO_NOT_SUBMIT` carried three outcomes, all mapped to FALSE POSITIVE

Two were the rounding error this plugin exists to prevent. Now ALREADY_FIXED (a
retraction — the bug was real), DO_NOT_SUBMIT (a false positive), and
NEEDS_MORE_INFO (an incomplete report — nothing disproven).

## Documents that disagreed with the code

- **The 7 and 13 devil's-advocate questions did not exist anywhere.** Deleted with
  standard/deep-verification.md in the rebuild while the Stage 1f prompt still told
  agents to work through them. Restored as one list of 13 with the 7 marked.
- **`baseDir` was undocumentable**: the plugin installs at a versioned path, and a
  traced run guessed 2.0.1 while 2.0.2 was live, silently breaking every reference
  read. Now: copy it, never reconstruct it, never type a version.
- **`triage-online` required `baseDir` and never used it**, so no reference reached
  any Stage 2 agent. Its scope agent now reads validation-dimensions.md.
- `REPORTED` was the one terminal status with no `reason`; `confidenceBand`
  hardcoded 5 so a sixth challenge would make HIGH unreachable; `openQuestions` was
  missing from every early return; runtime claims corrected for Express, Docker,
  Rails, Rust, pickle and Go data races.

## A bug in a test I wrote and had "proved"

**`ONLINE_YES` matched its own negation.** It listed the bare alternative
`go online`, and all seven prompts say "do not go online" — so it matched every
case in the wrong polarity. It passed for the wrong reason because the assertion is
`yes or no` and `ONLINE_NO` also matched, and it made the online contradiction
check unaddable. My detection proof tested *removal* of the phrase and never
polarity. `POC_YES` has the same latent shape, unfired only because no case is
worded that way. `stage_answers()` now strips the NO phrases before searching YES.

Also `test_no_regex_grader_is_satisfied_by_the_scaffold_alone` inspects zero
graders now that every regex targets `last_message`; its `last_message` equivalent
is added.

## Cases that do not discriminate, at n=9-18 per arm

already-fixed +0.500 and dead-route +0.289 discriminate on every sweep.
`wrong-parameter` (-0.067) and `should-not-fire` (+0.000) do not, both graders 9/9
in both arms; `inflated-impact` is +0.00 after its grader fix, which raises both
arms to 1.00 equally. `blocked-attack-path` is confounded: 4 of 9 with-plugin runs
produced no answer against 0 baseline.

10 new eval-suite invariants, each proven to fire by sandbox mutation (10 caught,
0 survived). They are owed to mutation-gate.sh and recorded there as such.

245 node + 324 pytest + 36 bats pass; lint and validate clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parameterising `confidenceBand(defeated, total = 5)` made the old pattern
`defeated === 5` match nothing, which the harness correctly reports as ERROR —
stale, not survived. Mutating the comparison rather than the literal keeps it
pointed at the boundary.

Added the sibling it was missing: a mutation on the DEFAULT, which is what a
caller that forgets to pass CHALLENGES.length receives. It runs the contract test
rather than a unit test, since that is where the two numbers are pinned together.

120 mutations, 0 survived, 12 deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…machinery

The merge should be at least as good as the best of its parts. Measured, it was
not: best-of-parts 20/21 outcome passes, merged 15/21, and the shortfall landed
on concept-prover's two signature mechanisms.

  case                     cp  old-fp  BEST  merged
  already-fixed             3     0      3     1    LOSES 2
  integration-cap           3     0      3     0    LOSES 3
  inflated-impact           1     3      3     2    LOSES 1
  dead-route                1     2      2     3    (exceeds)

Three subagents. The cause is not a bug in either parent's logic — it is that
**the merge added two gates in first position that neither parent had**, and they
decided the cases the specialised machinery was ported to decide.

Mechanism firings across 63 measured with-plugin runs:

  brocard DISMISS                                12
  a deep-route proof named in the answer          9
  decideGate blocking layer                       5
  upstreamFixStands (`already fixed by ...`)      0
  capSeverity (`severity lowered from ...`)       0
  missingPrecondition / decideVerdict / OUT_OF_SCOPE / NOT_VULNERABLE   0

**old fp-check's six-gate review ran zero times in 63 runs.** It sits behind five
sequential filters and something upstream always fired first. Half of one parent
was present, reachable in principle, and never reached.

## The biggest loss was NOT the brocards

I had assumed the brocard pre-gate caused all three. It caused `already-fixed`
and `inflated-impact`. `integration-cap` was killed by the OTHER merge invention:
a deep-route auxiliary proof returning BLOCKS terminally. One run says it plainly
— *"the top-line status came back NOT_EXPLOITABLE, but every one of its 13
sub-agents' underlying evidence unanimously stated the finding is real and
unblocked; the top-line label was self-contradicting"* — and all three then
discarded the verdict and reported their own uncapped Critical.

Root cause is mine: `race-feasibility` and `math-bounds` were told "answer
UNCERTAIN if this does not apply" in *prose*, and `api-contract` never defined
what PASSES and BLOCKS meant at all while using the layer enum. So an auxiliary
proof could block a finding on a question that did not apply to it.

## Fixes

- **`PROOF_SCHEMA` with a required `applies` boolean**, read as `applies === true`
  so an omitted or non-boolean answer cannot block. All three deep prompts now
  state their polarity.
- **A blocking proof is carried, not terminal.** The impact stage and `capSeverity`
  always run; the same two questions are then answered by `gateMathBounds` /
  `gateEnvironment` with all the evidence in view.
- **Brocards 4 and 5 get `defersTo`.** Their DISMISS is carried and the
  specialised gate — recovery/impact for 4, the history search for 5 — produces
  the answer. Brocards 2 and 6 keep the short-circuit: nothing downstream tests
  them, so deferring would buy a fan-out and no better answer. Cost is pinned by a
  test asserting zero non-brocard agents on a 2/6 dismissal.
- **`upstreamFixStands` moved above the blocking-layer branch**, so an already-fixed
  finding retracts with the commit reference instead of a brocard's prose. Both
  outcomes retract; only the reason the user gets changes.
- **`decideVerdict(result, carried, overruled)`** — a deferred dismissal or a
  blocking proof forbids TRUE_POSITIVE in code. Monotonicity holds by
  construction: the softest thing a deferral can reach is NEEDS_MORE_INFO.

## Stage 3 no longer reads as a complaint about the caller

The refusal said *"triage-poc received an unusable arg shape ... forward
triage-static's return value verbatim"* on dispatches where the caller had done
everything right, and the model reacted by building the PoC by hand — 4 of 21
runs. `settledByStageOne` recognises the six Stage 1 statuses that ARE a verdict
and returns the refusal in terms of the finding, carrying `settledBy` and a
`deliverable`. NEEDS_MORE_INFO and BLOCKED are deliberately excluded: those
genuinely are re-dispatches. Nothing was relaxed — same outcome on every input,
zero agents spent, TRUE_POSITIVE still the only path to an exploit — and nine
sandbox mutations confirm it, including the one that would loosen it.

SKILL.md gains a section on what to do when the user asked for a PoC and Stage 1
said no, a bounded negative-PoC concept (drive the entry point, assert the
refusal, never sit beside confirmed-vulnerability framing), and per-status framing
rules. `already-fixed` failed 2 of 3 runs with the analysis fully correct —
cites-the-fix and searched-the-history both 3/3 — purely on hedged wording that
matched the no-plugin baseline verbatim.

## Capabilities that did not survive, now honestly stated

`tests/coverage.test.mjs` (29 tests) maps every parent mechanism to present /
reachable / observed. Three are absent: batch triage and the exploit-chain check
exist only as SKILL.md prose with no code that can see two findings, and
online-triage's downstream-users census is gone while triage-online.js still
advertised it in meta.description. The claim is struck.

Those three arrived as permanently-failing tests. Permanent red teaches everyone
to ignore red, so they now assert the honest state instead: the gap is fine,
claiming it is not. SKILL.md says plainly that nothing enforces batching or
chaining. Each guard is proven to fire by removing the caveat it checks.

318 node + 324 pytest + 36 bats pass; lint, validate, bats, workflow-tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything free is green — 318 node + 324 pytest + 36 bats, and the mutation gate
at 120 run / 0 survived / 0 stale despite substantial gate rewrites. Two preflight
steps remain and neither has run against 2.2.0: a trace probe (~$2) and the sweep.

The handoff leads with the probe because the last one found two real defects for
$0.32 and the one before it found the plugin never activated at all. It probes
`integration-cap` specifically, and names the single check that matters: whether
`severityCorrection` appears in the trace. `capSeverity` has fired 0 times in 63
measured runs, and if it is still absent the sweep is not worth buying.

It also says what to do if the answer is "no" a third time. The question then
stops being which gate to fix and becomes whether the merge should exist: the
merge exceeds both parents on exactly one case, three of seven cases do not
discriminate at all, and concept-prover alone scores 16/21 for $39.96 against the
merge's 15/21 for $39.35. Splitting back up and keeping only what measurably
helped is a defensible outcome, and the doc says plainly not to keep the merge
because it took a long time to build.

Known gaps are listed to be inherited rather than rediscovered: Layer 3 does not
run (12 deferred mutations behind it), Stage 2 has never run in a graded run,
three capabilities did not survive, 19 mutations are owed, and `baseDir` is
model-supplied with nothing validating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The §1.1 preflight probe, run against 2.2.0 for $1.15, came back
`integration-cap` = 0.00 with the answer stating:

  Verdict: FALSE POSITIVE — DISMISSED at Stage 1's pre-gate (Brocard 2, "no
  exploit from the heavens"). Severity: not reached — the finding was dismissed
  before the impact/severity phase, so no severity was computed.

So 2.2.0 correctly fixed the deep-route proof that had been killing this case,
and the cap STILL never ran, because the one brocard still allowed to end the
stage killed it a step earlier. Three fixes deep on the same case and the same
shape each time: a cheap gate in first position answering a question the
expensive gate downstream was built to answer.

Brocard 2 was left short-circuiting on the reasoning that nothing downstream asks
its question. That is wrong for a whole class: **"the attacker must already
control the upstream rate service" IS an integration root cause**, which is
exactly what `missingPrecondition` and `capSeverity` decide — the finding is real,
the precondition must be stated, the severity caps at Medium. It now defers.

Its genuine dismissals survive: an active MITM that can already inject, ctypes
that already implies code execution. The impact stage reaches the same answer with
the trace in hand, and a deferred dismissal blocks a TRUE POSITIVE in code either
way, so nothing became easier to report as real.

**Brocard 6 is now the only one that ends the stage**, and it is the only one with
no downstream equivalent — nothing else in the plugin evaluates remediation cost.
The cost pin is kept and re-pointed at it.

Two of agent A's pins failed on this change, which is them working: the fixture
list of which brocards defer, and the declaration-order test. The latter is
rewritten against the SPECS list rather than a hardcoded key, so it keeps testing
the ordering property as brocards move between the two sides.

318 node + 324 pytest + 36 bats; lint, validate, bats, workflow-tests green.
Handoff updated to 2.2.1 and records the probe result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 2 still said brocards 2 and 6 keep the short-circuit, which the probe
disproved and 2.2.1 changed. Only 6 does now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… of the sweep

The handoff now opens with implementing item 2 of TODO-batch-and-users.md rather
than with the measurement, and says why the ordering is deliberate.

The design decision is made in the doc so the next agent implements rather than
re-litigates: option C, gate it in code on what Stage 2 already knows, NOT a third
question at Step 0. Whether severity depends on downstream usage is a finding of
the reachability analysis, not something the user knows up front — and a third
toggle is one more thing a non-interactive harness silently defaults to `no`,
which is the failure this plugin has already produced three times over
(capSeverity, upstreamFixStands, decideVerdict: 0 firings each in 63 runs).

Acceptance criteria are concrete: `needsUserCensus` pure and inline (a module
const cannot be extracted by loadFn), a schema whose absence-of-hits is not proof
of absence, a logged skip, and the `coverage.test.mjs` guard flipped from pinning
the gap to exercising the capability — that test states the flip in its own
assertion messages. `meta.description` may advertise the census again only once
the agent exists, and the guard enforces that ordering.

The section says plainly that this will NOT move any number in the 7-case suite.
Stage 2 has never run in a graded run: every prompt pins offline, and the
synthetic fixtures have none of the public evidence the stage is about. That is
exactly why it goes first — doing it after the sweep would mean the measured
artifact is not the shipped one. Nobody should read a flat sweep as evidence the
census failed; it is not in the measurement's reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Aug 7, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ GrosQuildu
❌ t


t seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@GrosQuildu
GrosQuildu marked this pull request as ready for review August 10, 2026 13:43
@GrosQuildu
GrosQuildu requested a review from kz-tob as a code owner August 10, 2026 13:43
Comment on lines +351 to +378
FNR == 1 { expect = 0; closer = "" }
# A docstring or block comment opened on an earlier line runs to its terminator.
closer != "" { if (index($0, closer)) closer = ""; next }
# Rule 3, before the comment skip below because a comment body is exactly what
# its second shape matches. No `next`: `def f():` followed by `...` is both an
# elided line and a stub body, as it was when these were separate greps.
/^[[:space:]]*(#|\/\/|\/\*)?[[:space:]]*\.\.\.[[:space:]]*(\*\/)?[[:space:]]*$|^[[:space:]]*(#|\/\/|\/\*)[[:space:]]*\.\.\./ {
flag("ellipsis-placeholder", FNR, $0)
}
# Comments, and the opening line of a docstring or block comment, at ANY nesting
# level. This ran only between a signature and its body, so a module- or
# class-level docstring was invisible and the code quoted inside one reached
# both rules below as a definition.
{
body = $0
sub(/^[[:space:]]+/, "", body)
if (body ~ /^(#|\/\/)/) next
sub(/^[rRbBuUfF]+/, "", body)
opener = ""
if (index(body, TQD) == 1) { opener = TQD; terminator = TQD }
else if (index(body, TQS) == 1) { opener = TQS; terminator = TQS }
else if (index(body, BCO) == 1) { opener = BCO; terminator = BCC }
if (opener != "") {
if (index(substr(body, length(opener) + 1), terminator) == 0) closer = terminator
next
}
}
# Rule 6. No `next`: one line can be both a reimplementation and a stub

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 poc-lint.sh's multi-line docstring/comment tracker (awk pass, ~L351-378) uses index($0, closer) to find the closing """, which matches ANY occurrence of that 3-char sequence regardless of escaping. If a docstring body line contains a raw """ (e.g. an escaped triple-quote or prose mentioning Python's triple-quote syntax), tracking closes one line early, the real closing line re-opens a bogus docstring, and the following line (often the function body) is swallowed into comment mode — hiding stub/reimplementation/ellipsis violations from rules 3, 6, and 7. Verified by execution: a PoC with a stub pass body plus such a docstring line lints clean and exits 0; removing that one line correctly flags stub-body and exits 1. Fix the closer-matching to account for escaped/nested triple-quotes (e.g. track raw string state or check for an odd number of preceding backslashes) and add a bats fixture for it.

Extended reasoning...

The bug. The awk pass that tracks multi-line docstrings/block comments uses a single closer variable and closes it with a bare substring test:

closer != "" { if (index($0, closer)) closer = ""; next }
...
if (index(substr(body, length(opener) + 1), terminator) == 0) closer = terminator

index() reports the position of the FIRST occurrence of the 3-character string """ anywhere on the line, with no regard for escaping, nesting, or whether it's inside a different quoting context. It cannot distinguish an actual closing triple-quote from an escaped one (\""") or from three quote characters that just happen to appear together in prose.

The trigger. Consider:

def exploit():
    """
    Some text with \""" escaped inline, not actually closing.
    Real content: after that.
    """
    pass

Line 1 (""") sets closer = '"""'. Line 3 contains the literal 3-byte sequence """ (as part of \"""), so index($0, closer) finds it and clears closer — one line early. Line 4 (Real content...) is now read as ordinary awk-processed code (harmless here, but this is where a real function body would slip through). Line 5, the REAL closing """, is then evaluated fresh against the opener test (index(body, TQD) == 1), matches, and re-opens a brand-new docstring. That newly-opened (bogus) docstring then swallows line 6 — the pass stub body — into comment mode, so it never reaches the awk rules that check for stub bodies (rule 7), reimplementation (rule 6), or ellipsis placeholders (rule 3).

Why nothing else catches it. poc-lint.sh is the sole mechanical gate for these rules; there's no secondary check. index() is a plain substring search with no awareness of Python string-escaping semantics, so any of the following independently trigger the desync: an escaped triple-quote in prose, a docstring that describes Python's """ syntax, or simply three quote characters landing adjacent for unrelated reasons.

Impact. triage-poc.js's isAcceptableBuild gate trusts lintPassed, and the independent artifact-check agent re-runs this same script and gates on lintExitZero. Both trust the exit code as ground truth. A PoC whose docstring happens to contain a raw """ sequence, with an actual stub (pass/...) or reimplemented body, would lint clean and pass both the build gate and the supposedly-independent re-check — exactly the "checker that silently passes is worse than none" failure class this repo's own guidance calls out as the most expensive kind of bug.

Step-by-step proof (independently reproduced by three verifiers via direct execution):

  1. Write a Python file with def exploit(): whose docstring's second line contains Some text with \""" escaped inline... and whose body is just pass.
  2. Run bash poc-lint.sh --symbol target_app.ledger.transfer_balance file.py.
  3. Output: 1 file(s) clean, exit code 0 — the stub body was never inspected.
  4. Delete only the line containing the embedded \""", keeping the docstring otherwise intact and the pass body unchanged.
  5. Re-run the identical command: now it reports stub-body ... A definition whose whole body is a placeholder and exits 1.
  6. The only variable changed between steps 3 and 5 is the presence of the embedded triple-quote — which conclusively demonstrates the tracking desync, not a difference in the actual code being linted.

The fix. The closer-matching needs to account for escaping — e.g. treat a """ preceded by an odd number of backslashes as not a real terminator, or more robustly, scan character-by-character tracking raw-string/escape state rather than doing a bare index() substring search. A bats fixture reproducing this exact case (stub body + docstring containing an embedded triple-quote) should be added to poc-lint.bats to lock in the fix, since this file's own stated design principle is that "a checker that inspects nothing must fail, not pass" — the same standard applies to a checker whose internal state can be silently desynced by attacker-controllable (or just incidental) input.

Comment on lines +340 to +368
const NOT_CITATIONS = [
// stand-ins: the shape that discards a live finding
'n/a',
'unknown commit',
'see evidence',
'TBD',
'none',
'fixed upstream',
// hyphenated English carrying a digit — the "any hyphenated token" failure
'fixed in a post-2020 refactor',
'a follow-up commit, not-found-1',
'internal-fix-2',
'go-to-market-2',
// a bare file:line, which challenge 4's own prompt names as a non-citation
'src/handlers/auth-v2.go:118',
'see evidence at auth.py:31',
// and file PATHS, which round 7 admitted by putting `/` in the keyword
// separator class: `bug/12` and `issues/42` read as GitHub shorthand, which
// contradicts the file:line rule directly above
'src/bug/12.go',
'tests/issues/42/repro.py',
'lib/pull/3/mod.rs',
// bare numbers and dates, indistinguishable from line numbers and years
'4521',
'2021.03',
'fixed sometime in the 2.x line',
'',
' ',
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 citedReference() in workflows/triage-static.js (duplicated verbatim in triage-poc.js) uses the regex [0-9a-f]{7,40} to detect a commit-sha citation, but since decimal digits are a subset of hex, any bare 7-40 digit number (a date like 20240115, a unix timestamp, a large ticket number) matches as a valid citation — directly contradicting the function's own documented intent that 'a bare number is deliberately not a citation.' This feeds upstreamFixStands()/alreadyFixedStands(), so a history agent returning a bare 7+ digit non-reference number in its reference field causes a real, live finding to be silently discarded as ALREADY_FIXED. The NOT_CITATIONS test table (static.test.mjs:362-364) only covers numbers under 7 digits, so add a 7+ digit case there and require at least one hex letter (a-f) in the sha branch (or exclude date/timestamp-shaped decimals).

Extended reasoning...

What the bug is. citedReference()'s first regex alternative for recognizing a commit-sha citation is (^|[^0-9a-z])[0-9a-f]{7,40}([^0-9a-z]|$). Because the decimal digits 0-9 are a strict subset of the hex character class [0-9a-f], this pattern matches any bare run of 7 to 40 plain digits — with zero hex letters required. Concretely: citedReference('4012345') -> '4012345', citedReference('20240115') (an 8-digit date) -> matches, citedReference('1700000000') (a 10-digit unix timestamp) -> matches, and a 40-digit string of all '1's also matches. This was verified directly against the regex extracted from the source by three independent verifiers.

Why it contradicts the code's own stated design. The function carries a comment (lines 765-769) that reads: "A BARE number is deliberately not a citation. 4521 is indistinguishable from a line number or a year, and admitting it makes 'fixed in 2021' a reference." The implementation only enforces that intent for numbers under 7 digits — citedReference('4521') and citedReference('2021') correctly return null because they are too short for the sha branch to match. But a longer bare number, which is exactly as indistinguishable from a line number, a date, or a byte count as a short one, slips through and is accepted as a valid commit reference.

The code path that triggers it. upstreamFixStands(historyVerdict) in triage-static.js calls citedReference(historyVerdict.reference) to decide whether Stage 1's already-fixed search retracts a finding as ALREADY_FIXED. The history agent is prompted to put "a commit, PR, issue or advisory ID" into reference. If the model instead writes a bare 7+ digit number — a copied byte count, a date written as YYYYMMDD, a large ticket number with no keyword, or any other big number that shows up in its reasoning about the fix — citedReference treats it as a valid sha, upstreamFixStands returns a truthy retraction, and decideGate returns ALREADY_FIXED. The identical regex is duplicated verbatim in triage-poc.js, where it drives alreadyFixedStands() for Stage 3's challenge-4 retraction. Both surrounding comments (lines 744-749 and 825-828 in triage-static.js) explicitly call out an unreferenced retraction as "the one failure mode that silently discards a real finding rather than merely reporting a false one" — which is exactly the failure mode this gap opens.

Why existing tests do not catch it. The NOT_CITATIONS table in tests/static.test.mjs (lines 362-364) is meant to cover "bare numbers and dates, indistinguishable from line numbers and years," but only tests '4521' and '2021.03', both under 7 digits. Nothing in the 146+ mutation-gate suite exercises a 7+ digit bare number, so this gap is invisible to the existing regression suite.

Step-by-step proof:

  1. Extract the sha regex: (^|[^0-9a-z])[0-9a-f]{7,40}([^0-9a-z]|$).
  2. Note [0-9a-f] matches every character in [0-9], so any decimal string of length 7-40 satisfies the quantified class.
  3. Run citedReference('the fix landed on 20240115'): the substring 20240115 is 8 digits, surrounded by non-alphanumeric boundaries (a space before, end-of-string or space after) — it matches, and the function returns truthy.
  4. This is a date, not a commit/PR/issue/advisory reference, yet upstreamFixStands reads a truthy return as "cites a valid reference."
  5. decideGate therefore returns ALREADY_FIXED for a finding that has no actual upstream fix evidence — discarding a potentially live, exploitable finding based on a coincidental date string.

The fix. Require at least one hex letter (a-f) to appear in the matched run when there is no other qualifying signal (e.g., a #, PR, CVE-, GHSA- prefix elsewhere in the string), or explicitly exclude date/timestamp-shaped decimal runs (8-digit YYYYMMDD, 10-digit unix timestamps). Add a 7+ digit bare-number case to the NOT_CITATIONS table in tests/static.test.mjs so the mutation gate can catch a regression here.

On severity. All three independent verifiers who examined this converged on nit: it is a real, verified defect that contradicts the code's documented intent and escapes the test suite, but triggering it requires the history agent to write a bare, non-keyword 7+ digit number into reference when explicitly prompted for "a commit, PR, issue or advisory ID" — a plausible but not likely model output. There is also a genuine design trade-off noted by one verifier: some real git short-shas are all-decimal, so a blanket rejection of all-digit strings would introduce a different false-negative. This does not block the PR, but is worth the maintainer's attention given it sits on the single most safety-critical retraction path in the plugin.

@kz-tob kz-tob added the blocked:CLA-unsigned Author/Contributor needs to sign the CLA label Aug 10, 2026
@GrosQuildu

Copy link
Copy Markdown
Contributor Author

Ignore CLA please, I committed from not existing email.

@kz-tob kz-tob removed the blocked:CLA-unsigned Author/Contributor needs to sign the CLA label Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants