Interbolt
Concepts

Policies

The policy DSL, the CEL context a when expression can reference, and the evaluation semantics behind every decision.

Policies

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.

The shape of a policy file

A policy declares three things, keyed by the three things a decision is made about: a source name, an agent id, and a qualified tool name.

version: "2.0"

defaults:
  sink_action: require_approval

sources:
  - name: web_search
    trust: untrusted
  - name: user_input
    trust: trusted

agents:
  billing-agent:
    groups: [payer, internal]

sinks:
  crm.query_customers:
    capabilities: [reads_private]

  email.send_email:
    capabilities: [reaches_external]
    rules:
      - name: block_untrusted_exfil
        when: taint.exists(t, t.trust == "untrusted") && args.to.endsWith("@external.com")
        action: block
      - name: default
        action: require_approval

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.

Sources

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.

Sinks and rules

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.

Evaluation semantics

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.

Writing conditions

A when expression is plain CEL. Use exists(...), CEL's own quantifier macro, to test whether any element of a list satisfies a condition:

when: taint.exists(t, t.trust == "untrusted")
when: taint.all(t, t.trust == "trusted")
when: taint.exists(t, t.lineage.exists(s, s == "web_search"))

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.

Namespacing: how a sink key is built

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" qualifies
def send_email(...): ...          # to "default.send_email"

@guard(tool="fs.write")           # one dot: already-qualified namespace
def 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.

Actions

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.

The CEL evaluation context

A when: expression can reference:

  • 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.

    sinks:
      email.send_email:
        - name: approve_when_run_touched_the_web
          when: run.untrusted_sources.exists(s, s == "web_search") && args.to.endsWith("@external.com")
          action: require_approval
  • agent: a CEL map with two fields.

    • 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.

Evaluation errors

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(...).

Modes and fail_mode

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:

INTERBOLT_MODE=monitor pytest

Making an application mode flag authoritative

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).

Declaring what a tool does

The capabilities: key on a sink entry names what the tool does, which is what makes the reads_private and reaches_external trifecta legs computable.

sinks:
  default.read_inbox:
    capabilities: [reads_private]

  crm.query_customers:
    capabilities: [reads_private]

  default.send_email:
    capabilities: [reaches_external]
    rules:
      - name: default
        action: require_approval

  default.fs_write:
    capabilities: []

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.

The trifecta limit

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.

The Rule-of-Two check

sinks:
  default.read_inbox:
    capabilities: [reads_private]

  default.send_email:
    capabilities: [reaches_external]
    rules:
      - name: block_rule_of_two
        when: size(run.trifecta) >= 3
        action: block
      - name: default
        action: allow

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.

Run-level gating (run.tainted)

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).

sinks:
  default.send_email:
    rules:
      - name: block_run_tainted_exfil
        when: run.tainted && args.to.endsWith("@external.com")
        action: block

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.

Per-agent carve-outs

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:

sinks:
  email.send_email:
    rules:
      - name: block_untrusted_exfil
        when: taint.exists(t, t.trust == "untrusted")
              && args.to.endsWith("@external.com")
              && agent.id != "outbound-mailer"
        action: block
      - name: default
        action: require_approval

A rule written this way cannot outlive the block it narrows or be reordered away from it. Compare the riskier shape, an allow staged above the block:

sinks:
  email.send_email:
    rules:
      - name: allow_internal_notifier
        when: agent.id == "notifier"
        action: allow
      - name: block_untrusted_exfil
        when: taint.exists(t, t.trust == "untrusted") && args.to.endsWith("@external.com")
        action: block

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:

sinks:
  payments.send_payment:
    rules:
      - name: only_billing_agent
        when: agent.id != "billing-agent"
        action: block
      - name: untrusted_payment
        when: taint.exists(t, t.trust == "untrusted")
        action: block
      - name: default
        action: require_approval

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:

      - name: allow_trusted_agent
        when: taint.all(t, t.trust == "trusted") && agent.id == "reporter"
        action: allow

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.

Group membership (agent.groups)

Enumerating agent ids one at a time works until a second agent needs the same treatment. A rule written as

when: agent.id == "researcher" && taint.exists(t, t.trust == "untrusted")
action: block

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:

agents:
  billing-agent:
    groups: [payer, internal]
  support-agent:
    groups: [internal]

sinks:
  default.tool:
    rules:
      - name: block_untrusted_for_non_payers
        when: taint.exists(t, t.trust == "untrusted")
              && !agent.groups.exists(g, g == "payer")
        action: block

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).

Two ways to reference an agent, and no specificity between them

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:

sinks:
  payments.send_payment:
    rules:
      - name: payers_need_approval
        when: agent.groups.exists(g, g == "payer")
        action: require_approval
      - name: billing_agent_blocked
        when: agent.id == "billing-agent"
        action: block

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:

      - name: payers_need_approval
        when: agent.groups.exists(g, g == "payer") && agent.id != "billing-agent"
        action: require_approval

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.

Endorsement-aware rules (t.endorsements, require_endorsement)

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.

Block the unendorsed case (require_endorsement)

The common shape blocks untrusted data that lacks the endorsement a sink requires:

sinks:
  default.send_email:
    rules:
      - name: require_allowlist
        when: >
          taint.exists(t, t.trust == "untrusted" &&
            !t.endorsements.exists(k, k == "recipient_allowlisted"))
        action: block
      - name: default
        action: allow

The rule field require_endorsement: <kind> compiles to exactly that expression, for the common case:

sinks:
  default.send_email:
    rules:
      - name: require_allowlist
        require_endorsement: recipient_allowlisted
        action: block
      - name: default
        action: allow

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.

Allow the endorsed case (a when carve-out above a block rule)

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:

sinks:
  default.send_email:
    rules:
      - name: allow_endorsed_recipient
        when: taint.exists(t, t.endorsements.exists(k, k == "recipient_allowlisted"))
        action: allow
      - name: block_untrusted_external
        when: taint.exists(t, t.trust == "untrusted") && !args.to.endsWith("@ourcompany.com")
        action: block
      - name: default
        action: require_approval

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.

Static validation

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.

On this page