reeflex-claude
Reference adapter: the Claude Code PreToolUse hook — Reeflex governance that also protects you from your own agent, gating every tool call on its impact before it runs.
What it is: A Reeflex adapter that governs Claude Code tool calls by implementing the four contract responsibilities (SPEC §6): INTERCEPT → NORMALIZE → ENFORCE → AUDIT.
What it is NOT: It does not decide anything. The decision is made deterministically
by reeflex-core (OPA/Rego). Zero LLM anywhere near the decision path.
How it works
Claude Code fires a PreToolUse hook before every tool call. This adapter:
- INTERCEPT — receives the tool call JSON on stdin (before execution).
- NORMALIZE — maps the tool call to a signed Action Envelope (SPEC §2): verb, three risk axes (reversibility / blast_radius / externality), tier.
- ENFORCE — POSTs the envelope to
reeflex-core /v1/decide; maps the Decision to Claude Code'spermissionDecision(allow | deny | ask). - AUDIT — appends one JSONL record per decision to the audit log.
Fail-closed invariant: if core is unreachable for any reason, the hook emits
deny and exits 0. It NEVER exits non-zero (which would make Claude Code
continue the tool anyway — silent allow).
flowchart LR
T["Claude Code tool call<br/>(Bash, Write, Edit, …)"] --> H["PreToolUse hook<br/><i>this adapter</i>"]
H -- "Action Envelope" --> C["reeflex-core<br/>POST /v1/decide"]
C --> D{Decision}
D -- allow --> R["✅ Tool runs"]
D -- ask --> A["✋ Human confirmation dialog"]
D -- deny --> X["⛔ Tool blocked, reason fed to the model"]
style D fill:#f6f8fa,stroke:#57606a
If your Reeflex portal gave you a line (connect)
Skip the three steps below. A Reeflex portal's Connect an agent screen
(/app/onboard) hands you one line:
pip install 'reeflex-claude>=0.2.0' && reeflex-claude connect \
--gate <gate-id> --token <rfx_reg_...> --portal https://app.reeflex.io
It exchanges the registration token for that gate's configuration, writes one
agent config, asks your engine one real question, and reports the verdict back
to the portal — then stops. --agent claude (default) writes the Claude Code
hook; --agent opencode writes an OpenCode plugin; --agent litellm writes a
proxy config fragment. --dry-run prints every path it would write and
exits, exchanging nothing.
Three things worth knowing before you paste it:
- It does not fetch a document and execute it. The whole procedure is
written out in
docs/setup/v1/agent-setup.md, the portal shows that file in full beside the copy button with its SHA-256, and this package pins the same digest. "Fetch a URL and follow whatever it says" is the instruction shape Reeflex exists to gate; we do not issue it. - The token in the line is not your gate token. It is scoped to one gate,
spent by one exchange, and expires in minutes. The exchange returns no
secret. If your engine needs a bearer token, export
REEFLEX_CORE_TOKENyourself first —connectreads it from the environment, says so when it is absent, and never invents one. - What the portal then shows is your agent's report. The
/v1/decideround trip is real; the report of it is not signed evidence. Signed decision records come from the evidence connector, which this does not install.
Install / wire up
1. Install. Requires Python 3.8+ (and a recent pip — upgrade with
python3 -m pip install --upgrade pip if the install fails on an old box).
pip install reeflex-claude
2. Have a reachable core. Either run reeflex-core locally
(docker compose up -d from the repo root gives you http://127.0.0.1:8080)
or point at an existing deployment.
3. Wire the hook and verify it, in two commands:
reeflex-claude setup # writes the fail-closed PreToolUse hook into .claude/settings.json
reeflex-claude check # verifies the deny path: fails closed if core is unreachable
setup targets the current project's ./.claude/settings.json by default
(created, with parent directories, if absent); pass --global to target
~/.claude/settings.json instead. It merges — an existing settings file
keeps every unrelated key; only the reeflex-claude hook PreToolUse entry
and the REEFLEX_* keys in the env block are written or updated. A
corrupt existing settings.json is left untouched and setup exits 1
with an explanation — it never guesses or overwrites.
Run setup in an interactive terminal (TTY) to be prompted for each value
below; in a non-interactive or piped context (CI, scripts, no TTY) it
silently skips the prompts and uses the defaults shown — no error, no hang.
Non-interactive flags (all optional; omitted ones fall back to an interactive prompt when running in a terminal, otherwise to the default shown):
| Flag | Default | Meaning |
|---|---|---|
--core-url |
http://127.0.0.1:8080 |
reeflex-core endpoint |
--token |
none | optional bearer token — prefer REEFLEX_CORE_TOKEN via env instead; see the warning setup prints if you use this flag |
--verify-ssl |
true |
false only for dev/self-signed endpoints, at your own risk |
--mode |
enforce (fail-closed) |
observe = fail-open calibration mode (recommended for a first dry run) |
--env |
production |
production | staging | dev |
Expected reeflex-claude setup output (abridged):
[reeflex-claude] Wrote new PreToolUse hook entry in /path/to/.claude/settings.json
[reeflex-claude] matcher: *
[reeflex-claude] command: /path/to/venv/bin/reeflex-claude hook (timeout 30s)
[reeflex-claude] env: {"REEFLEX_CORE_URL": "http://127.0.0.1:8080", "REEFLEX_MODE": "enforce", ...}
[reeflex-claude] Mode: ENFORCE (fail-closed) -- the safe default for a governance gate.
[reeflex-claude] Now run: reeflex-claude check
Expected reeflex-claude check output (PASS):
[reeflex-claude] probing hook command: ['/path/to/venv/Scripts/reeflex-claude', 'hook']
======================================================================
PASS -- fail-closed verified
======================================================================
hook denied the probe and exited 0 (fail-closed verified). stdout=...
[reeflex-claude] settings OK: /path/to/.claude/settings.json contains the reeflex-claude PreToolUse hook.
check forces the probe's own REEFLEX_MODE=enforce and points it at an
unreachable core address regardless of your real configuration — it is
testing the adapter's fail-closed plumbing (installed correctly,
resolvable on PATH, denies when core is unreachable), not your policy
configuration (that is core's job, exercised by demo/run_demo.py or a real
/v1/decide call). Exit code 0 = PASS, 1 = FAIL.
Why the wired command is an absolute path. A PreToolUse hook that cannot
start exits non-zero, and Claude Code then runs the tool anyway — a silent
allow, with no audit record and no message on screen. Earlier releases wired
the bare name reeflex-claude hook, which only moved that risk from the cwd
to the PATH: install into a virtualenv, launch Claude Code from an ordinary
shell, and the hook is command not found. setup therefore writes the
absolute path of the installed entry point, and check verifies the command
that is actually in your settings.json.
Why the matcher is *. Claude Code treats matcher as an allowlist: a
tool whose name does not match never reaches the hook at all. Listing tool
names would silently exempt every mcp__* tool — a database, payments or
cluster MCP server is exactly where an irreversible production action lives —
along with Task, SlashCommand, Skill, and every tool added to Claude
Code after the list was written. A governance gate cannot be enumerated in
advance; unknown tools are classified conservatively instead (see
classify.py). If you deliberately narrow the matcher, check will say so.
4. Restart Claude Code. From the next tool call on, every action passes through the gate.
Development install
For contributors working from a git clone (no pip install), the adapter is plain Python (stdlib only) and lives in this repository:
git clone https://github.com/Reeflex-io/reeflex.git
cd reeflex/reeflex-claude
Wire it into Claude Code's settings.json (a full sample is in
examples/settings.sample.json) using the
absolute path to hook_entry.py so it works regardless of the working
directory Claude Code runs from:
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python /absolute/path/to/reeflex/reeflex-claude/hook_entry.py",
"timeout": 30
}
]
}
]
}
}
(python -m reeflex_claude also works, but only if Claude Code's working
directory is reeflex-claude/ — the absolute-path form is the reliable one.)
⚠ A misconfigured hook fails OPEN. Claude Code only treats a PreToolUse hook's exit code 2 as "block". Any other non-zero exit (for example, the
No module named reeflex_claudeyou get frompython -m reeflex_claudewhen the working directory is notreeflex-claude/) is treated as a probe failure, and Claude Code runs the tool anyway — the gate is silently bypassed.hook_entry.pyis written to always exit0and print an explicitallow/deny/askdecision, so it fails closed instead. After wiring the hook, always run the verify command below before trusting it. (This is exactly the failure class that the pip-installedreeflex-claude checkcommand above structurally eliminates — no absolute paths, no cwd-dependent import.)
Verify the wiring. Run this once, with the same absolute path you put in
settings.json (core does not need to be running — the point of this check
is that a destructive command gets an explicit deny, not a crash):
echo '{"session_id":"verify-1","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | python /ABSOLUTE/PATH/TO/reeflex/reeflex-claude/hook_entry.py
Expected stdout (one JSON line; the exact wording of the connection error varies by OS):
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Reeflex: core unreachable or error -- failing closed: ... [rule=reeflex.core/fail_closed]"}}
Then check the exit code — it MUST be 0 even though the decision is
deny (that is fail-closed working correctly; a non-zero exit here is
exactly the fail-open bug described above):
# POSIX (bash/zsh)
echo $?
# Windows (cmd.exe)
echo %ERRORLEVEL%
# Windows (PowerShell)
$LASTEXITCODE
If you instead ran the unqualified python -m reeflex_claude form from a
directory other than reeflex-claude/, this same check will show the
failure mode directly: No module named reeflex_claude printed to stderr
and a non-zero exit — confirming the fail-open gap this section warns
about.
Set the environment variables (below) and restart Claude Code, or run
reeflex-claude setup from within the git clone (it works the same way —
pip install -e . first, or run it via python -m reeflex_claude.cli setup).
Environment variables
| Variable | Default | Purpose |
|---|---|---|
REEFLEX_CORE_URL |
http://127.0.0.1:8080 |
reeflex-core endpoint |
REEFLEX_CLAUDE_ENVIRONMENT |
production |
target environment (production|staging|dev) |
REEFLEX_CLAUDE_STRICT |
unset | if set truthy: unknown execute → irreversible and broad, so an unrecognised command in production reaches a human. Before RFX-145 it lifted only reversibility, and R2 requires broad, so it could not change any verdict — it changed a word in the audit log. |
REEFLEX_CLAUDE_PRINCIPAL |
null | on_behalf_of value in the envelope |
REEFLEX_CLAUDE_AUDIT_LOG |
<tempdir>/reeflex-claude-audit.jsonl |
adapter-side audit log path |
REEFLEX_CLAUDE_TIMEOUT |
5 |
HTTP timeout to core in seconds |
REEFLEX_VERIFY_SSL |
true (full TLS verification) |
set to 0/false/no/off (case-insensitive) to disable TLS certificate verification on the call to core. Insecure — dev/self-signed endpoints only, at the operator's own risk. Same env name as the WordPress adapter. |
REEFLEX_CORE_TOKEN |
unset | optional bearer token; when set, adds Authorization: Bearer <token> to the /v1/decide request. Never logged. Same env name as the WordPress adapter. |
Setting REEFLEX_CLAUDE_ENVIRONMENT=dev or staging relaxes the base policy
(R2/R3/R6 are production-scoped), letting dev workflows through without approvals.
reeflex-claude setup writes these into your Claude Code settings.json
env block for you (see "Install / wire up" above); the table is the
reference for what each variable does and for the git-clone / manual-export
path.
Trying it against api-dev.reeflex.io
https://api-dev.reeflex.io is a public evaluation endpoint. Use the public
eval token below — no signup needed:
Eval token:
reeflex-eval-public-2026— dev endpoint, rate-limited, may reset anytime; not for production.
reeflex-claude setup --core-url https://api-dev.reeflex.io \
--token reeflex-eval-public-2026 --mode observe
reeflex-claude check
TLS verification stays on (--verify-ssl defaults to true) — no flag is
needed here: api-dev.reeflex.io carries a valid, publicly-trusted
certificate. Starting with --mode observe lets you watch decisions land in
the audit log without the adapter enforcing them, so a policy
misconfiguration or connectivity issue can't block your Claude Code session.
Note that reeflex-claude check always probes with an intentionally
unreachable core address regardless of --core-url — it verifies the
adapter's fail-closed plumbing, not connectivity to api-dev itself; use the
demo or a manual curl .../v1/decide to confirm the endpoint responds.
For the git-clone / manual-export path, the equivalent is:
export REEFLEX_CORE_URL=https://api-dev.reeflex.io
export REEFLEX_CORE_TOKEN=reeflex-eval-public-2026 # public eval token (may reset anytime)
export REEFLEX_MODE=observe # recommended for a first run — see below
Decision mapping
| core decision | permissionDecision | effect |
|---|---|---|
allow |
allow |
tool runs |
deny |
deny |
tool blocked; reason fed to model |
require_approval |
ask |
human confirmation dialog shown |
| core unreachable | deny |
fail-closed; reason explains the error |
Running the demo
set REEFLEX_OPA_BIN=C:\path\to\opa.exe
python reeflex-claude/demo/run_demo.py
Runs 7 scenarios (ls → allow, rm -rf / → deny, force push → ask, fragmentation → ask at budget, fail-closed → deny exit 0).
Running the tests
cd reeflex-claude
python -m unittest discover -s tests -v
No network required for unit tests (classify + envelope are pure; enforce
tests spin a local stub server). test_setup_settings.py and test_cli.py
cover setup's merge semantics (fresh write, merge-preserving, corrupt-JSON
refusal) and check's deny-scenario probe (healthy install, missing binary,
non-zero exit, malformed output, wrong decision) without touching your real
~/.claude or ./.claude directories.
test_conformance_bash.py runs the corpus in reeflex_claude/conformance.py
— commands whose real-world effect was written down before the classifier was
— through the classifier and an offline transcription of R1–R4, and fails if
any irreversible production destruction reaches allow, or if any everyday
command stops reaching it. The live equivalent of the same corpus, through
the real hook against a real core, is:
python3 scripts/attack-probe-rfx144-agent-prices-own-action.py --strict --budget
Its exit code is the number of ground-truth production destructions that were allowed with no human, so CI can gate on it directly. Known residuals are printed with the ticket that tracks them and excluded from the count — a bounded gate that says what it does not cover is honest; one that silently drops cases from its own total reads as "covered everything".
Limits / upgrade paths
-
Bash classification is heuristic — structural matching on each command of the line, not a shell AST. Since RFX-144 the whole line is classified and not its first token: it is split at
&&,||,;,|,&and newline (quote-aware),sh -c '<inner>'is expanded in place,sudo/env/timeout/nohup/xargsare peeled off, every resulting command is classified on its own, and the most dangerous one is reported. A line is areadonly when every command on it is a read. Known gaps today, none of them closed by this design: a command inside a script file (python3 deploy.py,bash deploy.sh) is opaque, and a destruction executed on another host (ssh prod rm -rf /srv/data) is priced as the outboundemitit is rather than the delete it causes. UPGRADE: a real shell-AST parser once tooling stabilises. -
A single named production file: covered two ways now, and the two do not cover the same set (RFX-153).
blast_radiusis a cardinality axis, so R2'sbroadrequirement never reachedrm /srv/prod/db.sqlite— one production database, gone. Adapter side: a path whose name says it is a container of records (.sqlite,.db,.sql,.dump,.tar*,/pgdata/,/mysql/…) is now raised tobroad, and a block device (/dev/sdb, excluding/dev/nulland friends) tosystemic. This is a KIND claim, which SPEC §4.2 permits a name to make, and it is raise-only — the cardinality is still 1, and it can never make an enumerated set smaller or lower asystemicreading. Sorm /srv/prod/db.sqlitereaches a human whilerm /tmp/scratch.txtandrm /srv/app/notes.txtstill allow. What the adapter side does NOT cover: a production file whose name carries no container signal —/home/app/data/store,/srv/prod/state. The adapter cannot price thosebroadwithout claiming a cardinality it cannot observe. Policy side: R6 (SPEC §4.3) closes that remainder — it holds an irreversible production action on a declared production asset at any cardinality, by ref rather than by name shape. Read this before assuming you are covered: the list shipped inreeflex-core/policy/protected.regois a floor derived from the Filesystem Hierarchy Standard (/srv,/var/lib,/var/opt, ...). It knows nothing about/home/app/data, an unconventional database directory, or your bucket names. UPGRADE: add your paths toprotected_assets, or setdefault_protected := trueto hold every irreversible production action whose ref is not declared ephemeral. -
Two destruction shapes needed BOTH halves of this change (RFX-153 + RFX-144, measured).
> /srv/prod/db.sqliteanddd if=/dev/zero of=/srv/prod/db.sqliteused to be classifiedexecute+recoverable, and R6 requiresirreversible— so while the policy pack alone was in place, no policy posture rescued them: a policy rule cannot correct an envelope that under-declares irreversibility. Both are nowdelete+irreversible+broad. Measured through the shipped policy pack, same envelope both ways:adapter axes decision execute+recoverable(before RFX-144)allow/reeflex.policy/default_allowdelete+irreversible(after RFX-144)require_approval/reeflex.policy/irreversible_protected_asset_prodStated this way on purpose: neither change closed this on its own, and each was green in its own test suite for the whole time the gap was open. If only one of the two is present in your build, treat redirection and
ddas ungoverned. -
Stub signing:
meta.signature = "ed25519:stub:...". UPGRADE: Vault-backed ed25519 signing once the key management path is implemented (SPEC §6 note). -
Four families the classifier cannot see at all (RFX-158): the program arrives on stdin (
curl … | sh), the program is in a file (bash deploy.sh,python3 deploy.py), the command word is expanded by the shell ($RM -rf …,$(echo rm) …), or the destruction runs on another host (ssh prod 'rm -rf /srv/data', priced as the outboundemitit is). The destruction is not in the command string, so this classifier does not price it — and for the first three it is not in the command string at all, so nothing that reads only that string would. Thesshcase is different and should not be lumped in: the remote command is on the line, and a classifier that parsesssh's argv can price it (PR #98 did, measured), so that one is unclosed work rather than a limit of the approach.REEFLEX_CLAUDE_STRICT=1does cover five of the six — measured, not asserted: they are all unrecognised execute commands, which strict mode pricesirreversible+broad. Thesshcase is not one of them (it is classifiedemit, not unrecognised). -
REEFLEX_CLAUDE_STRICT: unset by default so coding agents are not blocked on every
npm install. When set, every UNRECOGNISED command is priced irreversible + broad, so in production it reaches a human — measured live on the conformance corpus, it moves 23 of 82 verdicts, includingpytest,npm installandmake buildfromallowtoask, and five of the six RFX-158 gaps above. That is the whole point of the knob: it is the noisy setting, and today it is the broadest lever this adapter ships for the commands the classifier cannot read. It is not the only way to cover them: a policy-side rule, or parsing the wrapper the destruction hides behind, both reach cases strict mode does not (thesshfamily above is one). Before RFX-145 it was neither noisy nor safe — it moved zero verdicts and only changed a word in the audit log. UPGRADE: use a per-command allow-list in policy instead. -
approval re-submission: the hook sets
approval.present = falseat interception. Re-submission withapproval.present = trueafter human approval is the caller's responsibility (Claude Code surfaces theaskdialog; the human clicks allow; Claude Code retries the tool — the adapter then re-intercepts with the same payload, at which point the policy must be configured to allow with approval present).
Observe mode
Observe mode is a dry-run / monitoring mode. Set REEFLEX_MODE=observe in the
hook's environment and the adapter will always emit allow — Claude Code
proceeds with the tool call — while still writing an audit record annotated with
"mode": "observe" and the would-be verdict from core.
export REEFLEX_MODE=observe
Fail-OPEN rule: in observe mode any error (core unreachable, timeout, invalid
response) also results in allow — it never blocks. This is the opposite of
enforce mode's fail-closed invariant, and it is intentional: observe must never
interrupt a Claude Code session.
Use observe mode for dry-run calibration before enabling enforcement: run your
normal Claude Code workflows, inspect the JSONL audit log for deny and
require_approval records, tune policy as needed, then remove REEFLEX_MODE (or
set it to enforce) to activate full fail-closed enforcement.
Metadata
Release files for reeflex-claude 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| reeflex_claude-0.2.0.tar.gz | 146.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| reeflex_claude-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 234.5 kB
Release files / reeflex_claude-0.2.0.tar.gz
| Download URL | reeflex_claude-0.2.0.tar.gz |
|---|---|
| Size | 146.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f06605048f68993e007cc52f1c1b6414f65574e168ebbf7853bc52ada2d183ac
|
|
BLAKE2b-256 checksum How to use checksums |
056416e71a0d55987d2dc28f7b6b07a4f0d13829a8ebbf5435aa36ca2cd32dcf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency logRelease files / reeflex_claude-0.2.0-py3-none-any.whl
| Download URL | reeflex_claude-0.2.0-py3-none-any.whl |
|---|---|
| Size | 88.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7e7a15e2919e25c096414e5a6a9caf679eab131e77b001a6aa66464806cb45b5
|
|
BLAKE2b-256 checksum How to use checksums |
816458ebc069e2ccaf2c13638f661ee3c86b5f73815e0b63506b0edd9a2c8063
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency log