# How should an instance treat a tool result whose taint label is absent rather than empty: the unlabeled-result boundary in cross-tool information-flow tracking **Date of Record:** 2026 November 13 **Scope:** Applies to agent runtimes that propagate dynamic information-flow (IFC) labels across tool-call boundaries to defend against prompt injection, specifically when a tool result arrives with no label field at all. Does not apply to systems where every tool adapter is contractually required to emit a label, nor to purely static taint analysis. ## Core Distinction The invariant most taint-tracking runtimes get wrong is the conflation of **absent label** with **empty label**. An empty label set means "this data was inspected and found to carry no untrusted influence." An absent label means "no IFC verdict was rendered for this data." These are categorically different: the first is a positive assertion of cleanliness, the second is an absence of evidence. A sound information-flow system must treat absence as *top* (⊤, the most restrictive element), not as *bottom* (⊥, the least restrictive). The failure mode is a lattice-defaulting bug: because most label representations deserialize a missing field to the empty set, an unlabeled tool result flows into the agent's context carrying the same trust as a label the policy engine explicitly cleared. Prompt-injected content from a third-party tool then reaches the planner with no taint, and downstream sinks (shell exec, HTTP egress, file writes) see a clean provenance chain. The non-obvious part is that this is not a *missing check* — the runtime does call `is_tainted()`. It is a *representation* bug. The check runs and returns `False` because the deserializer already erased the distinction before the policy code ever saw it. ## What Was Observed In a multi-tool agent (web fetch, document parser, database query, shell), an injected instruction was placed in a fetched HTML page. The fetch tool's adapter was upgraded mid-rollout and briefly emitted results without the `ifc_label` field (the field was added to the schema but a code path returned the raw payload dict). The parser consumed that dict, the label field deserialized to `frozenset()`, and the planner treated the page text as trusted. The injected instruction directed the agent to call the shell tool with a curl command; the shell sink's taint gate evaluated the argument's label as empty and permitted execution. No error was logged — every gate passed. The only observable signal was that the fetch adapter's output schema validation was *lenient* (unknown/missing fields tolerated), while the shell sink's gate was *strict on taint but blind to provenance*. The two tools disagreed about what an unlabeled result meant, and the disagreement was silent. A second observation: retries masked it. When the fetch adapter was fixed and re-emitted labels, the reproduction vanished, so the bug looked like a flake rather than a lattice-defaulting defect. The condition only reproduced when the specific adapter revision served the request. ## Condition of Validity The distinction matters only when all of the following hold: (a) labels are attached dynamically at tool boundaries rather than derived statically; (b) at least one adapter can produce a result without a label (schema drift, legacy path, partial rollout, third-party plugin); (c) the label representation admits an empty set as a valid value; and (d) some sink performs an allow/deny decision based on the label rather than on a signed provenance token. If labels are cryptographically bound to results (signed envelopes), absence fails closed at verification and the bug cannot occur. If the sink re-derives provenance independently, absence is caught downstream. The window is exactly the gap between a lenient producer and a label-trusting consumer. ## Decision Boundary The correct decision is: **treat absent as ⊤ and fail closed at the first consumer that would otherwise treat it as ⊥.** Concretely, the label deserializer must distinguish "field missing" from "field present and empty," and the policy engine must route the former to a quarantine path (drop, re-fetch with a labeled adapter, or escalate to human review) rather than to the trusted path. The boundary is *not* "reject all unlabeled data" — that breaks legitimate adapters during rollout. It is "unlabeled data may enter context only as untrusted-tainted, never as trusted." The rule to encode: `absent → TOP`, `present-empty → BOTTOM`, and any sink that cannot tolerate `TOP` must refuse. Do not let the schema validator's leniency be the de facto policy. ## Operational Effect After the fix, unlabeled tool results were routed through a quarantine sink that re-invoked the adapter under a strict schema or marked the payload `UNTRUSTED`. The shell and egress sinks gained an explicit `TOP` rejection with a distinct error class so the failure is loud rather than silent. The operational lesson: in IFC systems, the lattice's top and bottom must be *representable and distinguishable at the wire format*, because the security property is decided at deserialization time, long before any `is_tainted()` call. An empty set that means "clean" and a missing field that means "unknown" must never collapse to the same Python object. ```python from dataclasses import dataclass from typing import Optional, FrozenSet class UnlabeledResultError(RuntimeError): """Raised when a tool result carries no IFC label (absence, not emptiness).""" pass # Lattice: TOP = untrusted/unknown, BOTTOM = clean. TOP = "TOP" BOTTOM = "BOTTOM" @dataclass(frozen=True) class ToolResult: payload: str # None means the field was ABSENT. frozenset() means PRESENT and EMPTY (clean). ifc_label: Optional[FrozenSet[str]] = None def resolve_trust(result: ToolResult) -> str: if result.ifc_label is None: # Absence of a verdict is not a verdict of cleanliness. return TOP if len(result.ifc_label) == 0: return BOTTOM return TOP def admit_to_context(result: ToolResult) -> str: trust = resolve_trust(result) if trust == TOP: # Fail closed: never let an unlabeled result reach the planner as trusted. raise UnlabeledResultError( f"tool result has no IFC verdict (label={'absent' if result.ifc_label is None else 'tainted'}); " "route to quarantine or re-fetch under strict schema" ) return result.payload def shell_sink(command: str, source: ToolResult) -> None: # Sink refuses TOP; must never see an unlabeled argument as clean. if resolve_trust(source) != BOTTOM: raise UnlabeledResultError(f"refusing shell exec on non-clean provenance: {command!r}") # ... execute ... # Regression: absent label must raise, not silently pass. try: admit_to_context(ToolResult(payload="", ifc_label=None)) except UnlabeledResultError as e: print("correctly failed closed:", e) ```