Skip to content

agent: the server-text fence is a rule, and the one door to the model - #157

Merged
jaredLunde merged 3 commits into
mainfrom
jared/fence-hardening
Oct 7, 2026
Merged

jaredLunde merged 3 commits into
mainfrom
jared/fence-hardening

Conversation

@jaredLunde

Copy link
Copy Markdown
Contributor

What

Fixes the items left by the re-audit of #149. Each fix has a test that fails without it. I checked this by reverting each fix in place and running its test: 12 reverts, all fail.

# finding fix proving tests
1 fenced_server_message relied on hand-kept lists of lookalike and invisible characters. Drop by rule: every character in Unicode General_Category Cc (tab, newline and CR become spaces), Cf, Co, Cn, Zl, Zp or Cs; the TAG block U+E0000–E007F ("ASCII smuggling"); U+180E; the variation selectors. Map by rule: any character whose NFKC form or UTS #39 confusable skeleton is a fence character becomes ‹ › / '. So does any character whose Unicode name makes it a bare angle: PRECEDES, the angle-bracket ornaments and presentation forms, arrowheads, and so on. Those aren't all confusable with < under UTS #39, so they come from a generated table: tools/fence_tables.rs, produced by scripts/gen_angle_lookalikes.py, which excludes compound relations such as ≤ and ≮. New dependencies: unicode-general-category (already in the graph) and unicode-security. no_lookalike_or_invisible_spelling_survives_the_fence runs the auditor's full hostile set (audit149/r2/zz_fence.rs) plus private-use and unassigned characters. Each case keeps exactly one real closing tag, with no </> inside the fence and no invisible, format, separator, TAG or variation-selector character left. Reverting the category drop, the angle table, or the skeleton check each fails it.
2 Server text reached the model through more than one door. fenced_server_message is now the one place server text crosses into model-visible errors. Tool calls: a JSON-RPC error's message and data in tool_call_err. MCP Events: RpcError (from_json, from_service), fenced where it comes in; its code and structured data stay as they are, for the decisions made on them. Token refresh: rmcp's refresh errors, which carry the authorization server's own text such as error_description. a_json_rpc_errors_message_and_data_are_fenced_in_the_tool_error, a_servers_rpc_error_message_is_fenced_where_it_comes_in (from_json and from_service), a_refresh_failure_fences_the_authorization_servers_text
3 Untested #149 fixes These are now pinned: Y2, the insufficient-scope (and auth-required) challenge among a failed call's causes, via describe_cause; Y5, the legacy server/discover rejection's body; Y10, $-stripping on direct-HTTP streams, using an end-to-end forge over HTTP and stdio (new fixture flag MCP_FIXTURE_FORGE_HOST_KEYS); Y11, the dropped-id sequence seeded from the host's secret. a_challenge_header_is_fenced_among_a_failed_calls_causes, a_legacy_discover_rejections_body_is_fenced, a_servers_host_keys_forge_nothing_over_{http,stdio}, a_dropped_message_id_is_not_a_guessable_sequence
4 params_members copied a server's own $-scalars into the host's genuine stand-in. $ keys are no longer copied. a_servers_dollar_keys_are_not_copied_into_the_stand_in

Checks

  • Tests: 2838 pass, 0 failed. That covers every mcp_* suite, the serve suites, serve_harness_deadlines, tool_reactor_stall, and the agent and agent-core lib tests. The code-mode lib suite and tool_reactor_stall with code-mode pass too (24 tests).
  • Lint and format: cargo clippy with -D warnings is clean, both for the agent with code-mode and for the whole workspace. fmt and dprint are clean.
  • Interop: check.py passes 12/12 over HTTP and 12/12 over stdio.
  • Docs: ARCHITECTURE.md describes the rule and the single choke point.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk

jaredLunde and others added 2 commits October 7, 2026 08:06
… model

Re-audit of #149:
1. fenced_server_message drops by rule, not list: every character in General_Category Cc (tab,
   newline, CR become spaces), Cf, Co, Cn, Zl, Zp or Cs, the TAG block (ASCII smuggling), U+180E
   and the variation selectors. It maps by rule: any character whose NFKC form or UTS #39 confusable
   skeleton is a fence character, or whose Unicode name makes it a bare angle (a generated table,
   tools/fence_tables.rs from scripts/gen_angle_lookalikes.py: PRECEDES, the angle-bracket
   ornaments and presentation forms, arrowheads, ...), becomes ‹ › / or '. New deps:
   unicode-general-category (already in the graph), unicode-security.
2. fenced_server_message is the one place server text crosses into model-visible errors: a
   JSON-RPC error's message and data in tool_call_err; MCP Events' RpcError (from_json,
   from_service), fenced where it comes in with its code and structured data kept for the
   decisions made on them; rmcp's refresh errors carrying the authorization server's own text.
3. Pinned what #149 left untested: the insufficient-scope (and auth-required) challenge among a
   failed call's causes (describe_cause), the legacy server/discover rejection's body, the
   direct-HTTP stream's `$`-stripping (an e2e forge, over HTTP and stdio), and the dropped-id
   sequence seeded from the host's secret.
4. params_members no longer copies a server's own `$`-scalars into the host's genuine stand-in.

Tests, each failing with its fix reverted (12 mutations): no_lookalike_or_invisible_spelling_
survives_the_fence (the auditor's full hostile set, plus private-use and unassigned),
a_json_rpc_errors_message_and_data_are_fenced_in_the_tool_error,
a_challenge_header_is_fenced_among_a_failed_calls_causes,
a_servers_rpc_error_message_is_fenced_where_it_comes_in,
a_refresh_failure_fences_the_authorization_servers_text, a_legacy_discover_rejections_body_is_fenced,
a_servers_host_keys_forge_nothing_over_{http,stdio}, a_dropped_message_id_is_not_a_guessable_sequence,
a_servers_dollar_keys_are_not_copied_into_the_stand_in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
UTS #39 maps Katakana ノ and Coptic Ⳇ to `/`, so a Japanese server message
read "/ート". Letters are no longer folded to `/`; letters shaped like `<`,
`>` or `"` (Canadian syllabics ᐸ/ᐳ) still are, and without a `<` the fence
cannot be closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
@jaredLunde
jaredLunde force-pushed the jared/fence-hardening branch from c952224 to 76dada6 Compare October 7, 2026 15:06
@jaredLunde
jaredLunde enabled auto-merge (squash) October 7, 2026 15:18
@jaredLunde
jaredLunde merged commit daa09d4 into main Oct 7, 2026
21 checks passed
@jaredLunde
jaredLunde deleted the jared/fence-hardening branch October 7, 2026 15:32
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