Module 6 · 7 min read

Output Handling at Every Downstream Sink

Treating model output as untrusted input to browsers, databases, shells and other models, and why the fix belongs at the sink rather than anywhere upstream of it.

There is one discipline that applies uniformly wherever generated text goes: model output is untrusted input to whatever consumes it, and must be handled exactly as you would handle a string that arrived from an anonymous internet user. This is not a statement about model quality or trustworthiness. It follows mechanically from the fact that output is a function of the whole context, and the context contains content you did not author. Output inherits the trust level of the least trusted input that shaped it.

Take a support portal that renders model-written answers as HTML. A poisoned knowledge-base article causes the model to emit a script tag, and it executes in support agents' browsers, with their sessions and their privileges. Where does the primary fix belong? At the rendering sink: contextually encode or sanitise the model output like any other untrusted input, backed by a content security policy. That control is deterministic, it is enforced by code the model does not influence, and crucially it works no matter how the payload was produced, which means it also covers the payloads nobody has thought of yet.

At the sink, where it is enforceable

  • Contextually encode model output for the exact context it lands in, backed by a content security policy.
  • Execute generated SQL under a read-only role scoped to approved views, with an allowlist grammar and row and time limits.
  • Pass generated arguments as an array to an allowlisted binary, with no shell interpretation anywhere in the path.
  • Canonicalise and confine generated file paths to a fixed root; reject traversal and symlinks.
  • Resolve generated URLs through an egress proxy with a destination allowlist.

Upstream, where it is a prediction

  • Instruct the model in the system prompt never to produce HTML or destructive statements.
  • Scan source articles at upload time for the payloads you can currently imagine.
  • Have a second model review the first model's output for safety before it is used.
  • Escape quotes inside a generated shell command string and hope the quoting is complete.
  • Restrict the feature to senior staff, as though the payload came from the user.

The SQL sink

Now a reporting assistant that generates SQL from natural language and executes it against a production database. The containment has to be database-side and structural: execute with a read-only role scoped to approved views rather than base tables, validate generated statements against an allowlist grammar that permits only the constructs you intend, and enforce row limits and statement timeouts. Those controls are deterministic and completely indifferent to how the SQL was produced, which is the property you want, because you cannot enumerate the ways a model can be talked into writing a harmful query.

yaml
# Containment for a natural-language-to-SQL feature
execution_role: reporting_readonly     # no DDL, no DML, no writes at all
visible_objects: [vw_sales, vw_returns, vw_inventory]   # views, not tables
statement_policy:
  allow: [SELECT, WITH, JOIN, GROUP BY, ORDER BY, LIMIT]
  deny_everything_else: true           # allowlist grammar, not a denylist
limits:
  max_rows: 5000
  statement_timeout_seconds: 20
row_level_security: bind_to_acting_user
audit: log statement, acting user, row count, duration

Compare the alternatives. Prompting the model to refuse destructive statements puts the control inside the component being attacked. Having a second model review each query before execution feels like separation of duties but is not: both models are susceptible to the same class of persuasion and both depend on finding the input unpersuasive, so their failures are correlated rather than independent, and a second model raises the attacker's cost without adding an independent boundary. Restricting the feature to senior analysts misses the point entirely, because the malicious instruction arrives in the data, not from the analyst.

Every other sink

SinkWhat interprets the textControl that holds
Browser / rich clientHTML, CSS and script parsersContextual output encoding, sanitisation, content security policy
Relational databaseSQL parserParameterised statements; for generated SQL, allowlist grammar plus a scoped read-only role
Operating systemShellNever build a command string; pass argument arrays to allowlisted binaries with no shell interpretation
File systemPath resolutionCanonicalise, confine to a fixed root, reject traversal and symlinks
Outbound HTTP clientURL resolution and DNSDestination allowlist through an egress proxy; block internal address ranges
Templating or serialisation layerTemplate or object parserAutoescaping on, no dynamic template compilation, no deserialising generated data
Another model or agentThe next context windowCarry taint labels; treat the message as untrusted content, never as an instruction

Command execution deserves a specific warning because the failure is so total. If generated text is interpolated into a shell command string, a successful injection becomes arbitrary code execution on your infrastructure, which is a categorically worse outcome than a leaked conversation. Coding and operations agents concentrate this risk: they read untrusted content such as issue text and dependency documentation, and they hold the ability to run commands. If you build one, the shell boundary is where your engineering effort belongs.

The last row of that table is easy to overlook and matters increasingly. When one model's output becomes another model's input, the receiving context is a sink like any other, and it is the one sink that cannot enforce a code/data split at all. That is the bridge to the final module.

Check yourself

A coding agent reads issue text and dependency documentation, both attacker-influenceable, and runs build and test commands on a runner. Which design change most reduces the worst-case outcome of a successful injection?

Try it first

Model output is written to a database by one service, then read and displayed weeks later by three different consumers: a web app, a mobile client and a nightly PDF report. Before reading on: where does the encoding belong, and what goes wrong if you encode before storing?