Skip to content

feat(mcp): detect concealed Unicode in tool listings - #154

Merged
blitzcrieg1 merged 1 commit into
masterfrom
feat/concealed-unicode-detection
Sep 2, 2026
Merged

feat(mcp): detect concealed Unicode in tool listings#154
blitzcrieg1 merged 1 commit into
masterfrom
feat/concealed-unicode-detection

Conversation

@blitzcrieg1

Copy link
Copy Markdown
Owner

Closes #152.

Why this one is different from the rest of the MCP backlog

The fingerprint answers "did this server change what it advertises". It says nothing about a server that was hostile from the first listing anybody ever took. That trust-on-first-use gap has been raised twice in review and is real.

Concealed control characters are the exception, and the only reason this earns a separate signal: they need no baseline and no history. There is no legitimate reason for a tool description to carry a Unicode TAG block.

U+E0000 to U+E007F mirrors ASCII, so a whole second instruction can be written in it and render as an empty string:

clean description length          14
TAG-concealed description length  37   (renders identically to a human)

Research calls this an approval-view fidelity gap, and the phrase is exact: the human approving the tool reads one string, the model receives another. Recording what the model was handed rather than what a human was shown is this project's entire premise, and here the two demonstrably differ.

What it emits

proxy emits      : {'tag_block': 18}
survives ingest  : {'tag_block': 18}
on the event     : {'tag_block': 18}
no text leaked   : True

Counts per category. Never the text, never which tool. A finding that carries a concealed instruction has copied that instruction into the trail and forwarded it to a SIEM.

Categories: tag_block, zero_width (ZWSP, ZWNJ, ZWJ, BOM, soft hyphen), bidi_control.

Design decisions worth reviewing

Walks every string, not a field list. #103 settled that argument for the severity split and it applies identically here: an allowlist fails open on whatever the spec adds next. Tested against a property description nested in inputSchema and against _meta, both of which a named list would have missed.

Private Use Area is deliberately not a category. Icon fonts use it legitimately, and a category that cries wolf costs more than the one case it might catch. There's a test asserting it stays unflagged.

Attached on new as well as changed. Everything else in this module needs a prior. This does not, and a test pins that it fires on a server never seen before.

Omitted entirely when clean, so mcp_schema, the quietest event class in the trail, gains no field on a well-behaved server.

Not blocked by the freeze

core/diagnostics/mcp_schema.py is outside _RULESET_SOURCES. Ruleset fingerprint unchanged at 15846a0915769d4a, dogfood clock untouched.

The field is declared on ExternalIngestBody, because an undeclared field is dropped silently and that has already cost this file two shipped bugs. test_ingest_roundtrip.py would have caught it regardless, which is the point of having written it.

1,182 tests, ruff clean, benchmark 26/26 with 0 missed and 0 false positives.

🤖 Generated with Claude Code

Closes #152.

The fingerprint answers "did this server change what it advertises". It says
nothing about a server that was hostile from the first listing anybody took,
and that trust-on-first-use gap has now been raised twice in review.

Concealed control characters are the exception, and the only reason this check
earns its own signal. They need no baseline and no history, because there is no
legitimate reason for a tool description to carry a Unicode TAG block. U+E0000
to U+E007F mirrors ASCII, so an entire second instruction can be written in it
and rendered as an empty string. Research calls the result an approval-view
fidelity gap, and the phrase is exact: the human approving the tool reads one
string and the model receives another. Recording what the model was handed
rather than what a human was shown is this project's whole premise, and here
the two demonstrably differ.

Counts per category, never the text, never which tool. A finding that carries a
concealed instruction has copied that instruction into the trail and then
forwarded it to a SIEM.

Walks every string in a tool definition rather than a list of field names. #103
settled that argument for the severity split and it applies for the same
reason: an allowlist fails open on whatever the spec adds next. A property
description nested in inputSchema is exactly what a named list would miss, and
so is _meta, which was exempt from hashing until #142.

Private Use Area is deliberately not a category. Icon fonts use it legitimately
and a category that cries wolf costs more than the one case it might catch.

Lives in core/diagnostics, which is outside _RULESET_SOURCES, so the dogfood
clock is untouched. Declared on ExternalIngestBody deliberately: an undeclared
field is dropped silently and that has already cost this file two shipped bugs.
test_ingest_roundtrip.py would have caught it either way, which is the point of
having written it.

1182 tests, benchmark 26 of 26 with 0 missed and 0 false positives, ruleset
fingerprint unchanged at 15846a0915769d4a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@blitzcrieg1
blitzcrieg1 merged commit b2f80bd into master Sep 2, 2026
9 checks passed
@blitzcrieg1
blitzcrieg1 deleted the feat/concealed-unicode-detection branch September 2, 2026 19:25
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

detection: nothing looks for concealed Unicode in tool descriptions, which is the one poisoning visible on first sight

1 participant