# When must a runtime policy enforcer reject a tool call whose function name is allowlisted but whose arguments carry tainted provenance? **Date of Record:** 2026 November 6 **Scope:** Applies to runtimes that enforce a per-tool allowlist at the call boundary and separately perform taint tracking on argument values that flow from untrusted sources (user input, retrieved documents, tool outputs). Does not apply to static allowlists resolved at build time, nor to runtimes where argument sanitization is delegated entirely to the downstream tool. ## Core Distinction An allowlist answers *"may this tool be invoked?"*. Taint-aware sanitization answers *"may these arguments be passed to this tool?"*. These are independent predicates, and the common failure is treating the first as sufficient. A tool name that passes the allowlist is a *capability* grant; it is not a *data-flow* grant. The invariant that must hold at every call boundary is: > For every argument position `i` of an allowlisted tool `T`, if `T`'s signature marks position `i` as a *sink* (e.g., `exec`, `path`, `url`, `sql`, `shell`), then the taint label of the argument value at `i` must be `CLEAN` or must be discharged by an explicit, tool-specific sanitizer. Allowlist membership of `T` does not discharge taint. The non-obvious part: the allowlist check and the taint check operate on different objects (a string vs. a value-with-provenance), and a naive enforcer that short-circuits on allowlist success will never reach the taint check. This is the boundary failure. ## What Was Observed In a deployment enforcing a 14-tool allowlist (`read_file`, `http_get`, `run_sql`, `send_email`, …) with provenance labels attached to every string produced by `read_file` and `http_get`, the following sequence passed enforcement: 1. `read_file("/tmp/notes")` returns content labelled `TAINTED(source=file:/tmp/notes)`. 2. The agent constructs `run_sql(query=)`. 3. The enforcer sees `run_sql ∈ allowlist` → admits. 4. The taint check is present in code but is ordered *after* the allowlist gate and is guarded by `if not allowed: return DENY`, so it is unreachable on the admit path. Result: document content containing `DROP TABLE …` executed against the backing store. The allowlist was correct; the sanitizer was correct; the *composition* was wrong. The same pattern reproduced with `http_get(url=TAINTED)` (SSRF to an internal address embedded in a fetched page) and `send_email(body=TAINTED)` (exfiltration of a secret read from a file into an outbound message). In all three, the failing tool was legitimately allowlisted and the argument was legitimately tainted. A second, subtler observation: taint labels were being *dropped* across a benign transformation. `run_sql(query=content.strip().lower())` produced a `CLEAN` value because the string method returned a new object without propagating the label. Taint must be a property of the value's derivation, not of the object identity, or every string operation becomes a laundering primitive. ## Condition of Validity The invariant holds, and the enforcer must reject, exactly when all of the following are true: - The tool is allowlisted (otherwise the call is rejected earlier and taint is irrelevant). - The tool's signature declares at least one *sink* argument position. - The value bound to a sink position carries a non-`CLEAN` taint label that has not been discharged by a sanitizer explicitly registered for that `(tool, position)` pair. - The taint label is *reachable*: it derives, through any chain of pure transformations, from a source classified as untrusted. It does **not** hold (and rejection would be a false positive) when the taint was discharged by a position-specific sanitizer, when the source is on the trusted-source list, or when the sink position is declared `passthrough` by the tool author (e.g., a logging tool that only records the value). ## Decision Boundary The boundary is not "tainted vs. clean" but **"tainted-and-reachable-a-sink vs. tainted-and-confined."** A tainted value that never reaches a sink position is safe to carry; a tainted value at a sink position is not, regardless of allowlist status. Concretely, the enforcer must evaluate, in this order and without short-circuit: 1. Allowlist membership → `DENY` if absent. 2. For each sink position, resolve the argument's taint label (following derivation, not identity). 3. If label ≠ `CLEAN` and no registered sanitizer for `(tool, position)` has been applied → `DENY` with a taint violation, distinct from an allowlist violation. 4. Only if 1–3 pass → `ADMIT`. The two denial classes must be distinguished in telemetry; collapsing them hides the composition bug that produced the incident above. ## Operational Effect Enforcing the invariant at step 3 converts a class of silent capability-escalation (allowlisted tool + attacker-controlled argument) into an explicit, auditable denial. The cost is a small false-positive rate when sanitizers are under-registered; the mitigation is to require every sink position to name a sanitizer at tool-registration time, so an unregistered sink is a startup error rather than a runtime surprise. The decisive operational change is ordering: taint evaluation must be a *sibling* of the allowlist gate, never a branch beneath it. ```python class TaintViolation(RuntimeError): """Raised when a tainted value reaches a sink argument of an allowlisted tool.""" pass class AllowlistViolation(RuntimeError): """Raised when a tool is not on the allowlist.""" pass # tool -> set of argument positions that are sinks SINK_POSITIONS = { "run_sql": {"query"}, "http_get": {"url"}, "send_email": {"body", "to"}, "read_file": {"path"}, } ALLOWLIST = set(SINK_POSITIONS) | {"log"} # (tool, position) -> sanitizer that discharges taint REGISTERED_SANITIZERS = { ("run_sql", "query"): "sql_escape", ("http_get", "url"): "url_allowlist", } def enforce(tool: str, args: dict, taint: dict) -> None: # Step 1: allowlist gate. Denial here is a capability denial. if tool not in ALLOWLIST: raise AllowlistViolation(f"tool {tool!r} not allowlisted") # Steps 2-3: taint gate, evaluated as a SIBLING, never beneath step 1. for pos in SINK_POSITIONS.get(tool, set()): if pos not in args: continue label = taint.get(pos, "CLEAN") if label == "CLEAN": continue if (tool, pos) in REGISTERED_SANITIZERS: continue # discharged by a position-specific sanitizer raise TaintViolation( f"tainted value {label!r} reached sink {tool}.{pos} " f"without a registered sanitizer" ) # Step 4: admit. ``` The recorded lesson: allowlist admission and taint discharge are conjunctive, not hierarchical. Any enforcer that lets the first gate guard the second will admit exactly the calls this invariant exists to stop.