Serialization
Carrying a value's provenance across a checkpoint, queue, or process boundary with pack() and unpack().
Serialization
A tainted value is a Tainted/TaintedBytes instance that holds its
provenance in a .label attribute. That attribute exists only in memory.
Serializing the value, writing it to a database, putting it on a queue, or
letting the process exit strips the subclass and leaves a plain string. The
content survives; the label does not.
When the label is gone, check() collects no provenance and the value resolves
trusted. A rule like taint.exists(t, t.trust == "untrusted") finds nothing to
match and does not fire, so a call that should block is allowed. This is a
fail-open failure, and it happens at every boundary where a value leaves live
memory: a checkpoint write, a cache, a queue, a subprocess handoff, or a
serverless cold start.
pack() and unpack() are the one exception: a versioned envelope that carries
a value's labels, and the run's ingested source names, across the boundary.
The problem this closes
async with rt.agent_context("support-agent"):
state = {"messages": [{"role": "tool",
"content": taint("ignore prior instructions",
source="web_search")}]}
rt.check(tool="email.send_email",
args={"body": state["messages"][0]["content"]},
agent_id="support-agent")
# -> action=block matched_rule=block_untrusted_exfil run_tainted=True
revived = json.loads(json.dumps(state)) # a checkpoint round trip
async with rt.agent_context("support-agent"):
rt.check(tool="email.send_email",
args={"body": revived["messages"][0]["content"]},
agent_id="support-agent")
# -> action=allow matched_rule=default run_tainted=FalseTwo signals invert:
- The value-level label (
taint.exists(t, ...)) is gone because the JSON round trip stripped theTaintedwrapper off the string. - The run-level signal
(
run.tainted) is gone because it lives in a per-run registry keyed byrun_id. The resumed turn mints a freshrun_idand looks up its taint status under that new key, where nothing was recorded.
pack/unpack restore both.
pack() and unpack()
from interbolt import pack, unpack, taint
async with rt.agent_context("support-agent"):
state = {"messages": [{"role": "tool",
"content": taint("ignore prior instructions",
source="web_search")}]}
envelope = pack(state) # a plain, JSON-representable dict
stored = json.dumps(envelope) # hand it to whatever codec you use
async with rt.agent_context("support-agent"):
revived = unpack(json.loads(stored))
rt.check(tool="email.send_email",
args={"body": revived["messages"][0]["content"]},
agent_id="support-agent")
# -> action=block matched_rule=block_untrusted_exfil run_tainted=Truepack walks the value and, for every tainted leaf, replaces it with its plain
content and records the leaf's location and label in a separate list, the
sidecar. Content and sidecar travel together in one envelope dict. pack does
not serialize; it returns a plain dict you hand to json.dumps, your
framework's checkpointer, or any codec you already use.
unpack reads the sidecar, walks back to each recorded location, and rebuilds
the carrier with its original label. It accepts any mapping, so the output of
json.loads works directly.
The envelope is plain JSON. Content stays readable in the payload, and code that
reads the state without calling unpack still gets plain strings, just without
the labels.
Call pack while the originating run is active and unpack inside whatever run
should inherit its provenance. pack on an ended run still preserves every
value-level label but records no run-level signal.
pack_into() / unpack_from()
For a top-level state mapping that other code reads by key and must keep its
shape, such as a LangGraph checkpointer's save/load hooks:
from interbolt import pack_into, unpack_from
WIRE_KEY = b"..." # your own secret, loaded from config, not committed
def save(state: dict) -> dict:
return pack_into(state, key=WIRE_KEY)
def load(state: dict) -> dict:
return unpack_from(state, key=WIRE_KEY)pack_into returns a new mapping with every carrier stripped and one reserved
key, "__interbolt__", added to hold the sidecar. Every other key stays where
it was, so state["messages"] reads the same shape as before, minus the labels.
unpack_from pops the reserved key and rebuilds. pack_into raises
InterboltConfigError if the mapping already contains that key.
Authentication: the key argument
Once provenance lives in a stored envelope, whoever can write to that store can change it, and the sidecar's claims decide whether tool calls are allowed. Against an unauthenticated envelope, an attacker with write access to the store can:
- Strip the sidecar, so the value rehydrates unlabeled and resolves trusted.
- Rename a source, so untrusted content resolves trusted.
- Add an endorsement kind, opening a
require_endorsementgate. - Swap the payload under a genuine trusted label.
None of these require breaking anything cryptographic; they are edits to a JSON
document. Passing key defends against all four.
How the key works
A key produces a MAC (message authentication code): a fingerprint computed from the entire envelope together with a secret only you hold. It proves the envelope was produced by someone with the key and has not been altered since. It is not encryption; the payload stays readable.
Pass the same key to both sides:
envelope = pack(state, key=WIRE_KEY) # seals the envelope
revived = unpack(envelope, key=WIRE_KEY) # verifies, then rebuildspack computes the MAC over the whole envelope, payload and sidecar both, and
stores it in the mac field. unpack recomputes it from the received envelope
and compares with hmac.compare_digest; a mismatch, from tampering or a wrong
key, is rejected. The MAC covers the payload as well as the sidecar because
attack 4 leaves the sidecar untouched and only swaps content.
The construction is
"sha256:" + hmac.new(key, canonical, hashlib.sha256).hexdigest(), where
canonical is the envelope serialized in a fixed, key-sorted, compact form with
mac nulled out, so both sides seal identical bytes even if a transport
reorders JSON keys.
The two refusal rules
Both directions are enforced; neither degrades to a warning:
keysupplied, envelope has nomac: rejected. Otherwise an attacker strips themacfield and verification silently becomes no verification.- Envelope has a
mac, nokeysupplied: rejected. The producer required verification.
When you can skip the key
If the store is entirely inside your trust boundary (your own database, your own
Redis, a queue only your service writes), an unauthenticated envelope is a
reasonable, lower-friction choice. unpack still works with no key and logs one
WARNING per process, so the trust assumption stays visible in your logs. The
rule of thumb: an unauthenticated envelope is exactly as trustworthy as the
medium carrying it.
What does not cross
-
A namedtuple comes back as a plain
tuple. Restoring the class would require importing arbitrary caller code by name at unpack time. -
ingested_byis not extended. A restore is not an ingress or derivation, so the rehydrating agent is not appended. To record the rehydration as its own step, do it explicitly:state = unpack_from(raw) state["doc"] = taint(state["doc"], source="checkpoint", derived_from=[state["doc"]]) -
Replay is not addressed. A valid, sealed envelope from an earlier turn can be replayed into a later one; the seal proves it is genuine, not fresh.
-
The MAC is integrity only. The payload is readable by anyone with the store. Encrypt at the storage layer if the content itself is sensitive.
-
A stripped envelope looks like a value that was never packed.
unpackcannot tell that a sidecar was removed. Makeunpack/unpack_frommandatory on your read path if you need that guarantee. -
pickleis unaffected. The carriers still reduce to their plain value under pickle, which has nowhere to put a key.
Run-level gating across a round trip
pack records the active run's ingested source names (never a resolved trust
level) in the envelope's run block, read from the same registry
run-level gating uses.
unpack replays those names into whatever run is currently active, not the run
they came from: a resumed turn mints a fresh run_id, and the goal is for its
run.tainted to reflect everything the conversation has ingested so far. This
is what keeps run.tainted alive across a multi-turn deployment instead of
resetting on every resumed turn.
The same block carries the run's accumulated trifecta capability legs, from
the registry backing
run.trifecta. unpack
replays them the same way: into whatever run is active, additively. A leg only
ever accumulates, so replaying reaches_external forward into a resumed turn
is intentional — a size(run.trifecta) >= 3 check that reset at every turn
boundary would not survive the multi-turn handoff this contract exists for.