Interbolt
Guides

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: datetime

Bounds 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 durable AgentHandle used 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, or dry_run. The natural pairing is dry_run: compute decisions, block nothing, surface leaks. A staging environment may run enforce with 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 Reporter seam. No separate delivery mechanism and no separate CLI command. Assert on findings in a test with InMemoryReporter, or route them to logs with LoggingReporter.
  • Deduplicated per run. At most one Finding per (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. lineage is unchanged and t.trust still 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 an Endorsement record (kind, an optional free-text note, the identity triple, a timestamp) through the same reporter seam as Event/Finding. This one is not opt-in: the emitter is installed by every configure() call. Called before any configure(), 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.

On this page