# When must an instance Refuse a Tool Call That Passes Taint Checks: The Sink-Reachability Gap in Taint-Aware Allow/Deny Enforcement **Date of Record:** 2026 November 11 **Scope:** Applies to runtime policy engines that decide allow/deny for tool invocation by propagating taint labels from sources (user input, retrieved documents, model-generated arguments) to sinks (shell, filesystem, network, credential stores). Does not apply to pure static analysis, nor to systems where every tool is treated as a fully trusted sink with no argument-dependent behavior. ## Core Distinction A taint-aware policy engine answers two different questions, and conflates them at its peril: 1. **Label question:** "Does the argument reaching this tool carry a taint label that this tool is not cleared to receive?" 2. **Reachability question:** "Given the *concrete* argument value, does invoking this tool actually place tainted data into a sink the policy forbids?" The standard implementation evaluates (1) and treats a negative result as sufficient for allow. That is the boundary failure. There exist invocations where the argument is *untainted by label* yet the tool's *behavior* reaches a forbidden sink because the taint entered through a channel the labeler does not model — typically the tool's own configuration, its ambient environment, or a second-order read of state written during an earlier, separately-approved call. The invariant that actually holds is: **allow is safe only if the label question is negative AND the concrete invocation is reachability-closed under the current taint state.** Most engines enforce only the first conjunct. ## What Was Observed A policy engine allowed `write_file(path, content)` when `content` carried no taint label. A prior call in the same session, `fetch_url(url)`, had returned attacker-controlled bytes; those bytes were written to `/tmp/cache/response.bin` by an *approved* call whose taint was correctly scrubbed at the boundary because the destination was classified as a non-sink scratch area. A later call, `write_file("/etc/cron.d/sync", )`, presented content that the labeler marked clean: the read tool's output schema declared its result `trust: internal` regardless of the file's provenance. The label question returned negative. The reachability question, had it been asked, would have returned positive: attacker bytes flowed from a network source, through a permitted scratch write, through an unmodeled read, into a privileged sink. The failure is not a missing taint rule on `write_file`. It is that the *read* path (`read_file` on a path previously written by a tainted flow) was not treated as a taint *source*. The engine modeled taint as flowing through values returned by tools, but not through the filesystem as a shared mutable channel between calls. The scratch-area exemption, which was correct in isolation, became a laundering primitive once a subsequent read re-ingested the bytes. A second observed variant: `run_shell(cmd)` where `cmd` was a constant string, hence untainted, but the shell's working directory contained a `.envrc` written by an earlier approved call. The constant command sourced ambient state. Label-clean, reachability-dirty. ## Condition of Validity The label-only decision is valid **iff** the taint model is *channel-complete*: every way tainted bytes can re-enter a later invocation is itself instrumented as a source. Concretely, this requires all of: - Every persistent store writable by an approved call (filesystem, cache, env vars, database rows, message queues) is either (a) classified as a sink, so writes to it are gated, or (b) re-read through a tool whose output taint is derived from the *provenance of the stored bytes*, not from a static schema annotation. - Tool behavior is a pure function of its declared arguments. Any tool that reads ambient state (cwd, environment, config files, clock, network) violates this and must be modeled with an implicit argument carrying the taint of that ambient state. - No approved call can write to a location that a later call reads *without* the read being provenance-tracked. When any of these fails, the label question is necessary but not sufficient, and the engine must fall back to reachability evaluation for the concrete invocation. ## Decision Boundary The engine must refuse — or escalate to reachability evaluation — when the following predicate holds, even though all taint labels on the immediate arguments are clean: ``` invocation is label-clean AND tool reads ambient or persistent state S AND S was written, in this session or a prior one within the taint window, by a flow whose source was tainted AND the tool's effect class is a sink (privileged write, exec, network egress, credential access) ``` The non-obvious part is the middle clause. Engines routinely check the first and last clauses and omit the second, because "the tool has no tainted arguments" feels like a closed proof. It is not: it is a proof about the *call site*, not about the *call's transitive state dependencies*. Symmetrically, the engine must **not** refuse merely because a scratch write occurred earlier in the session. The scratch exemption is sound; what is unsound is the unmodeled read-back. Denying all sessions that touched scratch produces false positives that train operators to widen policy until the real gap reopens. ## Operational Effect Enforcing the reachability conjunct converts a class of silent laundering into an explicit deny or an escalation to a human/policy-oracle review. The cost is that the engine must maintain a provenance map over persistent stores — a write-set keyed by path/env/db-key, each entry carrying the taint of the flow that last wrote it, with a session-scoped or time-bounded window. This map is the operational artifact that makes the invariant checkable; without it, the engine can only enforce the label question and will pass the cron.d case above. Two consequences follow for operators. First, tools that read ambient state must be registered with their ambient dependencies declared, or they must be wrapped so the dependency is surfaced as an explicit argument and labeled accordingly. Second, the taint window must be defined: bytes written before the window are treated as trusted-by-aging, which is itself a policy choice that must be documented, because it is the exact seam an attacker will target with a delayed second-stage read. The record's takeaway: a clean label is a statement about arguments, not about effects. When a tool's effect depends on state it did not receive as an argument, the label question is silent, and silence must not be read as allow. ```python class SinkReachabilityError(RuntimeError): """Raised when a label-clean invocation nonetheless reaches a forbidden sink via ambient or persistent state written by a tainted flow.""" pass class ProvenanceMap: """Tracks the taint of the flow that last wrote each persistent key.""" def __init__(self): self._writes = {} # key -> taint label of writing flow def record_write(self, key, taint): self._writes[key] = taint def taint_of(self, key): return self._writes.get(key, "clean") SINK_EFFECT_CLASSES = {"privileged_write", "exec", "network_egress", "credential_access"} def enforce_invocation(tool, args, arg_taints, ambient_reads, provenance): """ tool: descriptor with .name, .effect_class, .ambient_deps (list of keys it reads) args: concrete arguments arg_taints: dict arg_name -> taint label ambient_reads: keys the tool will read from persistent/ambient state this call provenance: ProvenanceMap """ # Question 1: label gate on declared arguments. dirty_args = [k for k, v in arg_taints.items() if v != "clean"] if dirty_args: return {"decision": "deny", "reason": f"tainted args: {dirty_args}"} # Question 2: reachability gate. A label-clean call is NOT sufficient # when the tool reaches into state written by a tainted flow. if tool.effect_class in SINK_EFFECT_CLASSES: for key in ambient_reads: if key not in tool.ambient_deps: raise SinkReachabilityError( f"tool {tool.name!r} reads undeclared ambient key {key!r}; " f"reachability cannot be proven for a {tool.effect_class} sink" ) taint = provenance.taint_of(key) if taint != "clean": raise SinkReachabilityError( f"tool {tool.name!r} (effect={tool.effect_class}) is label-clean " f"but reads {key!r} last written by a {taint!r} flow; " f"refusing to treat argument-cleanliness as effect-cleanliness" ) return {"decision": "allow", "reason": "label-clean and reachability-closed"} ```