Eval suite for yara-authoring
Three cases. Two measure whether the skill makes rules tighter rather than just producing one; the third is the regression gate — it is the only case that fails when the skill's own content is removed.
Running
CLAUDE_CODE_WALNUT_SPIRE=1 claude plugin eval . --ablation with-without \
--judge-model opus --allow-tools Bash Write
CLAUDE_CODE_WALNUT_SPIRE=1is required whileplugin evalis in early access. Without it the command exits withplugin eval is currently in early accessand runs nothing.- Install the
yrCLI first (brew install yara-x). The skill tells the agent to runyr checkandyr fmt; on a machine without it the agent falls back to eyeballing the syntax, which is a different task and scores differently. Runs from machines that differ on this are not comparable, which defeats keeping the case names stable across runs.
The headline number is Δ — the with-plugin score minus the no-plugin baseline. A case that scores 1.0 in both arms measures nothing.
--allow-tools Bash Writeis the operator grant for gated tools. Eachprompt.mdalso listsallowed_tools, but that only declares what the agent may reach for within a run; the gated set still has to be granted from outside. Both are required.--judge-model opusbecause the cases runmodel: sonnet, and a model scoring its own output self-prefers. Any judge at sonnet tier or above works, as long as it differs from the case'smodel:.
Pilot one run before spending on the whole suite:
CLAUDE_CODE_WALNUT_SPIRE=1 claude plugin eval . --runs 1 --ablation with-without \
--case '02-*' --allow-tools Bash Write
Results land in results/<timestamp>/ (gitignored).
Cases
| Case | Input | What it tests | Δ vs baseline | Detects a gutted skill? |
|---|---|---|---|---|
02-string-dump-pe |
Twelve extracted strings, nine of them junk | Picking the three family-unique indicators and saying why the rest were dropped or demoted | +0.52 | no |
05-legacy-endianness |
A legacy rule with two dead magic-byte branches | Fixing uint32(0) == 0xCAFEBABE and explaining why a little-endian read reverses the constant |
+0.33 | no |
06-review-with-linter |
A rule with five concrete defects, for review | Running the plugin's linter and reporting findings by issue code | +0.67 | yes |
Case 02 is the only one that writes a rule file, so it carries the mechanical pre-filter
checks alongside three llm rubrics.
Why case 06 is the gate
Cases 02 and 05 both show real uplift over a no-plugin baseline, and neither detects the skill being broken. That was measured, not assumed:
| Case | intact | guidance deleted |
|---|---|---|
02 (−2,988 chars: string-rejection table, all of/any of table inverted, Grouping by Confidence, Core Principles 2 and 3) |
0.94 | 0.97 |
| 05 (−727 chars: the little-endian callout, the Common Mistakes row, the corrected constants) | 1.00 | 1.00 |
| 06 (−1,045 chars: the Scripts section and the review direction) | 1.00 | 0.60 |
For 02 and 05 the uplift comes from a skill being loaded, not from what it says. Sonnet
already knows that any of across generic strings is loose and that uintNN() reads
little-endian; it just needs to be in a frame where it thinks about YARA carefully. Delete
the guidance and it still answers correctly.
Case 06 works because it tests something only this plugin's files supply: the linter's
issue codes. reports-linter-codes passes 3/3 with the skill intact, 0/3 with the Scripts
section removed, and 0/2 with no plugin at all — a regex, so no judge variance. When
adding a case, prefer this shape: something only this plugin can supply, checked
mechanically.
Be precise about what it proves, though. It is a skill-content gate, not proof the
linter ran. The codes are not secret — references/style-guide.md tabulates all nineteen,
and SKILL.md names E002 and W009 in prose — so an agent could in principle map the
defects to codes by reading the reference and never invoke yara_lint.py. The 0/3 result
when the Scripts section is removed is still real: with nothing pointing at the linter,
runs did not go looking for the table either.
The residual risk is a silent pass. If uv or the yara-x wheel fails in the sandbox,
every script call errors, the agent falls back to the reference table, and
reports-linter-codes still passes — worse than the yr non-hermeticity above, which
merely scores differently. This gap is open. A linter-output-format grader matching
the linter's own SEVERITY [CODE] rule: layout was tried and removed: it failed 0/3 in
both arms, because agents paraphrase findings rather than pasting raw tool output, so it
depressed the with-plugin score without separating anything. tool_used: Bash is no better
— Bash gets used for other things in both arms. Until there is a check that distinguishes
execution from lookup, treat a green case 06 on an unfamiliar machine as needing a
confirming look at whether the scripts actually ran.
Case numbers are non-contiguous. The suite was cut from eight cases to two
(02-string-dump-pe, 04-generic-only-trap), then 04-generic-only-trap was removed
after measuring Δ 0.00 across four runs — both arms scored 50/100/100 and 50/50/100, the
same values in a different order — and 05-legacy-endianness and 06-review-with-linter
were added. Names are kept stable so stored results still line up.
Grader conventions
These are documented, not enforced — the suite has no grader tests, so a new grader that skips the comment guard below will score a false pass rather than fail loudly.
-
Weights are explicit on every grader —
2forllmrubrics,1forregexandfile_exists— so the judgement signal is not diluted by mechanical checks that happen to be numerous. -
Regex graders that target the rule file are anchored to non-comment lines, with
flags: m:^(?:[^/\n]|/(?![/*]))*<pattern>Without the prefix, a model satisfies the grader from a comment — writing
// rejected: Global\LarkMtx_7742 is version-specificwhile leaving the indicator out of the rule. The[/*]character class blocks both//line comments and the opening of a/* ... */block. It matters most on case 05, whose prompt asks the agent to say what was wrong: quoting the original condition as/* was: uint32(0) == 0xCAFEBABE */is likely behaviour, and with a//-only guard that comment both failsno-dead-branch-lefton a correct answer and satisfieszip-magic-correctedon a broken one. A pattern spanning multiple lines inside a block comment can still slip through; strip comments before matching if a future grader needs that. -
A file-targeted grader fails when its file is missing; it does not pass vacuously. Verified by pointing case 05's
not_containsgrader at a filename no agent writes: the harness reportsgrader threw: … path "…" does not existand scores it 0. So a run that writes nothing cannot bank credit from anot_containscheck, andno-dead-branch-leftneeds no companion guard. -
skill-fired.mdcarries noweight:and noarm:on purpose. Atool_usedgrader withtool: Skilland noarm:is display-only to the harness: dropped from the baseline arm, flagged[with-only, not scored]in the with-plugin arm, counted in neither numerator nor denominator. Addingarm:would start scoring a check the baseline can never pass and inflate measured uplift. The schema rejectsweight: 0, so omittingarm:is the mechanism. -
filesizeand magic-byte graders check that the pre-filter exists, not that it is well chosen. They accept every notation the skill promotes —500KB,2MB,400_000,0x80000, a bare byte count of three digits or more — and both spellings of the PE magic,uint16(0) == 0x5A4Danduint16be(0) == 0x4D5A(SKILL.md:63). SKILL.md lists numeric underscores as a v1.5.0+ feature rather than recommending them as style, but agents emit the form, so the grader accepts it.It does reject a bound with fewer than three digits, so
filesize < 0andfilesize < 2— pre-filters that make the rule match nothing — do not score.An earlier version tried to band-check the bound against the prompt's stated ~340KB and fail anything under 400KB. That was dropped: it rejected the
_and hex forms the skill itself recommends, and it accepted900MB. It also would not have discriminated — across seven recorded runs every rule chose 1MB, 2MB or 5MB, so a strict band fails both arms equally, which measures as little as passing both. If you reinstate a band check, confirm first that real runs land on either side of it. -
A
regexgrader against the reply takes notarget:at all.focus: last_messageis anllm-grader key; on aregexgrader the harness rejects it withUnrecognized key(s) in object: 'focus', andtarget: {source: last_message}fails withtarget: Invalid input. Omit the key. Only file-targeted regex graders taketarget: {source: file, path: …}. -
explains-rejectionsandjunk-cannot-fire-alonemust agree on what is allowed. They used to conflict:junk-cannot-fire-aloneaccepts generic strings as corroboration gated behind a unique indicator, whileexplains-rejectionsdemanded at least two strings be left out of the rule entirely. A run that gated everything instead of dropping it satisfied the first and failed the second, which is why the same skill scored PASS, FAIL, FAIL on three identical runs.explains-rejectionsnow accepts either disposition — dropped, or present but unable to fire alone — provided the response says which it is and why. Case 02 also runs 5 times rather than 3.Replacing it with a regex was tried and rejected. Checking the reply for rejection-language plus two of the nine junk strings passes all three recorded with-plugin replies but also two of three baseline replies, because a baseline model names the junk strings, calls them generic, and then uses them anyway in an N-of fallback branch. The distinguishing property is the role each string plays in the condition, which regex over prose cannot see. Checking the rule file instead does not work either: gated corroboration is explicitly allowed, so a
not_contains VirtualAlloccheck would fail a correct answer. -
A grader that no arm can fail is worth deleting, not reweighting.
finds-the-real- defectsin case 06 passes in both arms — base Sonnet finds the defects unaided — and is kept only because it would catch a genuine regression into approving a bad rule. An earlierran-the-lintergrader was dropped outright: it regex-matched the script name in the reply, so it failed runs that had demonstrably run the linter but summarised without naming the file.reports-linter-codesalready proves execution, since the codes cannot be guessed.