Recusal is one idea applied three ways. The mental model is always the same:
gather evidence → compute a verdict → act on the verdict (allow / retry / refuse).
pip install recusalRegister a hook in .claude/settings.json and Recusal refuses unsafe tool calls before
Claude Code runs them, even under bypassPermissions (a PreToolUse deny overrides it):
{ "hooks": { "PreToolUse": [
{ "matcher": ".*", "hooks": [
{ "type": "command", "command": "[ -n \"$CLAUDE_PROJECT_DIR\" ] || { echo 'gate: CLAUDE_PROJECT_DIR is empty, so the gate script cannot be found; failing closed' >&2; exit 2; }; for p in python3 python py; do \"$p\" -c 'import sys; sys.version_info >= (3, 9) or sys.exit(1); import recusal' 2>/dev/null && { \"$p\" \"$CLAUDE_PROJECT_DIR/.claude/hooks/my_gate.py\"; rc=$?; [ \"$rc\" = 0 ] || { echo 'gate: hook did not run cleanly; failing closed' >&2; exit 2; }; exit 0; }; done; echo 'gate: no python>=3.9 that can import recusal; failing closed' >&2; exit 2" } ]}
]}}Why the interpreter loop, and why exit 2: the exit-code semantics, stated
exactly: Recusal's normal refusal exits 0 with valid permissionDecision: "deny"
JSON, which Claude honors as a block; a clean verdict exits 0 with no output and
defers to Claude's normal permission flow; exit 2 is Claude's blocking failure
signal; and any other nonzero exit is a non-blocking error, the tool call
proceeds. That last rule is the trap: a hook whose command fails to launch
(python3 not found, the default on Windows, where only python or the py
launcher exists) or launches but crashes (a Python 2 python, a syntax/import
error in the hook) is nonzero-but-not-2, so the gate silently disables. The loop runs
the first python3 → python → py that is >=3.9 and can import recusal (an
interpreter without the package is skipped), executes the hook, and coerces
any nonzero exit into exit 2. Stated precisely: the launcher closes the
missing-interpreter, wrong-interpreter, import-error, and nonzero gate-process
failure modes; it does not cover Claude-level hook cancellation, the hook timeout
(fail-open: Claude Code documents that a PreToolUse command hook canceled at its
timeout "doesn't block the tool call", hooks reference; an Agent SDK callback
hook that times out does block), or a broken gate that exits 0 with irrelevant output. (Deny and defer both exit 0, so any nonzero genuinely means
"the hook did not run.")
The first clause, [ -n "$CLAUDE_PROJECT_DIR" ] || ..., covers an empty project
directory, which has been reported in hook environments: the gate path would collapse to
/.claude/hooks/my_gate.py, outside your project, so the launcher refuses the call and
says which variable is empty instead. It never guesses another folder.
On Windows, Claude Code runs shell-form hooks under Git Bash when it is installed and
falls back to PowerShell when it is not, where this POSIX one-liner is a parse error
with a non-blocking exit code - the gate silently disables. recusal init registers a
PowerShell-native launcher (explicit "shell": "powershell") on Windows instead; use
--launcher both for a settings.json shared across operating systems, and recusal doctor validates the registered launcher against the host. An EXISTING install with an
older launcher (POSIX-only, or one from 0.12.0 or earlier without the empty-directory
check) migrates with recusal init --repair-launcher, which replaces
exactly the canonical launcher and touches nothing else (plain init sees the existing
gate and deliberately changes nothing). Verify your install end-to-end before trusting
it:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | python .claude/hooks/my_gate.py
# Windows (py launcher): ... | py .claude/hooks/my_gate.py
# expect: {"hookSpecificOutput": {..., "permissionDecision": "deny", ...}}The hook must run under the same interpreter recusal is installed in. The launcher
picks the first python3/python/py on PATH; if that is a system Python while you
pip installed recusal into a venv, the hook raises ImportError and (correctly) fails
closed, blocking every tool call. For a venv, point the command at the venv's interpreter
explicitly (e.g. .venv/bin/python / .venv\Scripts\python.exe) instead of the bare probe.
# my_gate.py
from recusal import Finding
from recusal.claude_code import run_pretooluse_hook
def policy(tool_name, tool_input):
if tool_name == "Bash" and "rm -rf" in tool_input.get("command", ""):
return [Finding.fail("destructive_bash", severity="CRITICAL", message="refusing rm -rf")]
return [] # no opinion → defer to Claude Code's normal flow
run_pretooluse_hook(policy)A clean verdict defers (the gate adds refusals; it never strips Claude Code's own
prompts). A non-clean verdict denies, with the reasons. See examples/claude_code_gate.py.
The policy above is a hand-written deny-list: refuse known-bad calls, defer the rest.
It stops the accidental and common cases and its deny holds even in auto mode, but a
literal matcher can be obfuscated past, and python script.py runs code no string check
ever reads. Never read a deny-list as "cannot be subverted."
Rather than hand-roll one, use the hardened reference deny-list that governs this repo:
from recusal.claude_code import run_pretooluse_hook
from recusal.deny_list import DEFAULT_PROTECTED_PATHS, deny_list_policy
# protected_paths REPLACES the defaults: add your gate's paths to them, never instead of them
run_pretooluse_hook(
deny_list_policy()
) # or protected_paths=DEFAULT_PROTECTED_PATHS + (".mygate/",)deny_list_policy refuses destructive shell, writes to secret files, and edits or
deletions of the gate's own control paths, with uniform de-obfuscation, pipe-into-any-
interpreter, reverse-shell, cd/variable-indirection, and best-effort symlink coverage. It
is the engine behind .claude/hooks/recusal_gate.py, versioned and unit-tested in the
package (tests/test_deny_list.py), so a fix reaches you via pip install -U.
For high-stakes channels, flip to allowlist mode (default-deny), shipped as a factory:
from recusal.claude_code import allowlist_policy, run_pretooluse_hook
run_pretooluse_hook(allowlist_policy(writable_root="./workspace"))Nothing runs unless affirmatively named: unlisted tools, shell metacharacters, and bare
interpreters (python script.py) are refused, closing the write-a-script-then-run-it
bypass no deny-list can see (pinned in tests/test_claude_code_allowlist.py). The claim,
stated precisely: within a correctly registered routed tool channel, an unapproved
capability is refused by default rather than inferred safe. It says nothing about channels
outside Claude Code's tool loop.
The trade-off is maintenance: you add binaries, roots, and per-tool predicates
(safe_binaries=, writable_root=, allow={...}) as the agent legitimately needs them,
and it fails toward refusal in the meantime. A vetted call still defers to Claude Code's
normal permission flow, allowlist mode, too, only ever adds refusals. See
examples/allowlist_gate.py for the deny-list-vs-allowlist comparison, and
docs/COOKBOOK.md recipe 11 for tuning.
Use the manual agent loop (not the auto tool-runner) so you can adjudicate each
tool call before it executes. Gather whatever evidence proves the call is safe, get a
verdict, and on a non-PASS verdict hand Claude an is_error tool_result, it self-corrects.
import os
from recusal import Finding
from recusal.claude import gate_tool_use
SAFE_ROOT = os.path.abspath("/workspace/tmp")
def gather_evidence(tool):
# Whatever proves THIS call is safe: preconditions, policy, a dry-run, an allowlist.
# commonpath, not startswith: "/workspace/tmp_evil" would slip past startswith.
if tool.name == "delete_file":
target = os.path.abspath(tool.input["path"])
try:
inside = os.path.commonpath([SAFE_ROOT, target]) == SAFE_ROOT
except ValueError: # different drives on Windows
inside = False
if not inside:
return [
Finding.fail(
"path_allowlist",
severity="CRITICAL",
message=f"{tool.input['path']} is outside {SAFE_ROOT}",
)
]
return [Finding.ok("path_allowlist", severity="CRITICAL")]
# inside the manual loop, for each tool_use block:
allow, refusal = gate_tool_use(tool.id, gather_evidence(tool), tool_name=tool.name)
if not allow:
results.append(refusal) # tool_result, is_error=True → Claude adapts
else:
results.append(
{
"type": "tool_result",
"tool_use_id": tool.id,
"content": execute_tool(tool.name, tool.input),
}
)Runnable: examples/claude_agent_live.py (real API) and examples/claude_refusal.py
(offline, no key).
For agents running permission_policy: {"type": "always_ask"} (beta header
managed-agents-2026-04-01), the session emits an agent.tool_use or
agent.mcp_tool_use event, then idles with stop_reason.type: "requires_action" until
it receives a user.tool_confirmation for each blocking event. Make Recusal the decider.
The event shape below is the one Anthropic documents
(permission policies); tool_confirmation only
builds the dict and carries no SDK dependency. Pass the event ID of the tool-use
event (listed in stop_reason.event_ids), not a Messages API tool_use block id:
from recusal.claude import tool_confirmation
event = tool_confirmation(tool_use_event.id, gather_evidence(tool_use_event))
# → {"type": "user.tool_confirmation", "tool_use_id": ..., "result": "allow"|"deny",
# "deny_message": "..."}
client.beta.sessions.events.send(session.id, events=[event])Recusal decides only the calls that reach a confirmation. Under the auto policy the
server runs the calls it judges safe and denies the ones it judges high-risk without
asking, and a server denial cannot be overridden; only the calls it reaches no
determination on pause for a confirmation. For a tool every call of which must be
adjudicated, set that tool to always_ask. Custom tools are outside permission
policies entirely: your application decides before sending user.custom_tool_result,
which is where gate_tool_use-style adjudication belongs.
Use the built-in checks to turn data into evidence, then decide:
from recusal import compute_verdict
from recusal.checks import date_range, null_rate, referential_integrity, row_count
verdict = compute_verdict(
[
row_count(users, min_rows=1),
null_rate(users, "email", max_rate=0.10),
referential_integrity(orders, users, fk="user_id", pk="id"),
date_range(orders, "created_at", min_date="2026-01-01", max_date="2026-12-31"),
]
)
if verdict.refused:
raise RuntimeError(verdict.reasons()) # don't ship itfrom recusal import Finding, GateAdjudicator
gate = GateAdjudicator()
# Each gate folds its findings into a typed Verdict via the shared kernel.
g5 = gate.adjudicate(
"G5", [Finding.fail("coverage_floor", severity="CRITICAL", message="coverage 61% < 75%")]
)
release = gate.release("run-001", [g5])
assert not release.release_ready # G5 failed, so the release is refused
print([r.gate_id for r in release.blocking]) # ['G5']Pair the gate with an append-only, hash-chained log so every verdict is on the record and an in-place edit or reordering of any entry with a surviving successor is detectable:
from recusal import compute_verdict, AuditLog, verify
audit = AuditLog(path="audit.jsonl") # omit path for in-memory
verdict = compute_verdict(findings)
audit.append(verdict, action={"tool": tool.name, "input": tool.input}, actor=session_id)
ok, problems = verify(
audit.entries
) # (False, [...reasons]) if an entry with a later entry was editedEach entry carries the SHA-256 of the entry before it, so editing or reordering a record
that still has an untampered successor breaks the chain. Tail-truncation, a tail-suffix
rewrite (in the limit just the last entry), or a forged append by a write-access attacker
needs an external anchor (verify(..., expected_head=(count, last_hash))), the chain is
tamper-evident, not tamper-proof. See examples/audit_demo.py.
When the gate refuses or an action fails, decide what kind of failure it is and where it goes, deterministically, no model:
from recusal import classify_failure, classify_verdict
c = classify_failure(error_text) # or classify_verdict(verdict)
if c.route == "retry":
... # transient, try again
elif c.route == "refuse":
... # policy violation, don't retry as-is
elif c.route == "ask-human":
... # ambiguous, escalateDefault classes (order is precedence, security-critical first): policy_violation,
prompt_injection, transient, code_bug, data_shape, data_missing, spec_ambiguity.
Unmatched failures fall back to
ask-human, it never guesses. See examples/classify_demo.py.
The deny-list and the allowlist decide whether a call looks acceptable. This decides whether the agent holds authority for this call: bound principal, active subject, granted tool and operation, argument constraints, expiry, call budget, replay, and the policy and manifest the grant was approved under. Every input is evidence you supply, each with a provenance label; the adjudicator reads no clock and keeps no counter.
from recusal import (
ActionRequest,
AuthorizationContext,
Constraints,
DecisionReceipt,
Supplied,
certify_authorization,
)
request = ActionRequest(
principal="agent:invoice-assistant", # a label; bound to a principal only below
tool="crm.update_record",
operation="update",
resource="customer:456",
arguments={"status": "resolved", "notes": "invoice 456 paid"},
nonce="n-002",
)
context = AuthorizationContext(
trusted_principals=Supplied({"agent:invoice-assistant": "svc:invoice"}, "operator_config"),
active_subject=Supplied("customer:456", "session_store"),
allowed_operations=Supplied({"crm.update_record": ["update"]}, "delegation_grant"),
constraints=Supplied(
Constraints(
fields=("status", "notes"), max_calls=3, expires_at="2026-08-26T20:00:00+00:00"
),
"delegation_grant",
),
now=Supplied("2026-08-26T19:00:00+00:00", "adopter_clock"), # supplied, never read
calls_so_far=Supplied(1, "adopter_counter"), # supplied, never kept
used_nonces=Supplied(["n-001"], "adopter_nonce_store"),
approval=Supplied({"policy_version": "crm-writes-v3"}, "delegation_grant"),
policy_version=Supplied("crm-writes-v3", "operator_config"),
)
decision = certify_authorization(request, context)
if not decision.authorized:
raise PermissionError(decision.verdict.reasons())
receipt = DecisionReceipt.build(decision) # derived from the decision, nothing else
print(receipt.digest) # the same evidence yields the same digest, every timeThree things to hold onto. A required dimension with no evidence refuses: pass nothing
for active_subject and authorization.subject fails CRITICAL; there is no way to pass
by omission, and an unrelated passing finding cannot stand in for a missing one
(certify_dimensions synthesizes authorization.dimension_missing). A runtime label
is a claim. The Claude Code hook records the event's agent_id, agent_type and
permission_mode as runtime_* keys with provenance claude_pretooluse_event; the
principal dimension passes only through the trusted_principals rule you supply. State
is yours. Expiry, budget and replay verify the now, calls_so_far and used_nonces
you hand in; nothing here reserves a slot or consumes a nonce, so make the counter update
atomic in your own store before you execute. Every built-in dimension is always required
and cannot be switched off; additional_required= adds custom dimensions you evidence
with your own authorization.<name> findings (a custom finding may not reuse a built-in
name). Malformed evidence is rejected, not normalized: a non-string key, a set, an
arbitrary object, NaN or infinity raises at construction, and a value bound compares
JSON numbers only, so "100", True and "nan" refuse rather than coerce.
recusal demo --scenario expired-authorization runs this exact shape offline.
- Where evidence comes from is yours. Recusal doesn't gather evidence, it adjudicates it. Preconditions, dry-runs, policy checks, an allowlist, the output of your existing validators (Great Expectations, pytest, a linter): anything that produces
Findings. - Pick severity by consequence, not by check. The same check can be
CRITICALin one context andWARNINGin another. Severity is a parameter, set it where you call the check. RETRYis a real signal. AnERROR-severity failure returnsRETRY; in an agent loop that means "let the model try once more withverdict.reasons()as context," not "give up."- The gate refuses; it does not act. It returns a verdict (and, for Claude, a tool_result). You decide what your loop/pipeline does with it. That separation is deliberate, see CONSTITUTION.md.