Policy evaluation internals
The lethal-trifecta derivation, the policy fingerprint, and what interbolt validate actually checks.
Policy evaluation internals
The precise mechanics behind Policies: how
taint.exists(...) compiles, exactly what the trifecta limit means for a
rule you write, and what Policy.validate does and does not catch. Read the
concepts page first for the practical model, and
Writing a policy for the rule patterns these
mechanics support.
Conditions are plain CEL
CEL's macro grammar is exactly {map, filter, all, exists, exists_one, reduce, min}. A macro works anywhere in an expression, not only on
taint: t.lineage.exists(s, s == "web_search") and
t.endorsements.exists(k, k == "recipient_allowlisted") both work.
Earlier versions accepted .any( as an alias for exists, rewritten at
compile time. The alias is gone as of 0.2.0: a policy still written with
.any( fails at load, and at interbolt validate, with a message naming
exists as the fix, rather than compiling successfully and only failing
later at evaluation.
Separately, sources and max_trust are top-level context variables,
siblings of taint rather than taint.sources/taint.max_trust. The
exists/all macros require their receiver to be a CEL list, and one
context value cannot be both a list and a map.
The trifecta derivation, in full
The lethal trifecta describes three properties whose combination makes a tool call dangerous: the call is influenced by untrusted data, private data is reachable, and the call can send data outside your trust boundary. Interbolt computes all three, from two different places.
from_untrusted is derived from the call's own labels. It is present when any
label contributing to the call resolves untrusted against the policy's
sources table, and it needs no declaration.
reads_private and reaches_external are derived from the capabilities:
key on a sink entry, which names what each tool does. A tool whose entry
declares no capabilities: key contributes neither leg, which means a policy
that declares capabilities for some tools and not others under-counts
silently for the rest. interbolt validate warns about every sink entry
missing the key once any entry carries it, and a declared empty list records
that a tool genuinely has neither.
Two scopes, and why the run scope is the useful one
trifecta holds the legs satisfied by the current call: from_untrusted from
its arguments, plus whatever the called tool declares. A single tool rarely
both reads private data and reaches an external destination, so
size(trifecta) >= 3 remains rare even under a complete capabilities:
declaration. The per-call form is useful at two legs, where
size(trifecta) >= 2 reads as "untrusted data is about to reach a tool that
can send it out."
run.trifecta holds the legs satisfied anywhere in the run: from_untrusted
when the run has ingested untrusted data through taint(), plus every
capability declared for every tool guarded during the run, including the call
being decided. The three legs genuinely co-occur at this scope, so
size(run.trifecta) >= 3 is the Rule-of-Two check, and it holds through a
model-mediated handoff that launders value-level taint away, because it is
computed from run-scoped facts rather than from the bytes of an argument.
The call being decided is recorded into the run before its own rules are
evaluated. Without that ordering, a rule on an external-reaching call could
never see its own reaches_external leg and would fire one call late.
The limits
run.trifecta is run-scoped rather than value-scoped. Three legs present in a
run means the dangerous combination exists somewhere in that run, not that
this specific argument derives from private or untrusted data. A rule needing
the value-level claim is written against taint and t.lineage instead.
Like run.tainted, run.trifecta only reflects activity attributable to a
run, so it is empty outside any agent_context and does not see taint()
calls made on a thread-pool-offloaded worker. It clears when the
agent_context block exits, and it survives an explicit pack/unpack round
trip through the envelope's run block.
Run-level gating is coarse by construction. A run that legitimately mixes a private read, an untrusted ingest, and an external send will be gated, which is the intended behavior for a containment layer rather than a false positive to tune away. Where a specific combination is acceptable, narrow the rule with a per-agent or per-tool condition rather than dropping the check.
The policy fingerprint
Every compiled Policy carries policy.fingerprint, a "sha256:..." hash
computed once, at construction, from the validated PolicyDocument. It is
stamped onto every Event, Finding, and Endorsement the policy produces
(see Events), so a record stays attributable to the
exact policy that produced it even after the policy file has since changed.
It identifies the policy; it does not let you reconstruct it, so retention
of the policy document itself is still the operator's responsibility.
What gets hashed is the normalized document, not the compiled CEL
programs: a JSON dump of the parsed document with object keys sorted, never
the raw YAML text or the compiled CompiledSink/CEL runner objects (whose
internal serialization is not guaranteed stable across cel-python
versions). This gives fingerprints with exactly the properties you'd want
from a hash meant for auditing:
- Loading the same file twice, or loading it after a comment or whitespace-only edit, produces the same fingerprint. Comments cannot change a decision, so they should not invalidate a record's join against a retained policy.
- Reordering keys within a mapping does not change it, since object key order is normalized before hashing.
- Reordering rules within a sink does change it, because rule order is semantic under first-match-wins evaluation (see Policies: evaluation semantics).
- Any other semantic edit (a changed
trust:,action:, orwhen:) changes it. - The built-in default policy (
default_policy()) has a fixed, empty document, so its fingerprint is stable across processes with no special-casing.
What Policy.validate/interbolt validate actually checks
Policy.validate(path), and interbolt validate policy.yaml on the command
line, performs schema and CEL checks only, without executing an agent or
observing live taint. Every problem is returned as a string rather than
raised.
Schema, first and short-circuiting. The file is validated against the
policy schema before anything else, and if that fails, the schema problems
are returned immediately without any CEL checks running. Schema validation
covers the required fields, sink keys being well-formed namespace.tool
pairs (a.b.c is rejected as ambiguous), the mutual exclusion of when and
require_endorsement on one rule, and the endorsement kind pattern
^[A-Za-z0-9_.-]+$. That last one is security-relevant rather than
cosmetic: require_endorsement's kind is interpolated into compiled CEL
source, so an unconstrained character such as a quote could rewrite the
compiled predicate's meaning.
Then, per rule:
- Compiles the CEL expression, reporting a parse failure with the rule name.
A
require_endorsementrule is compiled too, through the expression that shorthand expands to. - Flags dead rules: more than one unconditional catch-all within a sink, or any rule placed after one.
- Checks every trifecta leg literal, in both
trifecta.contains(...)andrun.trifecta, against three cases: a name that is not a leg at all is an error, a capability leg referenced by a policy where no sink declares any capability is an error, and a capability leg that no sink declares is a warning, since the reference can never match as the policy currently stands. - Warns about any sink entry with no
capabilities:key, once at least one entry carries one. Declaring[]records the absence deliberately and silences the warning. - Rejects a document declaring
version: "1.0", or a sink entry written as a bare rule list, with a message naming both edits the 2.0 shape needs. - Rejects a
run.<field>reference outside the computable field set,{tainted, sources, untrusted_sources, ingested_by, trifecta}. - Rejects an
agent.<field>reference outside the computable field set,{id, groups}, the identical check applied toagentinstead ofrun. - Flags a group name referenced but never declared: a string literal
appearing inside an
agent.groups.exists(...)call in awhenthat does not match any group under anyagents:entry. A warning, not an error, since a policy may legitimately be written ahead of the deployment that declares the agent; an undeclared group already resolves to no match at evaluation time regardless (see Policies: group membership). - Flags a
t.source ==/!=comparison as a warning rather than an error, pointing att.lineageinstead; see Policies: the CEL evaluation context for why comparingt.sourceis unsafe after a merge. - Flags an identity-only allow as a warning: an
allowrule whosewhenreferencesagent.but none oftaint,max_trust,sources,run.tainted,run.sources, orrun.untrusted_sources, which grants that agent unconditional access to the sink regardless of provenance.run.ingested_bydoes not count: it is an identity signal, not a provenance one, so a rule gated on it alone still warns. See Policies: per-agent carve-outs. - Flags
taint.all(...)inside anallowrule as a warning:allon an empty list istruein CEL, so this combination can allow a call carrying no labels at all, including one whose provenance was laundered away. See Policies: per-agent carve-outs. - Flags an
all(...)macro overrun.sources,run.untrusted_sources, orrun.ingested_byinside anallowrule as a warning, the same empty-list-fold hazard astaint.all(...): it is vacuously true for a run whose ingress was never recorded, so it can allow a call in a runtaint()never touched. Points at theexists(...)form, which folds tofalsein the same case. Unlike the identity-only-allow andtaint.all(...)lints, this one parses the CEL AST via the same recognizersagent.groups.exists(...)uses, rather than matching text.
The identity-only-allow and taint.all(...) lints are string-level
heuristics over the when text: they catch the common shape, not every
semantically equivalent rewrite of it. Every other lint above, including the
t.source check and the run.*.all(...) check, parses the CEL expression
and matches against its syntax tree, so it isn't fooled by a string literal
that happens to contain a lint-triggering substring.
Identity shadowing
This check reasons across an entire sink's rule list rather than within one
when, unlike every lint above it. It parses each rule's when into CEL's
syntax tree and checks whether one rule's condition is a superset of
another's. It exists because agent.id
and agent.groups give a policy two independent ways to reference the same
agent, and first-match-wins rule order has no notion of specificity between
them (see
Policies: two ways to reference an agent).
What it proves. Within one sink, if an earlier rule's when and a later
rule's when are each built purely from agent.id == "x"/!= "x",
agent.groups.exists(g, g == "y"), or a negation of one of those, combined
only with &&/||, each condition resolves to a specific set of agent ids:
every id the policy declares, plus one stand-in for all the ids it does not.
This works because group membership is declared in the same file as the
rules, so the check computes set containment directly rather than reasoning
about what one condition implies about another. When the earlier rule's set
is a superset of the later rule's, the later rule can never fire.
interbolt validate reports this as an error, the same severity as the
dead-rule/catch-all check above: it is the same class of defect, a rule that
can never fire, proved a different way. The message names both rules and,
where one clean fact explains it, the specific membership that causes it
(for example, that a given agent is a declared member of the group that
shadows its agent.id rule).
What it deliberately does not prove. Any rule whose when mixes in a
taint, args, run, or trifecta condition is not purely identity-scoped,
so the pair is skipped. This means the check reports fewer shadowed rules
than may exist, rather than ever reporting one that is not actually dead.
Partial shadowing is also out of scope: a group rule can shadow an id
rule for some agents in the group but not others, and that is often
intentional rather than a defect. Reporting it would need different wording
("unreachable for agents A and B," not "unreachable") and a different
severity than this lint uses. interbolt explain --agent
answers that question instead, resolving reachability for one specific agent
rather than proving it once for every agent, which is exactly the
partial-shadowing case this lint does not cover.
interbolt validate exits 0 on a policy with only warnings, and 1 if any
error is present, which is what makes it safe to gate a pipeline on.
What it does not catch
Everything below needs a running agent, so a clean validate is necessary
rather than sufficient:
- Sink keys that match no guarded tool. A typo in a sink key produces a
policy section that silently never fires, and a guarded tool with no sink
entry silently falls through to
defaults.sink_action. Neither is visible statically, since the tool inventory only exists at runtime. args.*references that do not match a tool's real signature. These surface at the first matching call as an evaluation error, fail-closed underenforce. Guard optional arguments withhas(args.x).- Source names that appear in a
whenbut are never declared insources. An undeclared source resolves untrusted under default-deny regardless, so this is safe to skip. - Whether the rule order expresses what you meant. Reachability is checked; intent is not. A carve-out placed below the rule it was meant to except is a valid policy that quietly never applies.
Cover the last two with decision tests over check(); see
Testing and CI for wiring both into a
pipeline.