Skip to content

fix(agent-runtime): fence untrusted text before it reaches the model - #719

Open
kmonsoe wants to merge 1 commit into
mainfrom
fix/untrusted-content-framing
Open

fix(agent-runtime): fence untrusted text before it reaches the model#719
kmonsoe wants to merge 1 commit into
mainfrom
fix/untrusted-content-framing

Conversation

@kmonsoe

@kmonsoe kmonsoe commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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.ts is 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 so agent-host shares it.

1. Tool results could break out of their own <data> wrapper

runAgent wraps 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. The tool attribute 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_website crawls a site, summarises it into a company-profile KB 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 (bypassing runAgent, 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_import could plant a real role: "system" turn

historyToChatMessage mapped authorType: 'system' to a genuine system message. Every system note Munin writes itself is internal: true and filtered from runtime history, so the branch was unreachable — except through conv_import, whose schema accepts authorType: 'system' with internal: 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, and conv_import stores system messages as internal regardless of the payload flag, reporting each coercion in warnings. Tool description and docs-fixtures updated.

4. Connected MCP hosts were told none of this

The untrusted-data note lived only inside Munin's own runtime. An admin agent driving /mcp from claude.ai received raw tool results — arbitrary customer and end-user prose — into a session holding kb:write, crm:write and the rest, with nothing marking it third-party.

The server instructions every host surfaces at initialize now carry a data-provenance paragraph naming which modules return text Munin did not author, plus a note that the agent-runtime and website-import KB 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), and requireEndUserEmail refusing 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:

  • Existing profiles aren't backfilled. Orgs that already ran a website import have a company-profile generated 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.
  • Gap chore: release Nest 11 upgrade #4's fix is advisory — it depends on the host surfacing instructions and 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-runtime 324 pass, @getmunin/agent-host 102 pass, @getmunin/backend-core 1342 pass (integration against local Postgres).
  • pnpm typecheck clean across 31 tasks; eslint clean.
  • Every new test verified non-vacuous by stashing the corresponding source change and confirming it fails.
  • New coverage: untrusted.test.ts (evasion variants + lookalikes), tool-result breakout, system-turn mapping, company-context fencing + empty-profile case, a full runWebImportJob with a hostile page, a conv_import integration test driving /mcp with a hostile system record, and buildInstructions asserted against the @getmunin/core space-slug constants so a rename can't silently desync the text.

🤖 Generated with Claude Code

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>
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