Skip to content

W3/detector eval cli - #237

Merged
evkir merged 4 commits into
mainfrom
w3/detector-eval-cli
Aug 27, 2026
Merged

W3/detector eval cli#237
evkir merged 4 commits into
mainfrom
w3/detector-eval-cli

Conversation

@evkir

@evkir evkir commented Aug 27, 2026

Copy link
Copy Markdown
Owner

What this changes

How it was measured

Checklist

  • ruff format --check cyberai/ tests/ and ruff check cyberai/ tests/ pass
  • pytest -W ignore::DeprecationWarning -m "not slow and not smoke" passes
  • New behaviour is covered by a test that fails without the change
  • I have read CLA.md and I hereby sign the CLA

evkir added 4 commits August 27, 2026 20:33
…corpus

The corpus committed on the previous branch is only worth what the command
that reads it is worth. This is that command: it takes a corpus directory,
scores every sample through the production detector, and reports precision,
recall and false-positive rate per subclass. The corpus path is required and
has no default, so a figure published anywhere names the corpus it came from.

The report is per subclass on purpose. On the tracked corpus at the
production threshold the headline is 25.0% recall against 11.1% false
positives, and the headline hides the finding: seven subclasses score below
the threshold on every sample they hold. Encoded payloads, homoglyphs,
paraphrase without keywords, five languages, MCP tool metadata, social
pressure and -- the one that stings -- exfiltration, which has five patterns
written for it. Those are rendered in red and listed under the table.

Rates that have no referent print as a dash rather than as zero. A subclass
of captured nmap output contains no positives, so it has no precision: the
first version of the table printed 0.0% there, which reads as "it fired and
was always wrong" instead of "the question does not apply". Benign slices
report a false-positive rate instead, which is the figure the sprint's
acceptance criterion is written against. scanner_xml sits at 100% of one
sample, which is the single nmap XML capture being flagged.

The loader refuses a corpus it cannot trust rather than scoring a partial
one: a missing manifest, an entry pointing at no file, a duplicate id, an
unknown label, or a line that is not JSON each name the manifest line that
caused it. A duplicate id would otherwise collapse two samples into one
entry in the scores map and quietly change the denominator.

evaluate() takes an optional scorer so a rebuilt detector can be measured
against the same corpus without this module knowing anything about it. The
default is the production path, which is what makes a run of this a
statement about the product.

Mutation-tested, four mutants. Three killed by the unit tests: the
false-positive rate divided by the slice instead of by its negatives, the
positives check counting what fired instead of what was labelled, and the
flag comparison made strict, which drops true positives from 12 to 5 and
shows how much of the result the boundary carries.

The fourth survived and was the useful one. Unregistering the group from the
root command left all 27 unit tests green while `cyberai detector eval`
stopped existing: a module with a working API and no route to it is the same
defect as a helper with no call site, approached from the other end.
tests/unit/test_detector_eval_cli.py now drives the real Click app and all
six of its tests fail under that mutant.
The detector's numbers were living in three places that a machine cannot
read: a test docstring, some commit messages, and a terminal that has since
scrolled away. This adds --report, which writes the Markdown the evaluation
produces, and commits the file it produced.

The artifact follows the convention the benchmark scorecards already set. A
run-metadata table naming the timestamp, the engine version, the corpus and
the threshold, then the confusion matrix, then a row per subclass, then the
blind list in prose. Rates with no referent carry a dash rather than a zero,
the same distinction RunMeta makes for a knob that was never measured: a
placeholder in a machine-readable table reads as a value the run chose.

At the production threshold on the tracked corpus: 12 true positives, 36
false negatives, 5 false positives, 40 true negatives. Recall 25.0%,
precision 70.6%, false positives 11.1%. Seven injection subclasses are blind
-- encoded, exfil, homoglyph, mcp_metadata, multilingual, paraphrase and
social -- and the file names how many samples each one holds, so the size of
what is invisible is on the page next to the headline.

test_baseline_artifact_is_current.py regenerates the report in memory and
compares it to the committed file line by line, timestamp excluded. Editing
the file by hand fails. Changing the detector without re-running the command
fails. This repository has already published a stale artifact once, a
scorecard showing 28 requests where a re-run gave 21, and the rule against
it was prose in a document, which gates nothing.

Mutation-tested. Four against the gate, all killed: a number edited by hand
in the artifact, a line of the blind list edited, undefined rates rendered
as zero, and the production threshold moved, which fails three of the four
because the report would then describe a product nobody ships.

A fifth mutant covers the route rather than the content. The architecture
gate calls render_report itself, so it would stay green if --report stopped
writing anything; making the flag announce a file it never wrote fails three
of the CLI tests instead.
…inst it

adversarial-robustness.md said the threshold was not tuned against a corpus.
That was true when it was written and stopped being true when the corpus was
committed. It now carries the measurement instead, and a gate that keeps it
carrying the current one.

Recall 25.0%, precision 70.6%, false positives 11.1% at the production
threshold; 50.0% and 17.8% at the detector's own cut of 25. The document
leads with the part the headline hides: seven injection subclasses score
below the threshold on every sample they hold, and no list of English
regular expressions reaches any of them. It also names the two false
positives that are ours, because ordinary nmap output reaches the guard on
an XML comment and a hex escape, and the product flagging its own scanner is
worth saying out loud in a security document.

One limitation was corrected rather than removed. A single pattern hit does
block a tool argument, at the sanitize_input decorator's one call site in
tls_tool -- verified, not assumed -- but that path uses the detector's fixed
cut of 25 while the guard uses a configurable threshold, so the two halves
of the boundary answer at different sensitivities. That is the honest form
of the limitation and it was not stated anywhere.

The obfuscation bypass moves from an assumption to a measurement: homoglyph
substitution and base64 encoding score zero on every sample in the corpus.

test_docs_quote_the_artifact.py pins every percentage in the document to
either the committed report or a re-run at the alternate threshold, and pins
the count of blind subclasses to what the corpus actually produces. Wording
is not pinned; a test that pinned sentences would fail on every paragraph
edit and teach a reviewer to regenerate prose without reading it.

Mutation-tested, five mutants. Four killed immediately: a figure edited in
the document, the blind count edited, the reproduce command dropped, and the
README row dropped.

The fifth survived and was the point. Moving DEFAULT_THRESHOLD left this
file green, because the blind-subclass test had 50 written into it as a
literal. It imports the constant now and the mutant fails. This is the
second time in two days that a test copying a production value has been
caught measuring its own copy.
Recall 25.0% to 29.2% at the production threshold, precision 70.6% to 73.7%,
false positives unchanged at 11.1%. At the detector's own cut of 25, recall
50.0% to 58.3%. Four injections in the corpus become visible and no benign
sample moves.

Three passes, in order. NFKC folds compatibility forms, which is what
catches fullwidth Latin; it does not touch Cyrillic or Greek, because those
are different letters and not variants of the same one, so a table of
unambiguous look-alikes follows it. Zero-width characters are deleted
between the two: they carry no glyph, so a payload sliced across them reads
normally and matches nothing.

The table is written as escape sequences, not as the characters themselves.
A file holding a Cyrillic small er literally is a file a reviewer cannot
read accurately, in exactly the way the attack intends, and the bidi range
in this module was already written that way.
test_no_confusable_letters_in_source.py keeps it so. Its scope is Cyrillic
and Greek, not the whole of ASCII's complement: an earlier revision banned
every non-ASCII character and went red on 180 files of em-dashes, arrows and
box drawing, which would have been a demand to rewrite the repository rather
than a rule anyone had.

The folded text is used for matching and is never returned. The guard scores
the raw message and transmits the sanitised one, and normalising on the way
out would blind the detector the way scoring the sanitised copy already did
once. The mapping does rewrite legitimate Cyrillic into Latin nonsense --
a Russian sentence comes out unreadable -- which costs nothing against
English patterns and is the reason the copy must not escape the function.

Two blind subclasses are now one fewer. homoglyph-cyrillic went from 0 to
75 and homoglyph-fullwidth from 0 to 25, so the technique is narrowed rather
than closed: one of three samples still scores under the threshold.
zerowidth went from 0 to 75, which takes the smuggling subclass to three of
three. Six subclasses remain invisible: encoded, exfil, mcp_metadata,
multilingual, paraphrase and social.

The committed report and the security document were regenerated rather than
edited, and the tests pinning the old figures failed until they were, which
is what they are for. One of them named the sample that moved and pointed at
the document to update instead of letting a subclass quietly leave the list.

Mutation-tested, four mutants. Three killed: NFKC removed, the zero-width
deletions removed, the confusable table emptied.

The fourth survived. Returning the folded length from input_length left
every test green, because the existing assertion used a pure-ASCII string
where both lengths are equal by construction -- an assertion that cannot
fail on the thing it claims. It now uses a string carrying zero-width
characters, where the two lengths differ, and the mutant fails.
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 99.58506% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cyberai/core/security/eval_corpus.py 99.41% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@evkir
evkir merged commit 20e1bcb into main Aug 27, 2026
7 checks passed
@evkir
evkir deleted the w3/detector-eval-cli branch August 27, 2026 19:20
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.

2 participants