Auditing
Finding the places a transformation laundered a taint label that should have been re-tainted.
Auditing
The audit is the in-process answer to the propagation gap described in
Taint propagation: it finds the places
where a transformation (an f-string, a .format() call, a join) laundered
a label that you forgot to re-taint.
Wiring it in
from interbolt import configure, Policy
runtime = configure(
policy=Policy.from_file("policy.yaml"),
mode="dry_run",
audit=True,
)
# Drive your own agent through your own workload: a test, a recorded
# scenario, a staging run. Interbolt instruments the run; you drive it.
await run_my_agent(test_inputs)
findings = runtime.audit_findings()Or assert on findings through InMemoryReporter, the same as for decisions
(see Testing):
reporter = InMemoryReporter()
runtime = configure(policy=..., reporter=reporter, audit=True)
...
assert reporter.findings == []INTERBOLT_AUDIT overrides the audit= argument to configure() in both
directions: 1, true, yes, or on enables it, and any other value,
including 0 and false, disables it even when audit=True was passed in
code.
Mechanism
When audit is enabled, configure() installs an observer on taint()
itself. Content is registered the moment a value is tainted with a source
that resolves untrusted, attributed to the run active at that moment. This
catches the common case: an f-string or .format() call launders the label
away before the value ever reaches a guarded call in labeled form.
A second path registers content on the sink side, from labeled arguments
that do reach a guard. This covers content whose label was attached through
a derived_from merge at the sink rather than at raw ingress.
Content shorter than interbolt.constants.AUDIT_MIN_MATCH_LENGTH (12
characters) is never registered on either path, so it can never produce a
match.
At each guarded sink, every argument arriving as a plain, unlabeled
str or bytes (recursing into containers to the same bounded depth as
label collection) is scanned for any registered content appearing as a
substring. Bytes leaves are decoded before comparison. A match means
untrusted content reached the sink with no label: a laundering point.
A taint() call made with no active agent_context cannot be attributed
to a run and is invisible to the audit, the same limitation run.tainted
has (see
Policies: run-level gating).
Each Finding names the source that leaked and the argument it leaked
into:
source: str # the source whose content leaked
tool: str # the qualified sink it leaked into
argument: str # the argument name it leaked into
agent_id: str
run_id: str
session_id: str | None
schema_version: int
trace_id: str | None # active OTel trace, when one exists
span_id: str | None
timestamp: datetimeBounds and cleanup
Registered content is dropped when the owning agent_context exits. Two
caps bound the rest:
- Registered content is kept for at most 1000 runs, evicting the
least-recently-touched run first. This is defense in depth for a run that
never passes through an
agent_context(a durableAgentHandleused without one), which has no other cleanup path. runtime.audit_findings()holds at most the 10,000 most recent findings, evicting oldest first.
Properties
- Advisory only. Findings are emitted, not enforced.
- Orthogonal to mode. Audit can run under
enforce,monitor, ordry_run. The natural pairing isdry_run: compute decisions, block nothing, surface leaks. A staging environment may runenforcewith audit on and accept the extra cost. - Off by default, real cost when on. The registry and the per-call
rescan cost real memory and CPU, outside the sub-millisecond enforcement
budget
check()otherwise targets (see Performance). Enabling it in production is fine if you accept that overhead. - Emitted through the existing
Reporterseam. No separate delivery mechanism and no separate CLI command. Assert on findings in a test withInMemoryReporter, or route them to logs withLoggingReporter. - Deduplicated per run. At most one
Findingper(source, tool, argument)combination per run, so repeated identical calls in one run do not produce repeated findings.
What it catches
The audit catches mechanical laundering, where untrusted bytes
literally survive into a sink argument through an f-string, format,
join, or a slice-then-reassemble. It does not catch semantic
laundering, where a model paraphrases the text first. See
Taint propagation
for why that limit is structural rather than a bug to fix.
The audit raises the floor on developer-introduced leaks. For
model-mediated laundering, the mitigation is re-tainting at every
agent-to-agent or model-generation boundary (see
Identity: multi-agent runs and handoffs).
Endorsement
Sometimes a value is not just passed through: it is genuinely validated, a recipient checked against an allowlist, or a URL parsed and confirmed. Leaving the taint label in place after that means every downstream sink still blocks a value that has already been vetted. Laundering it through an f-string removes the block, but it also makes a deliberate validation indistinguishable from an accidental leak. The audit above would flag it as a finding, with no record that anyone actually looked at it.
endorse() is the sanctioned alternative:
from interbolt import endorse, taint
recipient = taint(user_supplied_email, source="web_search")
if is_on_allowlist(recipient):
recipient = endorse(recipient, kind="recipient_allowlisted",
note="checked against CRM export 2026-07-01")
send_email(to=recipient, body=...)It is:
- Provenance-preserving.
lineageis unchanged andt.truststill resolves exactly as before. Endorsement adds a fact rather than erasing one. - Sink-specific, by a required
kind. There is no bare "endorsed" boolean: a value confirmed to be a well-formed URL is not thereby confirmed to be a safe email recipient, and a policy names the exact kind a sink accepts (see Policies: endorsement-aware rules). An endorsement for the wrong kind still blocks, which is the sanitizer-mismatch case a boolean cannot express. The kind must match^[A-Za-z0-9_.-]+$, since it is interpolated into compiled CEL. - Audited. Every
endorse()call emits anEndorsementrecord (kind, an optional free-textnote, the identity triple, a timestamp) through the same reporter seam asEvent/Finding. This one is not opt-in: the emitter is installed by everyconfigure()call. Called before anyconfigure(),endorse()still works and logs the record at INFO instead. - Never model-triggered. Call
endorse()only from deterministic code, immediately after a real validation step. Never call it because a model asked to, or based on model output. The model is a confused deputy this library defends against (see Identity: identity as a policy input), and letting it decide when its own restrictions lift would defeat the containment property from the inside.
A merge is conservative about endorsements: combining two values keeps only the kinds both carried, so an endorsed value merged with an unendorsed one loses the endorsement rather than spreading it.
run.tainted is unaffected by endorsement. It stays a coarse, run-scoped
signal, and a value-level fact does not clear it.