A policy is the file that decides what your agent may do with untrusted
data. It is YAML for structure and CEL
for boolean conditions, loaded and compiled once via
Policy.from_file(path). The CEL implementation is
cel-python.
This page is the reference for the format and its semantics. For the
step-by-step path from an unguarded agent to a working policy, start with
Writing a policy.
sources: assigns trust to the names you pass to taint() at ingress. Those
names are yours to choose and are unrelated to tool names, so a source can be
a web search, a file, a queue message, or anything else that enters your
agent from outside.
sinks: describes your tools. A sink is the guarded call boundary where
check() runs, which means every guarded call is a sink, a read included.
Each entry takes two optional keys. capabilities: says what the tool does
with the data it touches. rules: gates calls to it, first match wins. An
entry with no rules falls through to defaults.sink_action, exactly as a
tool with no entry at all does, so a tool that only needs its capabilities
declared is written with a capabilities: key alone.
agents: is optional and declares group membership, covered below.
A malformed or schema-invalid file raises PolicyEvaluationError from
Policy.from_file.
A source declaration binds a name to a trust level. The name is the string
you pass to taint(value, source="web_search"), so it is the join key
between your agent code and this file. Trust is resolved here rather than
at ingress, which means changing a source's trust changes every future
decision without touching agent code.
A sink key is a dotted namespace.tool name identifying one guarded tool.
Its value is a mapping with two optional keys: capabilities: (see
Declaring what a tool does below) and
rules:, an ordered list of rules. A rule has four possible fields:
name: an identifier for the rule, reported back on the Decision as
matched_rule so a block is traceable to the line that caused it.
when: a CEL expression over the call. The rule matches when it
evaluates true.
require_endorsement: a shorthand covered under
endorsement-aware rules
below. Mutually exclusive with when.
action: allow, block, or require_approval, applied when the rule
matches.
Rules are evaluated top to bottom and the first match decides the call. A
rule with neither when nor require_endorsement matches unconditionally
and is the catch-all, so it belongs last. Anything after it is dead, and
interbolt validate reports it as unreachable. If no rule matches, the
call falls through to defaults.sink_action.
exists( works anywhere in an expression, on t.lineage, t.endorsements,
and agent.groups as well as on taint itself, and it is the spelling used
throughout these docs, in the starter policy, and in the conditions
Interbolt reports back on a Decision. Everything else is ordinary CEL:
&&, ||, !, comparisons, has(), and the string methods used above.
The values an expression can reference are listed under
the CEL evaluation context.
Expressions are compiled once when the policy loads, never per call. A
syntax error surfaces at Policy.from_file or at interbolt validate,
not on the first guarded call in production.
Tool identity is a structured (namespace, tool) pair internally, with the
dotted namespace.tool form as the policy-key and logging surface.
@guard # bare tool name "send_email" qualifiesdef send_email(...): ... # to "default.send_email"@guard(tool="fs.write") # one dot: already-qualified namespacedef write_file(...): ... # "fs", tool "write" -> "fs.write"
A bare tool name is qualified by prepending
interbolt.constants.DEFAULT_NAMESPACE ("default"). A name containing a
dot is treated as an already-qualified namespace.tool pair and used
as-is, after validating that neither half itself contains a dot.
Neither half may contain a dot, because a.b.c would be ambiguous to parse
back apart: namespace a.b with tool c, or namespace a with tool
b.c. A name with an extra dot raises InterboltConfigError rather than
being silently sanitized, since collapsing two distinct tools onto one
policy key would be a security-relevant collision. The check
(validate_qualified_name_part) runs everywhere a name is qualified: at
@guard/@handle.guard decoration, and when a policy file's sink keys are
validated. Two tools in different namespaces can therefore never collide.
Within a namespace, the integrator owns uniqueness.
There are exactly three actions: allow, block, and require_approval.
There is deliberately no sanitize or rewrite, since either would invite
an unverifiable "we cleaned the input" claim that an adaptive attacker can
defeat.
tool: the qualified name of the guarded sink, as a string.
args: the bound call arguments by name, for example args.to,
args.path. Arguments are exposed raw, with taint carriers already
stripped to their plain str/bytes/scalar/container form, so write
plain predicates like args.to.endsWith(...). An argument with no
CEL-representable shape (not a JSON-like scalar, string, list, or
mapping) is omitted from the context, and referencing it behaves like
referencing a missing key, which is an evaluation error (see below).
taint: a CEL list of per-label objects, one per label collected from
the call's arguments. Each entry exposes:
t.trust: the label's trust, resolved at evaluation time by checking
every name in its lineage against the policy's sources table,
untrusted-wins.
t.source: the label's recorded source field.
t.lineage: a CEL list of every source name that contributed to the
label.
t.ingested_by: a CEL list of agent ids that ingested or derived the
label's value, populated by taint()/track_model_call. Answers "which
agent's ingress is this data upstream of," independent of agent.id,
which answers "who is calling now." The two can differ, and often do
once more than one agent is in play:
when: taint.exists(t, t.trust == "untrusted" && t.ingested_by.exists(a, a == "researcher"))
reads as "block when untrusted data the research agent brought in
reaches this sink, regardless of which agent is making the call now."
Ingress and derivation attribution, not a full custody chain: a plain
hand-off with no re-taint at the boundary leaves no entry. See
Taint propagation: how trust is decided.
t.endorsements: a CEL list of the kind strings the label carries (see
Auditing: endorsement).
Match on lineage, not on t.source. A merged label's source is
only its first contributor in insertion order, so
taint.exists(t, t.source == "web_search") silently misses a value formed by
merging web search content with something that contributed first. Trust
resolution itself is always correct, since it checks the full lineage.
Write taint.exists(t, t.lineage.exists(s, s == "web_search")) instead, which
checks every contributor. interbolt validate flags a t.source ==/!=
comparison with a warning pointing here.
sources: a CEL list, the de-duplicated set of every source name
contributing to any argument's label across the call.
max_trust: a CEL string, "untrusted" if any contributing label
resolves untrusted, else "trusted". The same untrusted-wins resolution
as taint.exists(t, t.trust == "untrusted"), exposed as a convenience
scalar.
trifecta: a list of the lethal-trifecta legs satisfied by this call.
from_untrusted when any contributing label resolves untrusted, plus every
capability declared for the tool being called (see
Declaring what a tool does below).
Quantify with trifecta.exists(...) or count with size(trifecta).
run: a CEL map with five fields. run.tainted (boolean): true if the
active run has ingested untrusted data via taint() at any point,
regardless of whether this call's own arguments carry a label. See
Run-level gating below.
run.sources is the list of every source name passed to taint() during
this run, in the order they were first seen. run.untrusted_sources is
the subset that resolves untrusted against the policy's sources table,
and run.ingested_by is the de-duplicated list of agent ids that made
those taint() calls. All three are run-scoped, so they describe what
entered the run rather than what reached this call.
run.trifecta is a list of the trifecta legs satisfied anywhere in this
run: from_untrusted when the run has ingested untrusted data, plus every
capability declared for every tool guarded during the run, including this
call. size(run.trifecta) >= 3 is the Rule-of-Two check; see
Declaring what a tool does below.
That distinction decides how a rule should be written.
run.untrusted_sources.exists(s, s == "web_search") says web_search
entered this run at some point, and says nothing about whether this
call's arguments derive from it. For the value-level claim, write
taint.exists(t, t.lineage.exists(s, s == "web_search")), which is
evaluated against the labels on the arguments themselves. The run-level
form is the coarse layer that survives a model-mediated handoff, and it
is deliberately less specific than the value-level one.
These fields carry the same blind spot as run.tainted. A taint() call
made with no active agent_context, or on a thread that context does not
reach, is recorded against no run and appears in none of them.
Prefer exists over all on these lists. An all macro folds to true
on an empty list, so run.sources.all(...) is true for a run whose
ingress was never recorded, and interbolt validate warns when it
appears in an allow rule.
agent.id (string): the acting agent's durable identity. Always present
and non-empty, so referencing it introduces no new evaluation-error path
the way an optional args field would. See Identity for how
it is bound, and Per-agent carve-outs below for
the idiom.
agent.groups (list of strings): the acting agent's declared group
membership, from the policy's optional agents: section. Always
present, possibly empty: an agent not listed there, or listed with no
groups, resolves to [], never an evaluation error. Unlike agent.id,
agent.groups is a list, so agent.groups.exists(g, g == "outbound")
quantifies over it the same way t.lineage.exists(...) does; nesting a
list inside the agent map costs nothing, since only the receiver of
exists/all itself needs to be a list. See
Group membership below.
agent itself stays a map rather than becoming a list, since agent.id
only ever needs dotted field access.
Note there is no t.agent. t is the macro-bound loop variable inside
taint.exists/taint.all and exposes only trust, source, lineage,
ingested_by, and endorsements; agent is a top-level context variable,
a sibling of taint. The two read similarly and are easy to confuse:
agent.id is the caller, t.ingested_by is the data's history, and a
policy that means one but writes the other fails silently rather than with
an evaluation error.
A missing argument, a None value, or a non-marshalable value encountered
during evaluation is an evaluation error, handled per
mode: fail-closed under enforce, log-and-proceed
under monitor, downgraded to allow under dry_run. The canonical case is
args.to.endsWith(...) on an optional argument that was not passed, where
the reference raises and the call is blocked with PolicyEvaluationError.
Guard for presence when writing a predicate over an optional argument:
has(args.to) && args.to.endsWith(...).
mode governs what happens on an evaluation error, and whether a real
block is enforced. A correctly-computed block/require_approval
decision always holds, except under dry_run.
enforce (default): fail-closed. An evaluation error is treated as a
block and raises PolicyEvaluationError.
monitor: fail-open on evaluation error, which is logged while the call
proceeds. A correct block still blocks. An adoption on-ramp.
dry_run: every decision is computed and emitted but downgraded to
allow, so nothing is ever blocked. The emitted event's outcome field
records the real, pre-downgrade action, so a dry run against live traffic
shows what a real rollout would have done.
Mode has three sources, highest precedence first: the INTERBOLT_MODE
environment variable, the policy file's defaults.fail_mode, and the
mode= argument to configure() (the in-code default, lowest precedence).
Each is parsed strictly, and an unrecognized value raises
InterboltConfigError. configure() logs a warning whenever a
higher-precedence source changes the effective mode away from the one below
it: when defaults.fail_mode overrides the mode= argument, and when
INTERBOLT_MODE overrides either, so a non-enforcing mode, or a policy
quietly overriding a service's in-code request, cannot ship unseen. The
INTERBOLT_MODE override is the one-line CI escape hatch:
If your application exposes its own mode flag (a service CLI's --mode,
say) and you want that flag to outrank a policy's fail_mode, set
INTERBOLT_MODE from the flag rather than passing configure(mode=...).
INTERBOLT_MODE is the top-precedence source, so routing the flag through
it lets --mode dry_run win over a policy pinning enforce, and the
override is logged. Passing the flag as configure(mode=...) puts it at the
lowest precedence, below fail_mode, where the policy overrides it (loudly,
per above, but still overridden).
Exactly two capabilities exist. reads_private marks a tool that returns
private data, such as a mailbox read or a customer-database query.
reaches_external marks a tool that can send data outside your trust
boundary, such as an email send or an outbound HTTP call. A tool that has
neither is declared with an empty list.
The empty list is worth writing. A tool with no capabilities: key at all
contributes no legs and interbolt validate warns about it, because a
forgotten tool and an assessed one look identical at runtime and only the
declaration tells them apart. Writing [] records that you looked.
Keys are exact qualified namespace.tool names. There is no glob or prefix
matching, for the same reason there is none for rules: a second matching
semantics would bring its own precedence questions into a language whose only
ordering rule is first-match-wins within an entry.
A capabilities-only entry is the common case for read tools, and it is what
makes run-scoped trifecta reach three legs. crm.query_customers above has
no rules of its own. Calls to it fall through to the default action, and it
contributes reads_private to every run it is called in.
from_untrusted needs no declaration; it is derived directly from a call's
labels. reads_private and reaches_external are derived from the
capabilities: key on a sink entry, so a policy where no sink declares any
capability computes only from_untrusted, and interbolt validate rejects
any reference to the other two legs until at least one sink does. See
Policy evaluation internals
for the full derivation, both scopes, and the limits of run-level gating.
An agent that reads the inbox, ingests a web search result through taint(),
and then tries to send mail is blocked, whether or not the outgoing message
still carries a label. That last part is what this rule buys over a
taint.exists(t, t.trust == "untrusted") rule on the same sink: the model
composing the message body launders value-level taint away, and the
run-scoped legs survive it.
Use size(run.trifecta) >= 3 rather than run.trifecta.all(...). CEL folds
all to true on an empty list, so an all form in an allow rule would pass
a run whose legs were never recorded, and interbolt validate warns about
exactly that shape.
Value-level taint dies the instant an LLM reads tainted context and emits a
fresh tool call, because the model's output is deserialized into plain
strings with no label at all, leaving a rule written against taint/args
nothing to inspect. run.tainted is a coarser, laundering-resistant signal
for exactly this case.
The mechanism rests on taint(value, source=...) doing two separate things.
It labels the value, and it records the source name in a run-scoped registry
attributed to the active agent_context. run.tainted is computed from
that registry at the sink: true if any recorded source resolves untrusted,
and once true it stays true for the rest of the run. Because the registry
belongs to the run rather than to any individual value, it survives
everything that destroys a value-level label. It also survives an explicit
pack/unpack round trip across a turn boundary, via the wire format's
run block; see
Serialization: run-level gating across a round trip.
A taint() call with
derived_from records nothing here, since a derivation hop is inherited
trust rather than new ingress (see
Taint propagation: model calls and derived values).
This fires even when args.to/args.body were generated fresh by the model
and carry no Tainted label whatsoever, as long as something untrusted
entered the run earlier, such as a poisoned calendar invite or a web search
result. Because the decision is over a run-scoped fact rather than over the
bytes of the argument, paraphrasing or summarizing the untrusted content
before the tool call does not evade it.
Read this before relying on it:
Only sees taint() calls made while an agent_context is active. A
taint() call outside one, or inside a thread-pool-offloaded worker,
cannot be attributed to a run, so run.tainted will not reflect it, and
taint() logs a DEBUG message when this happens (see
Identity: thread pools).
Coarse and monotonic. Once set it stays true for the rest of the run,
gating a run that legitimately mixes an untrusted read with an unrelated,
safe write the same as a genuine attack. Write carve-outs by tool or
argument shape for that case.
A backstop rather than a replacement for value-level taint.taint/args rules stay precise where the value survives; run.tainted
covers where it does not.
interbolt validate rejects any run.<field> reference outside the
computable fields, tainted, sources, untrusted_sources, ingested_by,
and trifecta.
agent.id lets one policy file express "this agent may do X" without a
separate policy per agent, or a per-agent scoping block in the schema.
agent.id == "x" && <condition> already expresses everything a scoping
block would, and it composes with cross-agent conditions a scoping block
cannot.
Narrow the block rule, do not precede it with an allow. The safer idiom
attaches the exception directly to the rule it modifies:
First-match-wins (see Sinks and rules) means the
notifier bypasses block_untrusted_exfil entirely, for every argument,
forever, probably not what was intended if the goal was "the notifier
skips approval," not "the notifier is exempt from the exfil block."
interbolt validate warns on an allow rule whose when references
agent. but no taint/max_trust/sources/run.tainted/run.sources/
run.untrusted_sources condition, for exactly this shape. run.ingested_by
does not count as a provenance condition here: it names agents, not trust,
so a rule gated on it alone is still identity-only.
Identity is not a substitute for provenance. An agent being trusted
says nothing about whether the data reaching a given call is. Least
privilege across agents in one file combines both axes:
Watch the empty-list fold in an identity-scoped allow. CEL's all
macro folds to true on an empty list (exists folds to false instead,
which is why the block idiom above has never hit this). So:
reads as "only when everything is trusted," but it also fires on a call
that carries no labels at all, including one whose provenance was
laundered away (see
Taint propagation).
taint.exists(t, ...) is false on an empty list, which is the safe direction;
taint.all(t, ...) is not. interbolt validate warns on taint.all(...)
inside an allow rule; prefer !taint.exists(t, t.trust == "untrusted"), or
conjoin size(taint) > 0 if labeled input is genuinely required.
silently stops covering the deployment the moment a second research agent
ships: nothing fails, nothing warns, the rule just quietly protects less
than it used to. A group is a named set of agent ids, declared once in
the policy and referenced from any rule, so the rule can test membership
instead:
Phrased this way, a newly deployed agent with no declared groups is
covered by default, rather than silently falling outside the rule the way
the enumerated form does. agents: is optional and additive: omit it and
every agent resolves to agent.groups == [], identical to behavior before
this section existed.
A group grants nothing by itself. Permissions live entirely in the sink
rules, exactly as they do without groups; a group is only a label on the
acting agent that a rule's when can test. This is closer to a POSIX or
Active Directory group than to an IAM role: there is no permission attached
to the group itself, no ranking between groups, and no notion of a principal
"assuming" one group at a time. An agent in [payer, internal] is matched
by whichever rule references either, decided purely by first-match-wins rule
order (see Sinks and rules), never by any precedence
between the groups themselves.
Declared in the policy file, never at bind time. There is deliberately
no way to assert an agent's groups from application code (something like
agent("support", groups=["payer"]) does not exist). Group membership is
part of the same single authoritative policy document as sources and
sinks, so a local deployment can never grant itself membership a remote,
centrally managed policy is supposed to control. It also keeps a
model-influenced group string from being a more valuable injection target
than a model-influenced agent id already is (see
Identity as a policy input).
The same empty-list fold applies here as with taint.all.agent.groups.exists(g, ...) is false for an agent with no declared
groups: the safe, fail-closed direction for a group-gated block or a
narrowed exception. agent.groups.all(g, ...) is vacuously true for the
same agent, which is the wrong direction for a group-gated allow; treat it
with the same suspicion as taint.all(...) in an allow rule (see
Per-agent carve-outs above).
Agent ids and group names declared under agents: are both validated
against the identifier charset ^[A-Za-z0-9_.-]+$, and an unknown key
inside an agent's entry is rejected at load rather than silently ignored.
interbolt validate additionally warns when a rule references a group name
that no agent in agents: declares, the same typo-catching spirit as its
other lints (see
Policy evaluation internals).
agent.id and agent.groups are two independent ways to gate on the same
acting agent, and rule order has no notion of "the more specific rule wins."
First-match-wins means whichever of the two rules appears first decides the
call, regardless of which one names the agent more precisely:
If billing-agent is a member of payer, the second rule never fires: the
first rule already matched and decided the call. This reads like the id
rule should override the group rule (the way CSS specificity, longest-prefix
routing, and IAM's explicit-deny-wins all behave), and that is exactly the
intuition that does not hold here. interbolt validate catches the provable
case of this (see
Identity shadowing
below); the non-provable case (a group rule that shadows an id rule for some
members of the group but not others) is not something a lint can flag for
every agent at once, but interbolt explain --agent <id>
resolves it for one agent on demand. The sturdier fix, either way, is to not
write two ordered rules for one agent's exception in the first place. Fold
the exception into a single rule with a conjunct instead:
One rule, no ordering question, and the exception cannot be silently
reordered away from the condition it narrows, the same reasoning
Per-agent carve-outs above already gives for
narrowing a block rule rather than staging an allow above it. Reach for
separate rules when the two cases genuinely need different actions, not as
a way to special-case one agent within an otherwise group-scoped rule.
When your code has actually validated a piece of untrusted data,
endorse() records that fact on the
label without changing its lineage or trust. A sink can then gate on
t.endorsements. There are two shapes, and which you want depends on
whether you are blocking the unendorsed case or allowing the endorsed one.
require_endorsement and when are mutually exclusive on one rule, and the
compiled form is what appears as matched_condition on the Decision. This
shape is conservative: every untrusted contributor to the call must carry
the kind, since a single untrusted-and-unendorsed label makes the when
true and blocks.
When you need to combine the endorsement check with another condition, an
external-recipient check, say, require_endorsement will not do, since it
cannot be combined with when. Write an allow rule matching the endorsed
value and place it above the block rule, so first-match-wins lets the
validated call through:
This shape is more permissive than the block-form above: a single endorsed
label lets the whole call through, on the reasoning that an endorsed value
(an allowlisted recipient is a known-safe destination) makes the call safe.
Reach for it when one validated value vouches for the call, and the
block-form when every untrusted input must be individually endorsed.
Writing a policy
walks through this carve-out shape end to end.
Because t.trust is unchanged by endorsement, a policy gating on
t.trust == "untrusted" keeps gating endorsed values, and naming the kind a
sink accepts is what carves out the exception. An endorsement for the wrong
kind still blocks.
Policy.validate(path), and interbolt validate policy.yaml on the command
line, checks schema and CEL only, without executing an agent or observing
live taint. It returns every problem it finds rather than raising, and the
CLI exits non-zero on errors but not on warnings. See
Policy evaluation internals
for exactly what it does and does not catch, and CI for
wiring it into a pipeline.