fp-check - merge with concept-prover and online-triage, add evals - #243
fp-check - merge with concept-prover and online-triage, add evals#243GrosQuildu wants to merge 25 commits into
Conversation
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>
|
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. |
| 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 |
There was a problem hiding this comment.
🟡 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 = terminatorindex() 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.
"""
passLine 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):
- Write a Python file with
def exploit():whose docstring's second line containsSome text with \""" escaped inline...and whose body is justpass. - Run
bash poc-lint.sh --symbol target_app.ledger.transfer_balance file.py. - Output:
1 file(s) clean, exit code 0 — the stub body was never inspected. - Delete only the line containing the embedded
\""", keeping the docstring otherwise intact and thepassbody unchanged. - Re-run the identical command: now it reports
stub-body ... A definition whose whole body is a placeholderand exits 1. - 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.
| 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', | ||
| '', | ||
| ' ', | ||
| ] |
There was a problem hiding this comment.
🟡 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:
- Extract the sha regex:
(^|[^0-9a-z])[0-9a-f]{7,40}([^0-9a-z]|$). - Note
[0-9a-f]matches every character in[0-9], so any decimal string of length 7-40 satisfies the quantified class. - Run
citedReference('the fix landed on 20240115'): the substring20240115is 8 digits, surrounded by non-alphanumeric boundaries (a space before, end-of-string or space after) — it matches, and the function returns truthy. - This is a date, not a commit/PR/issue/advisory reference, yet
upstreamFixStandsreads a truthy return as "cites a valid reference." decideGatetherefore returnsALREADY_FIXEDfor 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.
|
Ignore CLA please, I committed from not existing email. |
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 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 evalwith an ablation baseline — the identical promptis 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:
integration-capblocked-attack-pathalready-fixeddead-routeinflated-impactnet/httprecovers per connection, so one connection drops and the server keeps servingwrong-parametershell=Trueand attacker input in one fileshould-not-fireWhich stages were measured
Only Stage 1 is measured across the board. All seven prompts pin "work
offline", so:
TRUE_POSITIVE, and onlyintegration-capreaches one. So it is exercised by one caseSo 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
Per case:
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 canrise 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.