fix(agent-runtime): fence untrusted text before it reaches the model - #719
Open
kmonsoe wants to merge 1 commit into
Open
fix(agent-runtime): fence untrusted text before it reaches the model#719kmonsoe wants to merge 1 commit into
kmonsoe wants to merge 1 commit into
Conversation
Four gaps in how outsider-controlled text reached the model, all closed by routing untrusted content through one fencing primitive. New `untrusted.ts` in @getmunin/agent-runtime: `fenceUntrusted(tag, body, attrs)` wraps content in a reserved tag and escapes every reserved tag inside it, so a fenced region can only be closed by its own fence. - Tool results interpolated the body verbatim into `<data>`, so returned text containing `</data></tool_result>` closed the region early and read as sitting outside the untrusted frame. The tool-name attribute is sanitized too; the name comes from the model's tool call. - Scraped third-party HTML reached the customer-facing system prompt: the website importer pasted raw page markdown into a plain user turn (bypassing runAgent and its untrusted-data note), and the runtime concatenated the resulting company profile verbatim into the system prompt. Pages are now fenced per page as `<source_page>`, and the profile as `<company_context>` behind a note marking it reference material. - `conv_import` accepts `authorType: 'system'` with `internal: false`, and history mapped that to a real `role: 'system'` turn. System history now renders as an assistant-side `[System note]`, and import stores system messages as internal, reporting each coercion in `warnings`. - Connected MCP hosts were told none of this. Server `instructions` now carry a data-provenance paragraph and flag the two KB spaces that are live agent configuration. Capability containment (RLS, the audience gate, per-skill tool allow-lists) is still the real defense; the framing keeps injected text out of instruction position. No behavior change in normal operation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four gaps in how outsider-controlled text reached the model. Found while auditing the prompt-injection posture end to end; each is fixed at the layer where it actually holds, with a test that fails without the change.
The primitive
New
packages/agent-runtime/src/untrusted.tsis now the single place that fences untrusted text.fenceUntrusted(tag, body, attrs)wraps content in a reserved tag and escapes every reserved tag inside it, so a fenced region can only be closed by its own fence. Reserved set:data,tool_result,company_context,source_page. Exported from the package index soagent-hostshares it.1. Tool results could break out of their own
<data>wrapperrunAgentwraps every tool result in<tool_result tool="…"><data>…</data></tool_result>and pairs it with a system note telling the model to treat everything inside<data>as information, never instructions. The wrapper interpolated the body verbatim, so a KB document, CRM field, or inbound email containing the literal</data></tool_result>closed the region early and everything after it read as sitting outside the untrusted frame — the whole defense was a string-delimiter contract with no escaping.Escaping tolerates casing, internal whitespace and attributes (
</ DATA >,</\ndata\n>), and leaves<database>-style lookalikes alone. Thetoolattribute is sanitized too: the name comes from the model's tool call, so a crafted name could otherwise escape the attribute even when the call resolves to an unknown tool.2. Scraped third-party HTML reached the customer-facing system prompt
kb_import_websitecrawls a site, summarises it into acompany-profileKB doc, and the runtime concatenated that doc verbatim into the system prompt of every end-user conversation. Neither stage was defended: the summariser called the provider directly (bypassingrunAgent, so it never got the untrusted-data note) and pasted raw page markdown into a plain user turn.So any page the crawler reached carrying third-party content — a blog comment, a review widget, a forum, a stale subdomain — could write instructions into the support agent's system prompt.
Pages now go in fenced per page as
<source_page url="…" title="…">, with the summariser told explicitly that page content is data to describe and never instructions to follow. The runtime wraps the profile in<company_context>behind a note marking it reference material. The block is still omitted entirely when no profile exists (pinned by a test).3.
conv_importcould plant a realrole: "system"turnhistoryToChatMessagemappedauthorType: 'system'to a genuine system message. Every system note Munin writes itself isinternal: trueand filtered from runtime history, so the branch was unreachable — except throughconv_import, whose schema acceptsauthorType: 'system'withinternal: false. Migration payloads are exactly where third-party text lives (a Zendesk or Intercom export is full of end-user prose).Fixed at both ends: system history renders as an assistant-side
[System note], matching how staff messages are already handled, andconv_importstores system messages as internal regardless of the payload flag, reporting each coercion inwarnings. Tool description anddocs-fixturesupdated.4. Connected MCP hosts were told none of this
The untrusted-data note lived only inside Munin's own runtime. An admin agent driving
/mcpfrom claude.ai received raw tool results — arbitrary customer and end-user prose — into a session holdingkb:write,crm:writeand the rest, with nothing marking it third-party.The server
instructionsevery host surfaces at initialize now carry a data-provenance paragraph naming which modules return text Munin did not author, plus a note that theagent-runtimeandwebsite-importKB spaces are live agent configuration — editing them changes the support agent in every future conversation. That last part is the persistence path that turns a one-shot injection into a permanent one, and it's useful product knowledge regardless.Scope and limits
Capability containment is still the real defense here and it was already sound — RLS on KB audiences (
packages/db/src/sql/kb.sql), the audience gate running ahead of the scope check (dispatch.ts), per-skill tool allow-lists on the curator lane (job-catalog.ts), andrequireEndUserEmailrefusing self-reported addresses. This PR keeps injected text out of instruction position; it does not change what an injected agent could reach.Two things deliberately left alone:
company-profilegenerated under the old prompt. Fencing means its content can't escape into instruction position, but if the stored text already reads like a directive the model still sees it as fenced reference material. Regenerating is just a re-import.instructionsand the model honoring them. The stronger version is per-result provenance metadata hosts act on mechanically; no host consumes such a key today, so building one would be speculative surface.Testing
@getmunin/agent-runtime324 pass,@getmunin/agent-host102 pass,@getmunin/backend-core1342 pass (integration against local Postgres).pnpm typecheckclean across 31 tasks; eslint clean.untrusted.test.ts(evasion variants + lookalikes), tool-result breakout, system-turn mapping, company-context fencing + empty-profile case, a fullrunWebImportJobwith a hostile page, aconv_importintegration test driving/mcpwith a hostilesystemrecord, andbuildInstructionsasserted against the@getmunin/corespace-slug constants so a rename can't silently desync the text.🤖 Generated with Claude Code