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:
- Is the source declared, and trusted? A source not listed in a
policy's
sourcestable is untrusted by default. See Policies: evaluation semantics. - Did you write
t.source ==instead of matching on lineage? A merged label'ssourcefield only names its first contributor, so a value formed by merging two sources can silently fail at.source ==check even though it correctly resolves untrusted. Writetaint.exists(t, t.lineage.exists(s, s == "web_search"))instead. See Policies: the CEL evaluation context. - Did the taint label survive the transformation? An f-string with
literal text,
str.formaton a plain template, or ajoinon 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. - Read the
Decision.decision.matched_ruleanddecision.untrusted_sourcesname 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_allowlistedis not satisfied by a value endorsed asurl_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_endorsementexception has to sit above the rule it excepts.
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_contextat all. Guarded calls still work, reportingagent_idas"default"with a freshrun_ideach, so nothing accumulates across calls andrun.taintedis never true. - The
taint()call happened beforeagent_contextwas 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 ownagent_context_syncblock. See Identity: thread pools. - A custom
check()loop passed a differentrun_idthan the oneagent_contextminted. Left alone,check()resolvesrun_idfrom the active context, so this only happens if you pass an explicitrun_id=that does not match the context's run. When that happens,taint()still records ingress under the context's run, butcheck()resolvesrun.taintedagainst therun_idyou passed, so the gate reads false permanently. Drop the explicitrun_id=to use the ambient run, or passcurrent_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_AUDIToverridesaudit=in both directions, soINTERBOLT_AUDIT=0disables it even when the code passedaudit=True. - The leaked content is shorter than 12 characters. Content below
AUDIT_MIN_MATCH_LENGTHis never registered, so it can never match. - The ingress was invisible to the run, for any of the
run.taintedreasons 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_approvalNothing 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.