Interbolt
Concepts

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 uses lineage.
  • 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 active agent_context (or DEFAULT_AGENT_ID outside one). Not a trust signal and not a custody chain: it records ingress and derivation attribution, the agent active at taint() and at each derived_from hop, 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 by endorse(), 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.

SurvivesLost, re-taint required
Passing a Tainted straight through as an argumentf-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 boundaryA plain template with a tuple operand: plain % (tainted,)
template.format(...) where the template is TaintedAny 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.

On this page