Skip to content

feat(mcp): strip invisible Unicode TAG characters from MCP content (port of block/goose#10746) - #80689

Merged
teknium1 merged 1 commit into
mainfrom
goose-port/mcp-unicode-tag-sanitize
Aug 17, 2026
Merged

feat(mcp): strip invisible Unicode TAG characters from MCP content (port of block/goose#10746)#80689
teknium1 merged 1 commit into
mainfrom
goose-port/mcp-unicode-tag-sanitize

Conversation

@teknium1

@teknium1 teknium1 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

MCP text content is now sanitized of invisible Unicode TAG characters (U+E0000–U+E007F) before it enters the model's context, closing the "ASCII smuggling" prompt-injection channel where a malicious/compromised MCP server hides instructions that are invisible in every terminal and chat UI but fully visible to the tokenizer.

Ported from aaif-goose/goose#10746 (merged Aug 5, 2026), adapted to Hermes' Python MCP client with one deliberate improvement: valid emoji tag sequences (U+1F3F4 base + tag spec + U+E007F CANCEL TAG — regional flags like 🏴󠁧󠁢󠁳󠁣󠁴󠁿) are preserved rather than mangled, consistent with our ZWJ-preservation stance.

Changes

  • tools/ansi_strip.py: new strip_unicode_tags() with a fast path (no regex work when no plane-14 chars present) and emoji-tag-sequence carve-out.
  • tools/mcp_tool.py: applied at every MCP text ingestion point:
    • tool result text blocks (call_tool)
    • embedded resource text (_render_mcp_resource_block)
    • read_resource contents
    • get_prompt message content
    • tool descriptions entering the registry schema (_convert_mcp_schema) — the highest-leverage injection surface since descriptions ship on every API call
  • tests/tools/test_unicode_tag_strip.py: 9 tests — goose's vector, smuggled-instruction payloads, orphan tags next to valid flags, unterminated sequences, ZWJ emoji untouched.

Validation

Result
tests/tools/test_unicode_tag_strip.py + test_ansi_strip.py 34/34 pass
MCP content/resource/structured tests 38/38 pass
E2E: smuggled rm -rf / in tag chars via resource block + tool description stripped, clean text preserved

Source

Infographic

MCP Unicode tag sanitization

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 1944cb9 — Port from aaif-goose/goose#10746: strip invisible Unicode TAG cha

⚠️ Warnings

CI timings · View report · View job

Wall time 28m6s vs 8m (+251.3%). 14 job(s) slower, 9 faster, 1 unchanged.

  • Python tests / Run tests slice 7/12: -46.0s
  • Python tests / Run tests slice 1/12: +46.0s
  • Python lints / Windows footguns (blocking): +30.0s
  • Python tests / Run tests slice 6/12: -27.0s
  • Python tests / Run tests slice 11/12: +22.0s

OSV vulnerability scan · View job

5 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P3 Low — cosmetic, nice to have tool/mcp MCP client and OAuth labels Aug 7, 2026
@spfcraze

spfcraze commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary:
The diff wires strip_unicode_tags into five MCP text ingestion points, but the server-controlled description fields in list_resources/list_prompts/get_prompt results and inside tool inputSchemas still reach the model unsanitized.

Problems:

  • tools/mcp_tool.py:5114 (_make_list_resources_handler) and :5229/:5235 (_make_list_prompts_handler) copy server description fields into the result JSON returned from the tool call; this diff does not touch either handler.
  • tools/mcp_tool.py:5304 — in the same _make_get_prompt_handler whose message content this diff sanitizes, resp["description"] = result.description is returned unsanitized.
  • tools/mcp_tool.py:5543_convert_mcp_schema sanitizes the tool-level description but passes the inputSchema through _normalize_mcp_input_schema unchanged, so per-parameter description strings are not tag-stripped.

Solution:
Apply strip_unicode_tags to the description fields in _make_list_resources_handler, _make_list_prompts_handler, and _make_get_prompt_handler, and to description strings inside the normalized inputSchema, so the coverage matches the stated "every MCP text ingestion point" claim.


Checked against 242b1b1 — the PR head when this was written — and eb8421b, main at the same moment.

@asamassekou10

Copy link
Copy Markdown

Nice work on this. It complements #78952 well: that PR detects Unicode Tag payloads in skill files before installation, while this one protects MCP content at runtime.

One coverage question before merge: could we also sanitize descriptions returned by list_resources, list_prompts, and get_prompt, along with nested inputSchema descriptions? They look like remaining server-controlled text paths. A few regression tests there would make the “every MCP text ingestion point” claim airtight.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The patch improves ordinary MCP text handling, but it does not establish a complete sanitization boundary. Its emoji-preservation regex accepts arbitrary hidden instruction payloads, and several model-facing MCP outputs bypass the helper entirely, including error text and structured or utility metadata. A malicious MCP server can therefore still place invisible instructions in the model context.

  • [P1] Emoji-tag preservation accepts arbitrary hidden instruction payloads
    The preserved-sequence alternative matches any U+1F3F4 black-flag base followed by one or more U+E0020-U+E007E tag characters and U+E007F, without checking whether the payload is a registered Unicode emoji tag sequence. An MCP result can encode text such as 'ignore previous instructions' in that range and have strip_unicode_tags return it unchanged. The characters remain invisible on normal terminal/chat surfaces but remain model-visible, so the new positive flag test does not close the smuggling channel.
    Remediation: Strip all U+E0000-U+E007F characters, or preserve only a strict allowlist of registered emoji tag sequences and strip the tag payload/cancel for every other black-flag sequence. Add an adversarial test with arbitrary tag text after U+1F3F4.

  • [P1] Model-facing MCP outputs still bypass Unicode-tag stripping
    The helper is applied to successful text blocks and selected resource/prompt content, but the result.isError branch concatenates block.text and resource.text before only credential redaction. structuredContent is serialized without recursive sanitization; ResourceLink fields, list_resources/list_prompts metadata, get_prompt descriptions, SamplingHandler message text, sampling system prompts, forwarded tool descriptions, and nested input-schema strings are also copied from the server without the helper. These are all untrusted strings that reach a model. Adversarial checks show the tag payload survives in an MCP error, structuredContent, and resource metadata response.
    Remediation: Sanitize every model-facing MCP string at one centralized serialization boundary, including error/resource-link text, utility metadata, prompt descriptions, nested tool-schema text, structuredContent recursively, sampling messages, and sampling system/tool metadata. Add coverage for each output variant.

Security evidence:

  • trust boundary: MCP servers are external, untrusted producers. Tool results, resources, prompts, metadata, errors, structuredContent, and sampling messages cross from those servers into model requests through MCP handling; Unicode-tag removal is the proposed trust-boundary control.
  • source/sink/invariant: Every model-facing string from MCP must have Unicode TAG code points removed, or be an exact validated emoji sequence, before schema or JSON serialization. No invisible instruction payload may survive into the model context.
  • current-main reproduction: Current main has no strip_unicode_tags helper and its MCP handlers append raw result text in both success and error paths. The baseline source therefore reproduces the original Unicode-tag smuggling path.
  • PR-head or patch-replay validation: The changed code was reviewed against the current main baseline.
  • positive/negative cases: The nine new Unicode-tag tests pass across clean input, stripping, valid Scotland preservation, unterminated sequences, and ZWJ cases, while adversarial checks show arbitrary black-flag payloads, MCP errors, structuredContent, and resource metadata retain tag code points.
  • residual bypass search: Residual source review found raw model-facing strings in the isError branch, structuredContent serialization, ResourceLink rendering, resource and prompt listing metadata, get_prompt description, SamplingHandler._convert_messages, sampling system/tool forwarding, and nested input-schema descriptions. Only selected text fields are sanitized.
  • reviewer validation: Focused Unicode-tag tests and focused MCP schema, tool-handler, utility-handler, and sampling tests passed.

Not checked:

  • Full MCP test module
  • ruff lint

Signed: GPT-5.6-luna-max in Codex

…rom MCP content

Unicode TAG characters (U+E0000-U+E007F) render as nothing in terminals
and chat UIs but are fully visible to LLM tokenizers, making them an
ASCII-smuggling prompt-injection channel for untrusted MCP servers.

- tools/ansi_strip.py: new strip_unicode_tags() with fast path; unlike
  goose we preserve valid emoji tag sequences (U+1F3F4 base + tag spec +
  U+E007F cancel), so regional flags survive.
- tools/mcp_tool.py: applied at every MCP text ingestion point — tool
  result text blocks, embedded resource text, read_resource contents,
  get_prompt message content, and tool descriptions entering the schema.
- tests/tools/test_unicode_tag_strip.py: smuggled-instruction vectors,
  goose's test vector, emoji-tag-sequence preservation, ZWJ untouched.
@teknium1
teknium1 force-pushed the goose-port/mcp-unicode-tag-sanitize branch from 242b1b1 to 1944cb9 Compare August 17, 2026 02:57
@teknium1
teknium1 merged commit 8bbda8f into main Aug 17, 2026
45 checks passed
@teknium1
teknium1 deleted the goose-port/mcp-unicode-tag-sanitize branch August 17, 2026 05:08
teknium1 added a commit that referenced this pull request Aug 17, 2026
…scout-slate wave

Post-merge docs sweep for the Aug 16 scout slate. Two pages:

- mcp.md: tool-result sanitization section — invisible Unicode TAG chars
  (U+E0000-E007F) stripped from results/resources/descriptions (#80689);
  vendor _meta surfaced to the model minus protocol-reserved
  modelcontextprotocol/mcp prefixes (#80712)
- tools.md: tool result annotations section — signal-death exit notes
  (subprocess -signum definite, shell 128+signum hedged) (#78074); UTF-16
  read_file transcoding with disclosure hint and 10MB cap (#80717)

Security-policy docs (approvals/allowlist) intentionally untouched.
teknium1 added a commit that referenced this pull request Aug 17, 2026
…scout-slate wave

Post-merge docs sweep for the Aug 16 scout slate. Two pages:

- mcp.md: tool-result sanitization section — invisible Unicode TAG chars
  (U+E0000-E007F) stripped from results/resources/descriptions (#80689);
  vendor _meta surfaced to the model minus protocol-reserved
  modelcontextprotocol/mcp prefixes (#80712)
- tools.md: tool result annotations section — signal-death exit notes
  (subprocess -signum definite, shell 128+signum hedged) (#78074); UTF-16
  read_file transcoding with disclosure hint and 10MB cap (#80717)

Security-policy docs (approvals/allowlist) intentionally untouched.
lisajlau pushed a commit to lisajlau/hermes-agent that referenced this pull request Aug 20, 2026
…scout-slate wave

Post-merge docs sweep for the Aug 16 scout slate. Two pages:

- mcp.md: tool-result sanitization section — invisible Unicode TAG chars
  (U+E0000-E007F) stripped from results/resources/descriptions (NousResearch#80689);
  vendor _meta surfaced to the model minus protocol-reserved
  modelcontextprotocol/mcp prefixes (NousResearch#80712)
- tools.md: tool result annotations section — signal-death exit notes
  (subprocess -signum definite, shell 128+signum hedged) (NousResearch#78074); UTF-16
  read_file transcoding with disclosure hint and 10MB cap (NousResearch#80717)

Security-policy docs (approvals/allowlist) intentionally untouched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P3 Low — cosmetic, nice to have tool/mcp MCP client and OAuth type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants