Interbolt
Reference

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:, or when:) 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_endorsement rule 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(...) and run.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 to agent instead of run.
  • Flags a group name referenced but never declared: a string literal appearing inside an agent.groups.exists(...) call in a when that does not match any group under any agents: 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 at t.lineage instead; see Policies: the CEL evaluation context for why comparing t.source is unsafe after a merge.
  • Flags an identity-only allow as a warning: an allow rule whose when references agent. but none of taint, max_trust, sources, run.tainted, run.sources, or run.untrusted_sources, which grants that agent unconditional access to the sink regardless of provenance. run.ingested_by does 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 an allow rule as a warning: all on an empty list is true in 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 over run.sources, run.untrusted_sources, or run.ingested_by inside an allow rule as a warning, the same empty-list-fold hazard as taint.all(...): it is vacuously true for a run whose ingress was never recorded, so it can allow a call in a run taint() never touched. Points at the exists(...) form, which folds to false in the same case. Unlike the identity-only-allow and taint.all(...) lints, this one parses the CEL AST via the same recognizers agent.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 under enforce. Guard optional arguments with has(args.x).
  • Source names that appear in a when but are never declared in sources. 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.

On this page