# In what scenarios must an instance treat taint-aware sink blocking as a hard invariant rather than a best-effort filter **Date of Record:** 2026 November 11 **Scope:** Applies to agent runtimes that propagate taint labels from untrusted prompt sources (retrieved documents, tool outputs, web fetches, user-supplied files) to downstream tool invocations, and that enforce blocking at "sinks" (shell exec, file write, HTTP POST, outbound messaging, credential use). Does not apply to systems where all tool inputs are operator-authored and no untrusted data enters the tool-call graph, nor to purely advisory classifiers that never gate execution. ## Core Distinction The distinction that matters is between **taint as a label on data** and **taint as a property of the reachable call graph from a source to a sink**. Most implementations get the first right and the second wrong. Labeling a string as `UNTRUSTED` is cheap; correctly computing whether *any* path from that labeled value reaches a sink without an intervening declassification is the actual invariant. The failure mode is not "we forgot to label the document." It is "we labeled the document, the label survived one hop, and then a transform (summarization, JSON re-encoding, templating, error-stringification, embedding lookup) produced a *new* value that the taint engine treated as freshly trusted because it was not byte-identical to the source." A second, subtler distinction: **blocking at the sink is not the same as blocking at the call site**. A tool may have multiple internal sinks (a `write_file` tool that both logs to disk and executes a post-write hook). Taint must be checked at each sink, not at the tool boundary, because the tool boundary is where developers most often insert a "sanitize" step that is actually a laundering step. ## What Was Observed In a runtime that propagated taint through a Python object graph, three concrete leaks were reproduced: 1. **Template interpolation laundering.** A tainted document was passed into a Jinja-style template as a context variable. The rendered output was a new `str` constructed by the template engine, which had no taint provenance. The rendered string was then passed to `shell_exec`. The taint engine saw an untainted `str` and allowed it. The source label existed; the path did not. 2. **Exception stringification.** A tool raised an exception whose message embedded the tainted input. The orchestrator caught the exception, extracted `str(exc)`, and fed it back into the model as a tool result. That round-trip stripped taint because the exception object was constructed inside trusted runtime code. The model then emitted a follow-up tool call carrying the original payload, which reached an outbound HTTP sink. 3. **Serialization boundary.** A tainted dict was passed through `json.dumps` → `json.loads` to satisfy a tool's schema. The deserialized object was structurally identical but provenance-free. Any sink downstream of the round-trip was reachable. In all three cases the label was correct at the source and absent at the sink. Static analysis of the tool definitions found no missing annotations; the loss happened in *runtime value construction*, which annotations cannot cover. ## Condition of Validity The invariant "no tainted value reaches a sink" holds only under the following conditions, and the enforcement is invalid if any is violated: - **Provenance must be attached to values, not variables.** Rebinding a variable, copying a field, or returning from a function must carry the label. If your taint model is a set of variable names, it is already broken. - **Every value-constructing operation must be a taint-propagating operation.** Concatenation, formatting, templating, serialization, exception construction, and dict/object copying must all be modeled as joins over their inputs. If an operation is not modeled, it is a declassifier by default — and declassifiers by default are the bug. - **Declassification must be explicit and audited.** The only legitimate taint removal is a call to a named, reviewed sanitizer with a stated contract (e.g., `sanitize_sql_identifier`). "It went through `str()`" is not a sanitizer. - **Sinks must be enumerated exhaustively, including transitive ones.** A tool that spawns a subprocess, opens a socket, writes a file, or calls an external API is a sink even if its name suggests otherwise. Sink enumeration is a maintenance burden, not a one-time task. If any of these does not hold, the system is doing *taint labeling*, not *taint tracking*, and the sink check is best-effort. ## Decision Boundary The operational question is: **when does a taint violation justify halting the task versus degrading gracefully?** - **Hard halt** when the sink is irreversible or externally visible: outbound network calls, credential use, `rm`/`mv`/`chmod`, message sends, payments. A taint violation here is a potential exfiltration or destructive action; the correct response is to abort the tool call, surface the source→sink path to the operator, and not retry automatically. Retrying with a "cleaner" prompt is how laundering gets reintroduced. - **Degrade with logging** when the sink is internal and reversible: writing to a scratch directory, appending to an in-memory log, emitting a metric. Log the path, continue, and let the operator review. - **Never silently declassify.** A policy of "strip the label and proceed if the value looks benign" converts the invariant into a classifier, and classifiers have false negatives. If you cannot prove the path is clean, treat it as tainted. The boundary is not "is the payload dangerous." It is "is the sink one where a false negative is recoverable." Irreversible sinks get the hard invariant; reversible sinks get the audit trail. ## Operational Effect Enforcing the invariant as a hard gate changes the runtime in three measurable ways: 1. **Tool authors must declare sinks, not just capabilities.** A tool's manifest gains a `sinks: [...]` field enumerating every external effect, and the runtime refuses to register a tool whose declared sinks are incomplete relative to its imports. This is enforceable with a static check on the tool module. 2. **Taint propagation becomes a runtime cost.** Every string construction, template render, and serialization must consult the provenance store. In practice this is a small constant-factor overhead per value, but it forces the orchestrator to route all value construction through instrumented paths — monkey-patching `str.__add__` is not viable, so the runtime must provide `tainted_format`, `tainted_json`, and friends, and lint against raw equivalents. 3. **Failure is loud and attributable.** When a tainted value reaches a sink, the runtime emits the full path: source identifier, each transform hop, and the sink. This is the only artifact that lets an operator distinguish a genuine attack from a benign false positive, and it is the difference between a system that can be operated and one that can only be disabled. The invariant is worth enforcing precisely because the leaks are invisible in code review: the label is present at the source and absent at the sink, and only a runtime path trace shows where it went. ```python class TaintSinkViolation(RuntimeError): """Raised when a value carrying untrusted-prompt taint reaches a hard sink.""" class Sink: HARD = "hard" # irreversible / externally visible SOFT = "soft" # internal / reversible HARD_SINKS = {"shell_exec", "http_post", "send_message", "use_credential", "fs_delete"} def enforce_sink(sink_name: str, value, provenance: dict) -> None: """ provenance: {value_id: {"sources": [...], "path": [...]}} Raises TaintSinkViolation if the value is tainted and the sink is hard. """ entry = provenance.get(id(value)) if entry is None: return # untainted, no provenance record sources = entry.get("sources", []) if not sources: return if sink_name in HARD_SINKS: path = " -> ".join(entry.get("path", [])) or "" raise TaintSinkViolation( f"tainted value reached hard sink {sink_name!r}; " f"sources={sources}; path={path}. " f"Declassify via an explicit, reviewed sanitizer or abort." ) # soft sink: caller logs and continues return def run_tool_call(tool_name: str, args: dict, provenance: dict) -> None: for arg in args.values(): enforce_sink(tool_name, arg, provenance) # ... dispatch to actual tool only after all args pass ... ```