Metadata-Version: 2.4
Name: reeflex-claude
Version: 0.2.0
Summary: Reeflex adapter for Claude Code: a PreToolUse hook that gates every tool call on its impact before it runs (allow/hold/deny), fail-closed -- governance that protects you from your own agent.
License-Expression: Apache-2.0
Project-URL: Homepage, https://reeflex.io
Project-URL: Source, https://github.com/Reeflex-io/reeflex
Project-URL: Issues, https://github.com/Reeflex-io/reeflex/issues
Project-URL: Documentation, https://github.com/Reeflex-io/reeflex/tree/main/reeflex-claude
Project-URL: Changelog, https://github.com/Reeflex-io/reeflex/blob/main/CHANGELOG.md
Keywords: reeflex,governance,claude-code,ai-agent-safety,pretooluse-hook
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Requires-Python: >=3.8
Description-Content-Type: text/markdown

# reeflex-claude

Reference adapter: the Claude Code PreToolUse hook — [Reeflex](https://reeflex.io) 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:

1. **INTERCEPT** — receives the tool call JSON on stdin (before execution).
2. **NORMALIZE** — maps the tool call to a signed Action Envelope (SPEC §2):
   verb, three risk axes (reversibility / blast_radius / externality), tier.
3. **ENFORCE** — POSTs the envelope to `reeflex-core /v1/decide`; maps the
   Decision to Claude Code's `permissionDecision` (allow | deny | ask).
4. **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).

```mermaid
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:

```bash
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`](../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_TOKEN`
  yourself first — `connect` reads 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/decide` round
  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).

```bash
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:**

```bash
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:

```bash
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`](examples/settings.sample.json)) using the
**absolute path** to `hook_entry.py` so it works regardless of the working
directory Claude Code runs from:

```json
{
  "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_claude` you get from
> `python -m reeflex_claude` when the working directory is not
> `reeflex-claude/`) is treated as a probe failure, and Claude Code
> **runs the tool anyway** — the gate is silently bypassed. `hook_entry.py`
> is written to always exit `0` and print an explicit `allow`/`deny`/`ask`
> decision, 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-installed `reeflex-claude check` command 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):

```bash
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):

```json
{"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):

```bash
# 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.**

```bash
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:

```bash
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

```bash
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

```bash
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:

```bash
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`/`xargs` are peeled off, every resulting
  command is classified on its own, and the most dangerous one is reported.
  A line is a `read` only 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 outbound `emit` it 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_radius` is a *cardinality* axis, so
  R2's `broad` requirement never reached `rm /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 to `broad`, and a block device (`/dev/sdb`, excluding `/dev/null` and
  friends) to `systemic`.  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 a `systemic` reading.  So
  `rm /srv/prod/db.sqlite` reaches a human while `rm /tmp/scratch.txt` and
  `rm /srv/app/notes.txt` still 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 those `broad` without 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 in
  `reeflex-core/policy/protected.rego` is 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 to `protected_assets`, or set
  `default_protected := true` to 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.sqlite` and
  `dd if=/dev/zero of=/srv/prod/db.sqlite` used to be classified `execute` +
  `recoverable`, and R6 requires `irreversible` — 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 now
  `delete` + `irreversible` + `broad`.  Measured through the shipped policy
  pack, same envelope both ways:

  | adapter axes | decision |
  |---|---|
  | `execute` + `recoverable` (before RFX-144) | `allow` / `reeflex.policy/default_allow` |
  | `delete` + `irreversible` (after RFX-144) | `require_approval` / `reeflex.policy/irreversible_protected_asset_prod` |

  Stated 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 `dd`
  as 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 outbound `emit` it 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.  The `ssh` case is different
  and should not be lumped in: the remote command *is* on the line, and a
  classifier that parses `ssh`'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=1` does cover five of the six** —
  measured, not asserted: they are all unrecognised *execute* commands, which
  strict mode prices `irreversible`+`broad`.  The `ssh` case is not one of
  them (it is classified `emit`, 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**, including `pytest`,
  `npm install` and `make build` from `allow` to `ask`, 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 (the `ssh` family 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 = false` at
  interception.  Re-submission with `approval.present = true` after human
  approval is the caller's responsibility (Claude Code surfaces the `ask` dialog;
  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.

```bash
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.
