Skip to main content

recusal, deterministic governance for Claude agents

Recusal

Deterministic governance for Claude agents: an independent verifier that can refuse to certify a tool call before it runs.

Lightweight (zero dependencies) · extensible (a check is just a function that returns a finding) · Claude-native (drops into Claude Code as a hook, MCP tool calls included, and the Claude Agent SDK as a tool gate). The zero-dep core works in any agent loop.

CI python license runtime deps

Two verbatim terminal transcripts: the dogfooded hook refuses rm -rf in a live Claude Code session running under --dangerously-skip-permissions, then the offline demo refuses a write to the wrong customer and allows the corrected call

Verbatim transcripts, rendered: a live Claude Code session where the repo's own hook refuses rm -rf under --dangerously-skip-permissions, then the offline demo (python examples/claude_refusal.py), no API key.

A judge recuses themselves from a case they can't impartially decide. The same principle governs autonomous agents: the thing that generates the work must never be the thing that certifies it. Recusal is that independent authority: collect evidence, adjudicate it into PASS / RETRY / FAIL, and let the gate refuse. No model call in the decision path: the same normalized evidence and policy inputs, under the same recusal version, produce the same verdict, including the "no".


The wedge: don't let the same model grade its own work

The reflex fix for agent safety is another model asking "does this action look OK?", but a judge from the same family shares the builder's blind spots and drifts with it: that is a conflict of interest, not a control. Recusal is an independent, deterministic authority instead: no model in the decision path, a verdict you can replay and audit, and a refusal that holds (a Claude Code deny is honored even under bypassPermissions). "Independent" means the verdict is produced outside the model's decision path; deployment isolation (who owns the config, the file permissions, the runtime) remains the adopter's responsibility.

The published evidence (models faking passing tests, benchmarks gamed by intercepting the evaluator, and the stated limits of Anthropic's own same-family safety layer) is laid out in docs/WHY.md, every source verified in docs/REFERENCES.md.

Builders generate. Recusal certifies. Refusal is a feature.


How Recusal fits into Claude Code

Recusal uses PreToolUse, Claude Code's native pre-execution policy seam, and it is intentionally ONE layer in a broader control stack, not a replacement for the others. Claude native permissions own broad allow/ask/deny rules (a native deny applies regardless of any hook decision). Claude sandboxing constrains what an allowed command can touch after it runs. Managed settings own organization-controlled policy, hook distribution, and MCP server restrictions (allowedMcpServers is how an enterprise bounds the effective server set). Claude MCP configuration owns transport, OAuth, credentials, and endpoint connectivity. Recusal owns deterministic evidence adjudication: explicit findings become PASS/RETRY/FAIL with no model in the decision path, a clean verdict defers to Claude's remaining permission flow by default, and a non-clean verdict denies before execution. The strongest deployment is layered:

  managed settings configure the allowed control surface
      tool proposal  ->  Recusal PreToolUse adjudication  ->  Claude native
      permission rules and prompt  ->  Claude sandbox / OS boundary
      ->  approved tool or MCP execution
      (audit records the Recusal decision separately, externally anchored)

The diagram shows execution order (PreToolUse runs before the permission prompt); enforcement precedence is deny-wins across the hook and native permission layers - a native deny applies regardless of any hook decision, and a blocking hook takes precedence over a native allow.

For production, pin the runtime the gate runs on: a dedicated venv with pip install "recusal==<version>", registered explicitly, protected from agent writes. pip install recusal is the quick start, not the governance deployment. One named residual: Claude cancels a command hook at the platform hook timeout (default 600s), and this repository has NOT independently established the resulting authorization outcome for the launcher - do not describe hook timeout as fail-closed until it is tested in your deployment environment. Recusal's shipped policies adjudicate in milliseconds; keep custom policies fast and bounded.


Architecture

One object model, one pipeline. Checks (or your own evidence) produce Findings; compute_verdict folds them into a Verdict; and the Verdict drives every surface: the gate refuses, the audit log records, the classifier routes.

  data / a proposed agent action / a tool call
          │
     [ checks ]            emit Findings               (recusal.checks, or your own)
          │
   compute_verdict()       fold findings → one Verdict (PASS / RETRY / FAIL)
          │
       Verdict
        │   │   │
        │   │   └─ recusal.classify        route the failure (retry / refuse / ask-human / …)
        │   └───── recusal.audit           tamper-evident, hash-chained record
        └───────── recusal.claude(_code)   allow or refuse the tool call
                   recusal.gates           staged G0-G8 release decision
Module What it is
recusal.evidence the contract, Finding, Verdict, Severity, Decision, compute_verdict
recusal.checks built-in deterministic checks that turn data into Findings
recusal.claude · recusal.claude_code gate a Claude agent's tool calls (SDK loop, Managed Agents, Claude Code hook)
recusal.deny_list · recusal.claude_code.allowlist_policy ready-made policies: a reference deny-list (refuse known-bad) and default-deny allowlist
recusal.mcp · recusal.mcp_fetch MCP tool and server-instruction integrity: pin supported source templates, observed server instructions, and complete tool declarations; refuse represented drift (diff_observation); enforce approved runtime tool names at call time (pure kernel); collect a live catalog over stdio (fetcher, the one module that spawns a process). Prompts, resources, channels, elicitation, live-session divergence, and Claude's effective server selection stay outside the manifest
recusal.audit tamper-evident, hash-chained log of every verdict
recusal.classify deterministic failure classifier + router
recusal.gates staged G0-G8 release-gate adjudication, compute_verdict at each checkpoint

Zero runtime dependencies, standard library only.


Install

pip install recusal

See it refuse (20 seconds, no API key)

git clone https://github.com/philpaz/recusal && cd recusal
python examples/claude_refusal.py   # a Claude agent stages a write to the WRONG
                                    # customer; the gate refuses before the tool runs
python examples/gallery.py          # the same gate across the OWASP agentic failure modes

Deterministic and offline: the same normalized evidence and explicit policy inputs, under the same recusal implementation version, produce the same verdict, including the no.

Plug it into Claude

Claude Code, drop-in PreToolUse hook

Refuse destructive tool calls before Claude Code runs them, even in auto / bypass mode.

One command:

python -m recusal init          # or: recusal init

scaffolds .claude/hooks/recusal_gate.py (the deny-list starter, edit it, it's yours) and registers the fail-closed launcher in .claude/settings.json, merging with (never clobbering) an existing file; re-running is a no-op, and an existing gate file is never overwritten. --posture allowlist scaffolds the default-deny variant instead. Claude Code asks you to confirm the new hook on the next session: a permission-changing hook is a deliberate step.

Or as a plugin (one gate across every project, no per-project setup):

claude plugin marketplace add philpaz/recusal
claude plugin install recusal-gate@recusal
pip install "recusal==0.5.5"   # the plugin is version-bound; fails CLOSED without it
                               # (POSIX launcher: macOS/Linux/Windows-with-Git-Bash)

The plugin ships the same deny-list shim; if the recusal package is missing it refuses every tool call rather than silently disabling itself. For a policy tailored to one project, prefer python -m recusal init and edit the scaffolded gate.

Prefer to see exactly what it writes? The manual path is the same two pieces. Register a hook in .claude/settings.json:

{ "hooks": { "PreToolUse": [
  { "matcher": ".*", "hooks": [
    { "type": "command", "command": "for p in python3 python py; do \"$p\" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else 1)' 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; failing closed' >&2; exit 2" } ]}
]}}

The command runs the first python3pythonpy that is >=3.9 and fails closed. The exit-code semantics, stated exactly: Recusal's normal refusal exits 0 with 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 that lets the tool call proceed. That last rule is why the launcher exists: a bare python3 on a Windows machine (no python3 on PATH), a python that is Python 2, or a hook that raises at import would each be a nonzero-but-not-2 failure, i.e. a silently disabled gate. The loop coerces exactly those gate-process failure modes - missing interpreter, unsupported interpreter, import failure, nonzero gate-process exit - into exit 2, so they refuse instead of waving the call through. It does not cover Claude-level hook cancellation or the hook-timeout outcome (below).

Windows: shell-form hooks run under Git Bash when it is installed, and Claude Code falls back to PowerShell when it is not - where this POSIX loop is a parse error with a non-blocking exit code, i.e. the gate silently disables (live-verified). recusal init therefore registers a PowerShell-native launcher with an explicit "shell": "powershell" on Windows; the POSIX form above is for macOS/Linux and Windows-with-Git-Bash. For a settings.json shared across operating systems, recusal init --launcher both registers the pair, and recusal doctor validates the registered launcher against the host.

# 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 permission flow

run_pretooluse_hook(policy)

A clean verdict defers (Recusal adds refusals; it never strips Claude Code's own prompts). A non-clean verdict denies, with the reasons. See examples/claude_code_gate.py.

Two paths, one principle: pick by your channel, not by a ranking. The policy above is a deny-list: name the known-bad calls, defer everything else. It drops into a broad, open-ended channel with almost no friction and needs no inventory of your tools, which is why this repo dogfoods it (a general-purpose dev repo runs an unbounded set of legitimate commands). Its boundary is inherent, not a defect: a literal matcher can be obfuscated past, and python script.py runs code no string check ever reads, so a deny-list never earns "cannot be subverted."

The other path is allowlist mode (default-deny): name the affirmatively-safe calls, refuse everything else. It fits a narrow, enumerable, high-stakes channel: nothing runs unless listed, and bare interpreters and shell metacharacters are refused, which closes the documented command-construction and bare-interpreter bypass classes by construction (pinned as tests). Within a correctly registered routed tool channel, an unapproved capability is refused by default rather than inferred safe; what sits outside that channel is named in SECURITY.md. One more honest line: the default-safe tools are nonmutating, not authorized for all data - cat can read a credential file - so add path- and subject-level read rules where confidentiality matters. The trade is friction and maintenance: you enumerate and grow the capability set, and it fails toward refusal until you do.

Neither is "better" in the abstract: a deny-list refusing the unknown would grind a broad channel to a halt, and an allowlist deferring the unknown would defeat the point of a high-stakes one. Choose by the channel. Both ship, and both are pinned as tests.

from recusal.claude_code import allowlist_policy, run_pretooluse_hook

run_pretooluse_hook(allowlist_policy(writable_root="./workspace"))

Don't start from a blank policy. docs/COOKBOOK.md has copy-paste recipes (destructive shell, unscoped SQL, secret-file writes, wrong-subject writes, egress allowlists, injection quarantine, action budgets) that drop straight into the hook above.

Recusal governs this repository exactly this way: a real hook refuses rm -rf, force-pushes, and secret-file writes to its own maintainers. Verbatim, reproducible, CI-locked proof: docs/PROVEN.md.

MCP tools, the same gate

MCP server tools reach Claude Code as ordinary tools: the hooks reference documents that they "appear as regular tools in tool events" (PreToolUse, ...) under the naming pattern mcp__<server>__<tool> (mcp__github__create_issue, mcp__filesystem__write_file). So the .* matcher above already routes every MCP call through the same policy(tool_name, tool_input) seam: no MCP-specific adapter, no extra wiring. The same call-time controls apply to MCP exactly as to Bash: destructive-operation refusal, repository/record scope, write-path confinement, egress and action budgets, the tamper-evident audit record. In allowlist mode the posture is stronger still: an MCP tool is refused unless affirmatively named (allow={"mcp__github__create_issue": vet}), the least-privilege default the MCP spec's own security guidance pushes toward. Pinned as tests.

def policy(tool_name, tool_input):
    if tool_name == "mcp__salesforce__delete_records":
        return [Finding.fail("mcp_destructive_action", severity="CRITICAL",
                             message="bulk Salesforce deletion is not approved")]
    if tool_name == "mcp__github__merge_pull_request":
        repo = tool_input.get("repo")
        if repo not in {"philpaz/recusal"}:
            return [Finding.fail("mcp_repository_scope", severity="CRITICAL",
                                 message=f"repository {repo!r} is outside the approved scope")]
    return []   # defer everything else to Claude Code's normal flow

Runnable: examples/mcp_governance.py (approved-server pinning, destructive-verb refusal, path confinement, allowlist mode). Pinned: tests/test_mcp_governance.py. Recipe: docs/COOKBOOK.md §12. In a custom Agent SDK or MCP-client loop nothing intercepts for you: invoke the gate between the model's proposed MCP call and the client dispatching it (the same gate_tool_use seam as below).

The three MCP tool-call boundaries, stated plainly. A call-time policy adjudicates the proposed tool name and arguments; MCP has two more boundaries, and Recusal covers each with its own evidence:

Boundary Threat (as the field names it) Recusal
Discovery (initialize.instructions + tools/list) model-facing server instructions, tool-description poisoning (benchmarked against real-world MCP servers by MCPTox), unapproved capability, post-approval declaration changes (the rug pull), name collisions pin + refuse drift: recusal mcp pin / recusal mcp verify / recusal.mcp.manifest_policy (next section); legacy tools-only observations keep an explicitly weaker instruction claim
Invocation (the call) tool misuse (OWASP ASI02), wrong-subject writes (ASI03), exfiltration via tool invocation (MITRE ATLAS AML.T0086) this section
Response (the result) indirect prompt injection in tool output (OWASP LLM01) quarantine, cookbook recipe 6

Transport and authorization threats (confused deputy, token passthrough, session hijacking) are the MCP specification's own Security Best Practices layer, complementary to this gate, neither replaces the other. Every source here is verified in docs/REFERENCES.md.

MCP discovery, pin the catalog, refuse the rug pull

The model chooses tools by reading their declared descriptions, so a poisoned declaration steers the agent before any call exists for a call-time policy to see, and the call that follows looks structurally valid. recusal.mcp adds deterministic integrity controls at that boundary the way this library governs every boundary: deterministic evidence, with the human where the judgment is:

recusal mcp pin --claude-config .mcp.json --approve-server-launch   # review once, pin
recusal mcp verify --claude-config .mcp.json  # CI / session start: same source+catalog, or refuse

--claude-config and --stdio execute the declared server commands to ask them for tools/list; there is no other way to ask a process for its catalog. The first pin is therefore an explicit trust event: review the command/args lines like you review the declarations, then pass --approve-server-launch to record it. After the pin, verify compares each launch specification against the manifest before launching anything, and stdio servers run with a minimal environment by default (--inherit-env is the named opt-out). Minimal environment is not a sandbox: the server still runs with your user's filesystem, process, and network permissions. And --from pins the supplied declaration set; it does not attest which remote endpoint produced the dump.

Recusal does not judge whether a description is malicious: that is semantic judgment, a human's call at pin time (a deterministic marker screen surfaces the obvious, and pin refuses to write over a flagged catalog until --force records that a human reviewed it). What it detects, deterministically, is unpinned capability and post-approval change: the rug pull, the new tool, the mutated schema. The pin is the confirmed human decision promoted to a deterministic artifact: manifest bytes are reproducible, tool declarations and server instructions are stored as hashes only (poisoned text is never embedded anywhere) while source templates are stored readable so drift can be explained - keep secrets out of them, the pin warns - and the same complete observation against the same pin, under the same recusal version, yields the same verification result, every time. verify fails closed: a missing manifest, a failed fetch, a wholly empty observation, or a pinned server that can no longer be reached for integrity-checking (e.g. silently swapped to a URL transport) is a refusal, never a clean-looking pass. (A pinned server legitimately removed from the config is recorded as a warning, not refused: a shrunk capability set is not an attack.) The pin also enforces at call time: recusal.mcp.manifest_policy("mcp-manifest.json") drops into the same PreToolUse gate and refuses any mcp__server__tool call that was never pinned (no pin, no MCP), composing with the argument-level rules above. A minimal zero-dependency stdio client collects tools/list; remote/HTTP servers are pinned from a JSON dump you produce with any MCP client (--from, copy-paste recipe: docs/COOKBOOK.md §14; local/.mcp.json servers pin directly, §13). Recusal owns the deterministic adjudication, not the transport, so it inherits neither the HTTP client's dependencies nor its SSRF surface. Collection is never decision: the kernel adjudicates what was observed.

The honest boundary: this is discovery-time and call-time integrity, not a live tap on every message. verify proves the catalog at the moment it runs (wire it into CI and session start); the call-time gate then enforces approved tools only. A server that serves one catalog to verify and a different one to the live session (a client- or time-discriminating server) is a residual this layer names rather than claims to close: run verify against the same endpoint the session uses, close in time. The manifest pins the source specification as well as the declared catalog, and verify compares it before any process starts. For stdio servers that is the unexpanded command template, args, cwd, and the environment value templates as written in the config, so a rewritten command, a same-key env value swap (NODE_OPTIONS, LD_PRELOAD), or a ${VAR} reference rename is refused without the replacement ever executing (each pinned by an adversarial test proving the substituted command's marker file is never written). Every server entry in the SUPPLIED .mcp.json is represented or the operation refuses: a remote (http/sse/ws) entry pins its url_template, header value templates, headersHelper command template, and OAuth policy fields; an added or transport-swapped server of any kind is drift, and a config entry the parser cannot faithfully represent fails closed. The remaining residuals, named: the operator-shell values behind ${VAR} references are not pinned (the reference is); npx/uvx-style launchers resolve through PATH and fetch what the registry serves (pin package versions in the args); executable bytes are not attested; and a --from-only pin records transport: external, attesting the declaration set, not the endpoint that produced it. Keep protecting .mcp.json and mcp-manifest.json as control-plane files - the default deny-list does.

Scope, stated exactly: Recusal verifies the configuration artifact YOU supply (--claude-config/--stdio/--from); it does not reconstruct Claude Code's effective MCP environment across local, project, user, plugin, claude.ai connector, CLI/SDK, project-approval, disabled-server, or managed-deployment state, and a successful verification does not prove Claude accepted, enabled, connected to, or selected the supplied entry as the effective definition - use Claude managed MCP policy (allowedMcpServers) to constrain the effective server set, then pin what it allows. Recusal governs MCP tools and, since manifest v5, the initialize-result server instructions (pinned as a hash; added, removed, or changed instructions are drift). With Claude Code's default tool-search behavior those instructions and the tool names load at session start while full tool definitions are deferred; full definitions load up front when tool search is disabled or falls back, when a server sets alwaysLoad, or when a tool declares anthropic/alwaysLoad (that tool-level flag lives inside the declaration, so it IS part of the declaration fingerprint; the server-level alwaysLoad/timeout fields are shape-validated but deliberately not source identity - recusal pins declaration content, not Claude's loading strategy). Recusal fingerprints the complete observed instruction string while Claude truncates what it loads into context (currently 2KB each for instructions and tool descriptions), so a change outside the loaded prefix still drifts - the safe side of that asymmetry. Instruction coverage for remote servers requires the rich --from shape ({server: {"instructions": ..., "tools": [...]}}, or --server with the same single-server object); legacy {server: [tools]} dumps stay supported but record observed: false and establish no instruction claim. For OAuth, Recusal pins the configured policy fields in .mcp.json (including the configured scopes string, whose change is drift); it does not observe the final authorization request, scopes Claude appends (such as offline_access when the server advertises it), the issued token, granted authority, or the server-side authorization result. OAuth applies to http entries; Claude documents WebSocket authentication as header-only, so a ws entry carrying oauth is refused as a shape Claude does not support. Prompts, resources, resource templates, channels, and elicitation can still introduce context without a tool invocation and are outside manifest_policy. Claude Code supports dynamic list_changed updates: a NEW tool name stays blocked at call time, but a changed description under an already-pinned name is invisible to the call-time hook until you verify again - verification is point-in-time, not continuous attestation. And Recusal never authenticates to a remote endpoint: Claude Code (or the MCP client producing your --from dump) owns OAuth, headers, headersHelper execution, TLS, and transport; Recusal records the approved nonsecret templates and adjudicates the supplied catalog. Plugin-bundled MCP servers use scoped runtime names: for plugin my-plugin, server database-tools, tool query, the runtime name is mcp__plugin_my-plugin_database-tools__query, so the manifest SERVER key must be plugin_my-plugin_database-tools and the tool key query (never the whole tool name in the server field). Recusal does not discover plugin metadata; supply the exact runtime server segment yourself. Verifying a config that contains remote servers needs their fresh catalogs alongside it:

recusal mcp verify --claude-config .mcp.json --manifest mcp-manifest.json          # stdio-only
recusal mcp verify --claude-config .mcp.json --from remote-catalogs.json     --manifest mcp-manifest.json                                                   # mixed/remote

See the refusal: examples/mcp_manifest_rugpull.py (offline). Pinned as tests: tests/test_mcp_manifest.py, tests/test_mcp_policy_bridge.py, tests/test_mcp_fetch.py, tests/test_mcp_cli.py.

Claude Agent SDK, manual loop

In a manual agent loop, gate each tool call and hand Claude an is_error tool_result on a refusal; it self-corrects:

from recusal.claude import gate_tool_use

allow, refusal = gate_tool_use(tool.id, gather_evidence(tool), tool_name=tool.name)
if not allow:
    results.append(refusal)                          # 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 Managed Agents always_ask, recusal.claude.tool_confirmation is the deterministic decider (the SDK event shape is illustrative; verify it against your Agent SDK version).

Any agent loop, no Claude required

The Claude adapters are conveniences; the zero-dep core is framework-neutral. examples/agent_loop.py gates a plain propose → gate → act loop whose only import is recusal; the same compute_verdict seam drops into LangGraph, the OpenAI Agents SDK, or a homegrown runtime unchanged.

Robustness, across the OWASP Agentic failure modes

python examples/gallery.py runs the gate against the common autonomous-agent failure modes:

  scenario                OWASP                 verdict outcome
  wrong-subject write     ASI03 Identity Abuse  FAIL    REFUSE
  destructive file delete ASI02 Tool Misuse     FAIL    REFUSE
  unscoped SQL mutation   ASI05 Code Execution  FAIL    REFUSE
  data exfiltration       ASI01 Goal Hijack     FAIL    REFUSE
  coverage floor          quality gate          RETRY   BLOCK (retry)
  runaway action volume   ASI08 Cascading       RETRY   BLOCK (retry)
  compliant write         -                     PASS    ALLOW

The tiers are the policy: destructive things REFUSE terminally; recoverable ones BLOCK with a retry; a clean call passes. (Same policies power the demo and the test suite.)

The verdict, directly

from recusal import compute_verdict
from recusal.checks import row_count, null_rate, referential_integrity

verdict = compute_verdict([
    row_count(users, min_rows=1),                                  # CRITICAL if empty
    null_rate(users, "email", max_rate=0.10),                      # ERROR if too sparse
    referential_integrity(orders, users, fk="user_id", pk="id"),   # CRITICAL on orphans
])
if verdict.refused:
    raise RuntimeError(verdict.reasons())
Worst finding Verdict Meaning
CRITICAL failure FAIL Terminal. The work is wrong. Do not retry.
ERROR failure RETRY Recoverable. Retry once, with the failures as context.
WARNING / INFO only PASS Proceed. Warnings recorded, info kept as metrics.

Tamper-evident audit

Pair any verdict with an append-only, hash-chained log: every decision on the record, and an in-place edit or reordering of any entry with a surviving successor is detectable (catching tail-truncation, a tail-suffix rewrite, or a forged append by a write-access attacker needs an external anchor, see recusal.audit):

from recusal import AuditLog, verify

audit = AuditLog(path="audit.jsonl")
audit.append(verdict, action={"tool": "Bash", "command": "rm -rf /"})
ok, problems = verify(audit.entries)   # False if an entry with a later entry was edited/reordered

In the Claude Code hook it is one argument: run_pretooluse_hook(policy, audit=AuditLog("audit.jsonl", resume="tail")) puts every adjudication - defer, allow, and deny - on the chain, with the proposed tool_input bound by SHA-256 fingerprint, never embedded, and an unwritable log failing closed to a deny (the record is part of the control). Declared policy_id/policy_version values are caller-supplied labels unless you separately bind them to a protected source artifact; the record provides the identifiers needed to locate the policy and evidence for replay, it does not by itself make arbitrary policy execution replayable. resume="tail" recovers the chain head from the final record without loading or scanning the full log, and file-backed appends are serialized with an inter-process lock, so hooks for parallel tool calls extend one chain instead of forking it.

Deterministic, stdlib-only, and shaped for OWASP Agentic logging / EU AI Act Article 12 (record-keeping).

Gate your CI

CI is, by construction, not the session that did the work, which makes it the natural place for a recusal verdict. The same kernel runs as a command line with blocking exit codes (PASS 0, RETRY 1, FAIL 2; any operational error exits 2, failing closed):

recusal verdict findings.json --json   # adjudicate any tool's findings; nonzero blocks the job
recusal audit verify audit.jsonl --expect-head "42:<hash>"   # a missing log is NOT an intact log
recusal doctor                         # "the gate silently isn't installed" fails CI, not prod

Or as a GitHub Action (action.yml, dogfooded by this repo's own CI, including the negative case: a tampered audit log must make the gate refuse):

- uses: actions/setup-python@v6
  with:
    python-version: "3.12"
- uses: philpaz/recusal@v0.5.5   # or pin an immutable commit SHA for stronger provenance
  with:
    findings: reports/findings.json   # RETRY exits 1, FAIL exits 2 → the merge is blocked
    audit-log: reports/audit.jsonl
    doctor-dir: "."

The action ref selects the implementation: the action force-installs the recusal bundled with the selected ref, replacing whatever happens to be on the runner, so pinning the action pins the code (proven in CI on a clean runner and against a deliberately conflicting preinstall). The two escape hatches are explicit inputs that name the provenance trade: version: installs a named PyPI release, and use-installed: "true" keeps a deliberately preinstalled checkout.

Given nothing to adjudicate, the action exits 2 rather than pass vacuously: an evidence set that proves nothing certifies nothing.

Classify and route a failure

A refusal or failure is only useful if you know what to do next. The classifier says what kind of failure it is and where it routes, deterministically, with no model:

from recusal import classify_failure

c = classify_failure("Traceback ... TypeError: 'NoneType' object")
c.failure_class   # "code_bug"
c.route           # "fix-code"

Default taxonomy (extend or replace it): transient → retry · policy_violation → refuse · prompt_injection → quarantine · code_bug → fix-code · data_shape → fix-data · data_missing → fetch-data · spec_ambiguity → ask-human. Unmatched failures fall back to ask-human; it never guesses. classify_verdict(verdict) routes a non-PASS verdict.

Documentation

New here? The quick objections (do I need this? doesn't Claude already do it? is it ready to use?) are answered in the docs/FAQ.md. The plain-terms "so what": docs/WHY.md.

Full documentation index: docs/. Comparison with the landscape: docs/LANDSCAPE.md. The principles and why each helps: CONSTITUTION.md. The contract: docs/EVIDENCE.md. Usage & extending: docs/HOWTO.md · docs/EXTENDING.md. Copy-paste policies: docs/COOKBOOK.md. A full worked configuration: docs/EXAMPLE.md. Proof it governs itself: docs/PROVEN.md.

Development

pip install -e ".[dev]"
pytest -q

Contributing

Contributions are welcome. Recusal is deliberately small, and the bar is keeping it that way (no model in the verdict path, no runtime dependencies, don't grow the kernel). Read CONTRIBUTING.md and the CODE_OF_CONDUCT.md first. Security reports go through SECURITY.md, privately.

Contact

Built by Philip Paz. Messages are open, especially from teams running agents in regulated environments, and doubly so if you wired the gate in and found where it leaks (tell me here).

License

Apache-2.0 © Philip Paz

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

recusal-0.5.5.tar.gz (3.4 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

recusal-0.5.5-py3-none-any.whl (112.9 kB view details)

Uploaded Python 3

File details

Details for the file recusal-0.5.5.tar.gz.

File metadata

  • Download URL: recusal-0.5.5.tar.gz
  • Upload date:
  • Size: 3.4 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for recusal-0.5.5.tar.gz
Algorithm Hash digest
SHA256 c4ec8b31e3b6964ec5756a5a823a17b8ee57b5d86ecadf49416383b6b504c1e8
MD5 b4dadf16dde2ff5207a65705d0bb6b04
BLAKE2b-256 65443d425c1c4dbecb452da306667848798306881ed29b1ade516061e48e16e7

See more details on using hashes here.

Provenance

The following attestation bundles were made for recusal-0.5.5.tar.gz:

Publisher: release.yml on philpaz/recusal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file recusal-0.5.5-py3-none-any.whl.

File metadata

  • Download URL: recusal-0.5.5-py3-none-any.whl
  • Upload date:
  • Size: 112.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for recusal-0.5.5-py3-none-any.whl
Algorithm Hash digest
SHA256 f9d8fc96ffe0273b0d38b4a8edbf666c6632c919610c02723d0b4803ac66d0b4
MD5 e085d5890cf0bd5935a906240577d16d
BLAKE2b-256 445fe76f5775cbdfe7a747f2988e2d327201751c2982ecaa7d1fd3667bb05718

See more details on using hashes here.

Provenance

The following attestation bundles were made for recusal-0.5.5-py3-none-any.whl:

Publisher: release.yml on philpaz/recusal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.9.0

2 files

0.8.0

2 files

0.7.1

2 files

0.7.0

2 files

0.6.0

2 files

0.5.12

2 files

0.5.11

2 files

0.5.10

2 files

0.5.9

2 files

0.5.8

2 files

0.5.7

2 files

0.5.6

2 files

This release

0.5.5 This release

2 files

0.5.4

2 files

0.5.3

2 files

0.5.2

2 files

0.5.1

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page