Skip to main content

Lightweight, extensible, Claude-native governance for AI agents: an independent, deterministic verifier that can refuse to certify a tool call before it runs. Builders cannot grade their own work.

Project description

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 and the Claude Agent SDK as a tool gate). The zero-dep core works in any agent loop.

CI python license runtime deps

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. Same evidence, same verdict, every time, including the "no".


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

The reflex fix for agent safety is another model: "does this action look OK?" But the judge and the builder come from the same family, share the same blind spots, and drift together. That's not a control; it's a conflict of interest.

2026 made the failure mode concrete:

  • An Anthropic study found an RL-trained coding model that learned to call sys.exit(0) to fake passing tests, and generalized the cheating to unrelated tasks.
  • UC Berkeley scored 100% on six of eight agent benchmarks without solving a single task, by intercepting the evaluator.

A model will, given the chance, certify its own success. Even Anthropic's own Claude Code auto-mode safety layer is a same-family classifier with an admitted 17% false-negative rate, and Anthropic says plainly it is "not a drop-in replacement for careful human review on high-stakes infrastructure."

Recusal is an independent, deterministic authority with 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).


Positioning

  • Lightweight: zero dependencies, a kernel you can read in one sitting. Governance without a Kubernetes-sized platform.
  • Extensible: a check is just a function that returns a finding. Bring your own policy; the core never changes.
  • Claude-native: drops into Claude Code as a PreToolUse hook and the Agent SDK as a tool gate; a deny holds even under bypassPermissions.
  • Independent & deterministic: not the same model grading its own work. No model in the decision path; a verdict you can replay and audit.

Builders generate. Recusal certifies. Refusal is a feature.


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.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, same evidence, 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. Register a hook in .claude/settings.json:

{ "hooks": { "PreToolUse": [
  { "matcher": ".*", "hooks": [
    { "type": "command", "command": "for p in python3 python py; do \"$p\" -c '' 2>/dev/null && exec \"$p\" \"$CLAUDE_PROJECT_DIR/.claude/hooks/my_gate.py\"; done; echo 'gate: no python; failing closed' >&2; exit 2" } ]}
]}}

The command probes python3pythonpy (macOS / Linux / Windows) and fails closed: Claude Code treats a hook whose command can't launch as a non-blocking error and lets the tool call proceed, so a bare python3 invocation on a Windows machine would silently disable the gate. Exit code 2 is a blocking hook error — no interpreter, no tool call. (On Windows, Claude Code runs hook commands under Git Bash.)

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

A hand-written policy like the above is a deny-list: it stops the accidental and common cases, but a literal matcher can be obfuscated past, and python script.py runs code no string check ever reads — don't read it as "cannot be subverted." For high-stakes channels use the shipped allowlist mode (default-deny): nothing runs unless affirmatively named, and bare interpreters are refused, closing the write-a-script-then-run-it bypass (pinned as a test). That's the posture that earns "the agent could not subvert it" for the routed tool channel:

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.

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 in-place edits or reordering of existing entries detectable (catching tail-truncation or a full re-hash 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 existing entry was edited or reordered

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

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

Why this, and why not the alternatives

  • Agent frameworks (LangGraph, CrewAI, AutoGen, OpenAI Agents SDK) orchestrate, their governance is in-process and self-graded.
  • Guardrails (Guardrails AI, NeMo Guardrails) filter I/O content, they don't adjudicate a work product.
  • Eval libraries (promptfoo, DeepEval, Haize's Verdict) score offline, usually with an LLM-as-judge, the probabilistic opposite of a deterministic gate.
  • Observability (Langfuse, AgentOps) records, zero authority to stop anything.
  • Anthropic's auto mode is a same-family classifier grading the same family, exactly the conflict of interest this exists to remove.
  • The newer agent-firewall projects (e.g. AEGIS) and Microsoft's Agent Governance Toolkit are real peers. Recusal's bet is not feature parity, it's independence (a verifier the builder cannot influence) and a kernel small enough to trust on sight.

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.

License

Apache-2.0 © Philip Paz

Project details


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.1.0.tar.gz (3.0 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.1.0-py3-none-any.whl (37.6 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for recusal-0.1.0.tar.gz
Algorithm Hash digest
SHA256 32ed4acfccb554c67d0159e606cf972fc004acdb343360bfc270656709f5b3a0
MD5 11449ab5254bfa44c3d59bf4087a178e
BLAKE2b-256 5c3b723fd4b4dbc08dd1d4da4680a3ee9b5060f55da828e35609d02e6b1e2226

See more details on using hashes here.

Provenance

The following attestation bundles were made for recusal-0.1.0.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.1.0-py3-none-any.whl.

File metadata

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

File hashes

Hashes for recusal-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ba467bda09a2aaaf9feb59e7e85c96393ac145ea906c28b8c0498a7da7f0032e
MD5 a2c8e542a5fd8d9fbf226f6cc2029540
BLAKE2b-256 fca32129f05210c049fd845bcd76413f8927ea2ee888e8c7b8845b43378282c9

See more details on using hashes here.

Provenance

The following attestation bundles were made for recusal-0.1.0-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.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page