Taint propagation
What taint() marks, what survives a transformation, and how derived_from makes a model call or agent handoff a trust-aware new source rather than a fresh, unrelated ingress point.
Taint propagation
This page is where Interbolt's limits are most visible. Read it before you rely on a taint label surviving a transformation you wrote.
How trust is decided
A label records where the data came from rather than a trusted/untrusted
bit. Trust is resolved late, at the sink, by looking each contributing
source up in the policy's sources table (see Policies).
"More restrictive wins" falls out of this for free: if any source
contributing to a value is untrusted, the value resolves untrusted,
regardless of how many trusted sources also contributed.
Label (in interbolt.models.core) carries:
source: the originating source name, or the first contributor in insertion order on a merged value. Informational only; trust resolution useslineage.value_id: a unique id minted when the label was created or last transformed.lineage: the de-duplicated set of every source name that contributed. Trust resolution checks every name here, which is what makes "more restrictive wins" hold after a merge.ingested_by: the de-duplicated set of agent ids that ingested or derived the value, populated from the activeagent_context(orDEFAULT_AGENT_IDoutside one). Not a trust signal and not a custody chain: it records ingress and derivation attribution, the agent active attaint()and at eachderived_fromhop, not every agent that has ever handled the value. See Identity: multi-agent runs and handoffs.endorsements: the de-duplicated set of endorsement kinds recorded on the value byendorse(), without changing its lineage or trust. See Auditing: endorsement.
A plain str literal carries no label and contributes no sources. It is
trusted by construction, having no provenance to resolve as untrusted.
taint(value, source=...) returns a Tainted (a str subclass) for string
input and TaintedBytes (a bytes subclass) for bytes. Both behave as
their underlying value everywhere, so they pass into model SDKs and tool
functions with no special handling, and expose .label for inspection.
What survives, in practice
A str subclass can only intercept an operation where it is the receiver
or an operand. Anything else assembles a plain str in C with no hook, and
the label is lost.
| Survives | Lost, re-taint required |
|---|---|
Passing a Tainted straight through as an argument | f-strings with surrounding text: f"Summary: {x}" |
Operators +, %, * with a Tainted on either side | "{}".format(x) on a plain template |
Slicing, indexing, and str methods called on a Tainted: .upper(), .strip(), .split(), .replace() | " ".join(chunks) with a plain separator |
encode()/decode() across the str/bytes boundary | A plain template with a tuple operand: plain % (tainted,) |
template.format(...) where the template is Tainted | Any round-trip through a non-overridden operation: json.loads, int() |
A bare single-field f-string, f"{x}", and copy.copy/copy.deepcopy |
For the exact rule behind every case, including a few sharp edges not listed here, see Taint propagation internals.
Boundaries that always reset to untrusted ingress
An ordinary serialization or storage round trip resets the label. Data
re-entering the process through any channel other than the serialization
contract below is fresh untrusted ingress, unconnected to any prior label,
and must be re-tainted at re-entry. Pickling a Tainted, TaintedBytes,
or LabeledValue degrades to the plain underlying value for this reason:
pickle is an implicit, ambient channel with no place for a key, and it is
unaffected by the exception below. copy.copy/copy.deepcopy stay
in-process and preserve the label.
The one exception is an explicit pack/unpack pair.
Serialization carries a value's provenance
across the boundary in a versioned envelope, verified by unpack when a
key was supplied. Reach for it at a checkpoint round trip, a queue hop, or
any other explicit boundary you control; every other channel still resets,
as stated above.
A model-mediated agent-to-agent handoff is the same kind of boundary: the
receiving agent gets plain, unlabeled text even when it derives from
untrusted data. Re-taint it at the boundary, using the trust-aware form
in the next section where the inputs are in scope (see also
Identity: multi-agent runs and handoffs).
Model calls and derived values
derived_from marks a value as derived from other values instead of as a
fresh ingress point, so trust is inherited rather than assumed:
summary = taint(model_output, source="model", derived_from=[prompt, context])Every label found among derived_from is merged, recursing into containers
to the same bounded depth as everything else here. The result's lineage
is the union of those labels' lineage, so trust resolves at the sink
exactly as if the original inputs had reached it directly: untrusted if any
one of them was, trusted if all were. source names the derivation hop
("model" above) for tracing, while lineage still names the real
upstream sources. The result's ingested_by is the union of the
contributing labels' ingested_by, plus the calling agent, since the
derivation hop itself happened under that agent's control: this is what
lets ingested_by grow across a track_model_call handoff. If nothing
among derived_from carries a label, value is returned completely
unmarked, consistent with a plain str literal being trusted by
construction.
This records no run-level ingress event for source (see
Policies: run-level gating). A
derivation hop is not a source declared in your policy, and recording it
would make run.tainted true on every model call regardless of whether the
inputs were trusted.
track_model_call wraps this primitive for the common shape, a function
whose return value should inherit trust from its arguments:
from interbolt import track_model_call
@track_model_call(source="model")
def summarize(web_result: str, internal_result: str) -> str:
return llm_client.complete(...)It binds the call's arguments the same way @guard does and calls
taint(result, source=source, derived_from=bound_arguments.values()). Sync
and async are auto-detected. It tracks provenance only and does not
evaluate policy, so stack @guard alongside it if the model call should
also be gated.
This closes part of the handoff gap rather than all of it. You have to
identify which values a derivation's trust comes from, and either pass
derived_from by hand or wrap the producing function. Interbolt never
inspects generated text to verify a summary is faithful to its input, and
never infers a derivation you did not declare.
Mechanical and semantic laundering
The laundering audit finds the places where a
developer forgot a re-taint and the untrusted bytes survived into a sink
argument. It cannot catch the case where a model summarizes or paraphrases
untrusted text before it reaches the sink, because no byte sequence
survives to match against. That limit is fundamental to an in-process
string-subclass carrier rather than a bug to be fixed later, and
run.tainted is the coarse
backstop that covers it. See also
Threat model.
Non-string values
taint() wraps a non-string scalar (a number, bool, None) in a
LabeledValue, exposing .value and .label. Pass it straight to a sink
argument and check() sees the label; transform .value first and the
result is plain and unlabeled.
There is no TaintedInt, because bool and NoneType cannot be
subclassed in CPython, and numeric coercion (int(x), comparisons,
arithmetic) discards subclass identity immediately, so such a carrier would
lose its label on everything except direct passing.
LabeledValue appears in exactly one case: taint() called on a
top-level non-string, non-bytes, non-container scalar. A non-string
leaf inside a container is never wrapped.
Container recursion
Tool outputs are routinely containers, so taint() recurses into builtin
containers (list, tuple, set, frozenset, dict keys and values)
and labels string leaves. check()/guard recurse the same way when
collecting labels from bound arguments.
Only str/bytes leaves are wrapped. Any other leaf passes through
unchanged, so taint({"count": 3}, source="api")["count"] + 1 works
because the 3 stays a plain int.
Both paths read the same bound, interbolt.constants.RECURSION_DEPTH
(default 4, overridable by INTERBOLT_RECURSION_DEPTH within [1, 10]),
so ingress labeling and sink collection are bounded identically. Bounding
is a denial-of-service and latency requirement, since this runs on the
guarded-call hot path. One consequence: a label buried below the cutoff is
not seen, and the call is evaluated as if that leaf were untainted. A
sub-container at the cutoff passes through unchanged and unlabeled, so it
stays subscriptable and usable as the original. Only builtin containers are
traversed; arbitrary objects are not introspected. See
Taint propagation internals
for exotic and namedtuple containers.
Merge rule
When two tainted values combine, the merged label's lineage and
ingested_by are each the de-duplicated union of the contributors' (an
operand-level combine, not a derivation hop, so unlike
derived_from/track_model_call this does not add the current agent to
ingested_by), and its endorsements (see
Auditing: endorsement) is the intersection of the
contributors', so a kind survives only if every contributor carried it.
There is no trusted/untrusted state to merge, since trust resolves later at
the sink. Merge is associative and order-independent, so propagation does
not depend on how an expression is parenthesized. See
Taint propagation internals
for the single-parent fast path.