Interbolt
Guides

Troubleshooting

Fixes for the most common surprises when integrating a guarded call, a policy, or a run.

Troubleshooting

Most of these resolve faster if you start from the Decision itself. PolicyViolation and ApprovalDenied both carry one on .decision, and describe_decision renders the tool, action, matched rule, matched condition, mode, and untrusted sources in one line:

from rich.console import Console
from interbolt import PolicyViolation, describe_decision

try:
    send_email(to=..., body=summary)
except PolicyViolation as e:
    Console().print(describe_decision(e.decision))

Every call raises ApprovalDenied

The built-in default posture denies everything. configure() called with no policy= loads a policy with no sources and no sinks, where every guarded call falls through to require_approval, and the default approval resolver (auto_deny) denies every request. configure() logs a WARNING pointing at interbolt init when this happens.

The same symptom appears with a real policy when the sink key does not match the guarded tool: @guard on def send_email registers default.send_email, so a policy keyed email.send_email never matches and the call falls through to defaults.sink_action. Compare the key in the policy against decision.tool, which always shows the qualified name that was actually looked up. See Policies: namespacing.

A call was blocked and I expected allow

Check, in order:

  1. Is the source declared, and trusted? A source not listed in a policy's sources table is untrusted by default. See Policies: evaluation semantics.
  2. Did you write t.source == instead of matching on lineage? A merged label's source field only names its first contributor, so a value formed by merging two sources can silently fail a t.source == check even though it correctly resolves untrusted. Write taint.exists(t, t.lineage.exists(s, s == "web_search")) instead. See Policies: the CEL evaluation context.
  3. Did the taint label survive the transformation? An f-string with literal text, str.format on a plain template, or a join on a plain separator all silently drop a label. See Taint propagation: what survives, in practice, or run the laundering audit to find the exact call site.
  4. Read the Decision. decision.matched_rule and decision.untrusted_sources name exactly which rule fired and which sources caused it.

An endorsed value is still blocked

Three causes, in order of likelihood:

  • The kind does not match. A sink requiring recipient_allowlisted is not satisfied by a value endorsed as url_sanitized. This is deliberate.
  • The endorsement was lost in a merge. Endorsements intersect when two values combine, so an endorsed value concatenated with an unendorsed one keeps only the kinds both carried.
  • The rule order puts the carve-out below the block rule. First match wins, so an require_endorsement exception has to sit above the rule it excepts.

See Auditing: endorsement.

run.tainted is false when I expected true

run.tainted only sees a taint() call made while an agent_context is active. Common causes:

  • No agent_context at all. Guarded calls still work, reporting agent_id as "default" with a fresh run_id each, so nothing accumulates across calls and run.tainted is never true.
  • The taint() call happened before agent_context was entered, or outside it entirely.
  • The taint() call happened inside a thread-pool-offloaded worker. Context variables do not cross a thread pool boundary, so a spawned thread needs its own agent_context_sync block. See Identity: thread pools.
  • A custom check() loop passed a different run_id than the one agent_context minted. Left alone, check() resolves run_id from the active context, so this only happens if you pass an explicit run_id= that does not match the context's run. When that happens, taint() still records ingress under the context's run, but check() resolves run.tainted against the run_id you passed, so the gate reads false permanently. Drop the explicit run_id= to use the ambient run, or pass current_run_id.get(). See Identity: custom dispatch loops.

taint() logs a DEBUG message whenever it cannot attribute ingress to a run.

A run-level rule blocked a call and the record does not say why

On a decision whose arguments the model authored, contributing_labels, sources, and untrusted_sources are all empty, which is the normal shape for a run-level block rather than a sign of a missing label. Read decision.run_ingress instead. It names each source that entered the run, the trust it resolved to, and the agents that ingested it. interbolt inspect prints the untrusted ones on the decision line whenever run_tainted is true.

The audit reports no findings

  • The audit is off. INTERBOLT_AUDIT overrides audit= in both directions, so INTERBOLT_AUDIT=0 disables it even when the code passed audit=True.
  • The leaked content is shorter than 12 characters. Content below AUDIT_MIN_MATCH_LENGTH is never registered, so it can never match.
  • The ingress was invisible to the run, for any of the run.tainted reasons above.
  • The laundering was semantic. A model that paraphrased the untrusted text leaves no surviving byte sequence to match. See Taint propagation.

PolicyEvaluationError on a call I thought should be allowed

The most common cause is a when expression referencing an optional argument that was not passed, for example args.to.endsWith(...) when to is absent. Guard for presence: has(args.to) && args.to.endsWith(...). See Policies: evaluation errors.

Under enforce (the default), any evaluation error fails closed and raises this exception, and both matched_rule and matched_condition come back None on the decision. To fail open while diagnosing, switch mode temporarily. See Modes and fail_mode.

InterboltUsageError when calling a guarded function

Either configure() has not run yet in this process, or a sync call site was given an approval resolver that returns an awaitable. A module decorated with @guard can be imported before configure(), but the first call needs a configured runtime. See Identity: import order and reconfiguration.

interbolt validate passes locally but behaves differently in production

Mode has three sources, in precedence order: the INTERBOLT_MODE environment variable, the policy file's defaults.fail_mode, and configure(mode=...). A higher-precedence source overrides a lower one, so a shell or CI INTERBOLT_MODE, or a policy's fail_mode, can change the mode out from under configure(mode=...). configure() logs a WARNING whenever this happens, both when fail_mode overrides the mode= argument and when INTERBOLT_MODE overrides either, so check the logs for it first. configure() also logs one INFO summary line naming the effective mode, policy source, and source and sink counts. See Policies: modes and fail_mode.

A clean validate also does not mean the policy matches your tools. It cannot see sink keys that match no guarded tool, or args.* references that do not match a real signature. See Policy evaluation internals: what it does not catch.

A policy fails to load with an error naming .any(

.any( was removed as an alias for CEL's exists quantifier as of 0.2.0. Policy.from_file and interbolt validate both reject it now, at load time, rather than compiling it and only failing later at evaluation. The fix is one substitution: change every .any( in the policy to .exists(, including inside t.lineage.any(...), t.endorsements.any(...), and agent.groups.any(...), all of which take the same .exists( spelling. See Policy evaluation internals: conditions are plain CEL.

A rule using trifecta.contains("reaches_external") (or reads_private) never fires

reads_private and reaches_external are computed only for a tool whose sinks: entry declares capabilities:; a tool with no capabilities: key contributes neither leg. Check that the tool is declared, and with the right capability. interbolt validate rejects a leg reference when no sink in the policy declares any capability at all, and warns when some sink does but none declares that particular leg, so seeing this at runtime means the policy was deployed without validation. See Policies: declaring what a tool does.

A rule using size(run.trifecta) >= 3 never fires

The same declaration requirement applies at run scope: a leg missing from run.trifecta traces back to a tool with no capabilities: key, or to a call made outside any agent_context, where run.trifecta stays empty (see Run-level gating).

My policy stopped loading after upgrading

A sink entry is now a mapping with optional capabilities: and rules: keys rather than a bare list of rules, and the top-level capabilities: section has moved inside the entries it described. Set version: "2.0" and indent each rule list under a rules: key:

# before
sinks:
  email.send_email:
    - name: default
      action: require_approval

# after
sinks:
  email.send_email:
    rules:
      - name: default
        action: require_approval

Nothing inside a rule changed, and no when: expression needs editing. The error message names both edits.

A test using InMemoryReporter is not seeing records

Make sure configure() was called with that reporter instance after any prior configure() call in the test; re-configuring rebinds the process-current runtime cleanly. Note that events land in reporter.events, audit hits in reporter.findings, and endorse() calls in reporter.endorsements, so an empty events list does not mean nothing was emitted. See Testing.

I cannot find the JSONL log, or parsing it broke after an upgrade

JsonlReporter logs one WARNING naming the destination path after its first successful write, including the interbolt inspect command for it.

If a previously working parser broke, check schema_version. Version 7 removed the top-level fields Event duplicated from its decision, so event["agent_id"] is now event["decision"]["agent_id"]. See Events.

On this page