Skip to content

Feat/the detector knows icons and url elicitation - #289

Merged
evkir merged 4 commits into
mainfrom
feat/the-detector-knows-icons-and-url-elicitation
Sep 19, 2026
Merged

evkir merged 4 commits into
mainfrom
feat/the-detector-knows-icons-and-url-elicitation

Conversation

@evkir

@evkir evkir commented Sep 19, 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

…reads it

Tool poisoning hides directives in what an LLM is shown before it calls
anything. _collect_text named seven keys and icons was not one of them,
so a directive carried in an icon reached no matcher at all -- not for
want of a pattern, but because the text was never collected. The probe
had been returning the field since it dumps by alias with exclude_none;
the scanner was the blind half.

icons arrived with revision 2025-11-25 and is server-controlled: src is
a URL the client is expected to fetch, mimeType and sizes are free
strings rendered beside the tool name. Measured in the installed SDK:
mcp-types 2.0.0 declares Icon(src, mime_type, sizes, theme) and carries
icons on Tool, Resource, Prompt and Implementation.

The test asserts the channel, not the verdict. Its clean half runs first
and must stay clean, so a flag in the second half can only have come
from the field under test; without that control the same assertion would
pass with the channel still blind. Mutation: removing icons from the
collected keys kills exactly this test and nothing else, as predicted.

The file's own strict baseline was eight errors and none of them were
checked by CI. Touching it means owning it: seven missing return
annotations annotated, and the method-assign on the logging double named
with a coded ignore rather than left bare.
An icon fetched from a CDN is how the field is meant to work, and this
matcher reads flattened text, so it cannot tell the server's own origin
from anyone else's. A pattern on 'src is remote' would flag the ordinary
case and measure nothing. What is scored instead is content the client
executes or renders as markup beside the tool's name, before the user
has agreed to call anything: icon_active_scheme for javascript:,
vbscript:, data:text/html and data:image/svg, icon_executable_carrier
for a declared image/svg+xml or an .svg name.

They go to MCP_POISONING_PATTERNS rather than INJECTION_PATTERNS. The
eval corpus is scored by l1_scorer, which reads the generic list only,
and a JSON icons key already appears in one benign sample; a generic
pattern would carry that risk into recon output for a signal that
belongs to tool metadata. The declared pattern count and the three
documents pinned to it are therefore untouched.

False positives measured before the patterns were written, on three
populations: the eight tools this repository's own server advertises,
the 45 benign corpus samples, and hand-built negatives including a PNG
data URI. Zero on all three -- with the limit stated plainly: none of
those populations contains an icons field at all, so the repository
holds no negative control for this category yet. Recorded as a tail.

Four mutants, four kills. Removing either category kills the test.
Removing either half of the carrier alternation kills it too, but only
after the first pass caught a defect in the test rather than the code:
one fixture carried a declared type and an .svg name at once, so the
extension half was unfalsifiable and its mutant survived. Split into two
fixtures, the second with a version query a CDN would append.
The probe has recorded capabilities since it was written and no stage read
it: poisoning, overprivilege, exposure and trust all take tools, attestation
took transport and a flag. So a server's declared surface -- experimental
blocks, protocol extensions, task support -- was collected and dropped. It
belongs in the attestation summary, whose own standing reason says MCP has no
in-protocol capability attestation: a claim about a set nobody reported.
Names only; the values are free-form per the spec and stay in the raw dump.

URL-mode elicitation turned out not to be a scanner category at all. Measured
in mcp-types 2.0.0: ServerCapabilities has eight fields and elicitation is
not among them. It is a client capability -- the client says it is willing to
open a URL, and the server demands one with error -32042 in reply to a call.
A passive probe calls nothing, so there is nothing on the target to detect.
The risk points the other way: a scanner that advertises the capability has
agreed to follow the links of the endpoint it is scanning.

Today it does not, and that was held by structure alone. The second test
pins it, with the control as the point of the file: the SDK builds
ElicitationCapability(form=..., url=...) from one callback with no separate
switch, so someone adding a form prompt would turn on URL mode in the same
line. Read off a built session rather than off our source, because our source
is not what decides this.

Four mutants, four kills, two of them only after the tests were fixed. Both
survivors were wiring: dropping the argument at the stage call site, and
dropping it in run(), each left the whole suite green because every existing
test entered below that layer. Now one test per layer.

agent.py's single strict error was Returning Any -- the untyped coroutine
runner, not the probe, decided the type; annotating the result at the call
site fixed it and made the module clean, which the drift step then reported
as an undeclared clean module. Declared: scope 99 -> 100, errors 284, drift
none, crossings unchanged at 22 reaching 29 (agent.py became a crosser as
mcp_scan/__init__.py stopped being one). Page and badges follow, by script.
Verified in the CI's own shape -- bare mypy on a cold cache -- after a
full-package run's cache made a file-list run report 55 errors from seven
modules it was not asked about.
…emise

The register said no test mentions either term and a tree-wide search returns
nothing. Half of that is now false and the other half was never the right
question. Icons: the field sat outside the metadata whitelist, so no pattern
could have reached it, and two tests hold the channel and the categories.
Elicitation: ServerCapabilities has eight fields and none of them is
elicitation, so a scanned server cannot advertise URL mode at all. What a
target does with it arrives as error -32042 in reply to a tool call, and this
scanner calls nothing -- which is why the row stays partly rather than closed.
The exposure that does exist runs toward us, and its test is named too.

The corpus is deliberately untouched, and that is a measurement rather than a
shortcut. Samples are scored by l1_scorer, which reads the generic pattern
list only; a tool-metadata category lives in the MCP list, so an icon sample
would be scored by nothing, land in the blind subclasses and move the pinned
32/51 without measuring anything. The plan for today assumed these categories
would go in the generic detector. The false-positive measurement said
otherwise: one benign sample already carries a JSON icons key, and a generic
rule would carry that into recon output.

The scanning page gains the two categories in its stage table, the declared
capability set beside the attestation row, and a section stating both
directions of elicitation -- so the behaviour is described where a reader
looks for it rather than only in a commit message.
@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@evkir
evkir merged commit 0a13238 into main Sep 19, 2026
10 checks passed
@evkir
evkir deleted the feat/the-detector-knows-icons-and-url-elicitation branch September 19, 2026 13:18
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.

1 participant