You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: CLAUDE.md
+12-8Lines changed: 12 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -29,7 +29,7 @@ The project targets **Claude Code** as its primary integration on **Linux, macOS
29
29
2.**Event delivery** — The interceptor sends the event to the plugin's embedded broker via Unix domain socket.
30
30
3.**Rule evaluation** — The plugin feeds the event to Falco's rule engine via the source plugin API (`next_batch`). Falco evaluates all loaded rules.
31
31
4.**Alert feedback** — Matching rules generate alerts. Falco delivers them back to the plugin's embedded broker via `http_output` (localhost).
32
-
5.**Verdict resolution** — The broker determines the verdict from rule tags (`deny`, `ask`, or allow-by-default) and responds to the interceptor.
32
+
5.**Verdict resolution** — The broker determines the verdict from rule tags (`deny`, `ask`, or the no-match floor `allow`/`defer` per `default_action`) and responds to the interceptor.
33
33
6.**Verdict delivery** — The interceptor communicates the verdict to the coding agent using the standard hook response format.
34
34
35
35
### Components
@@ -57,11 +57,11 @@ Rule verdicts are encoded in the `tags:` field of Falco rules, not in the `outpu
57
57
58
58
-`tags: [coding_agent_deny]` — block the tool call
59
59
-`tags: [coding_agent_ask]` — require user confirmation
60
-
- No deny/ask tag — allow (no explicit allow tag needed)
60
+
- No deny/ask tag — the **no-rule-match floor** (`default_action`, see "Operational modes"): `allow` (default; Prempti approves) or `defer` (Prempti steps aside)
61
61
62
-
There is no allow tag because the absence of a verdict IS the allow verdict. Rules only fire when their condition matches — a tool call that doesn't match any deny or ask rule simply produces no deny/ask alert, and the broker resolves it as allow via batch-completion.
62
+
There is no allow tag because the absence of a deny/ask verdict IS the floor. Rules only fire when their condition matches — a tool call that doesn't match any deny or ask rule simply produces no deny/ask alert, and the broker resolves it via batch-completion with the configured floor (`allow` or `defer`).
63
63
64
-
The broker parses the `tags` array from Falco's JSON alert output. Verdict escalation applies when multiple rules match: deny > ask > allow.
64
+
The broker parses the `tags` array from Falco's JSON alert output. Verdict escalation applies when multiple rules match: deny > ask > {allow | defer} (the floor is uniform per plugin instance, so allow and defer never compete).
65
65
66
66
### Catch-all seen rule + HTTP verdict resolution
67
67
@@ -174,12 +174,16 @@ Note: Falco's alert delivery is asynchronous — alerts are pushed to an interna
174
174
### Operational modes
175
175
176
176
Three plugin modes, switchable without reinstallation via `premptictl mode <guardrails|monitor|passthrough>`:
- **Monitor** — rules evaluated and logged, but all verdicts resolve to allow after the synchronous rule-eval wait. Would-deny / would-ask log lines still fire.
179
-
- **Passthrough** (Experimental, embedding-only) — every interceptor request is resolved as `allow` immediately at register, without waiting for rule evaluation. Events are still enqueued for Falco, so alerts continue to flow through `http_output` / `falco.log` and any observability pipeline hanging off them. No would-deny / would-ask log lines, because rule evaluation is decoupled from the verdict. Use only when embedding Prempti inside a host agent that has its own alert pipeline and does not want the hook's latency tied to Falco's rule loop.
177
+
- **Guardrails** (default) — verdicts enforced (deny / ask / floor). The no-rule-match floor is `default_action` (below).
178
+
- **Monitor** — rules evaluated and logged, but all verdicts resolve to `defer` after the synchronous rule-eval wait (Prempti steps aside; would-deny / would-ask log lines still fire). `default_action` is ignored.
179
+
- **Passthrough** (Experimental, embedding-only) — every interceptor request is resolved as `defer` immediately at register, without waiting for rule evaluation. Events are still enqueued for Falco, so alerts continue to flow through `http_output` / `falco.log` and any observability pipeline hanging off them. No would-deny / would-ask log lines, because rule evaluation is decoupled from the verdict. `default_action` is ignored. Use only when embedding Prempti inside a host agent that has its own alert pipeline and does not want the hook's latency tied to Falco's rule loop.
180
180
181
181
The three modes are mutually exclusive — `mode:` is a single string, so only one is active at a time.
182
182
183
+
**No-rule-match floor (`default_action`)** — orthogonal to mode, switchable via `premptictl default-action <allow|defer>`. It governs how the broker resolves an event that matches **no** deny/ask rule, in **guardrails mode only**:
184
+
- `allow` (default) — Prempti actively approves: Claude Code gets `permissionDecision: "allow"` (skips its own prompt); Codex gets `{"behavior":"allow"}` at `PermissionRequest`. No regression from prior releases.
185
+
- `defer` — Prempti steps aside: Claude Code gets empty stdout + exit 0 (its normal permission flow applies, prompting if it normally would); Codex's `PermissionRequest` gets no output (its own approval flow decides). This is also what `monitor` and `passthrough` always emit, which keeps the active-approval shape confined to guardrails mode. `defer` is a fourth wire verdict alongside `allow`/`deny`/`ask`; deny/ask are unaffected by `default_action`. Note `defer` is **not** rendered as Claude's `permissionDecision: "defer"` (a `-p`/Agent-SDK value that pauses the call for resume) — empty stdout is the documented "let the agent's own permission system decide" path.
186
+
183
187
Mode changes are applied via an explicit service restart driven by `ctl mode`: it rewrites the plugin config fragment, stops the service, re-registers the interceptor hook (so the brief restart window stays fail-closed rather than passing tool calls through unchecked), and starts the service again. Behavior is identical on Linux, macOS, and Windows. The same flow applies to any other config edit: edits made directly to `falco.yaml` or any included config / rule file take effect on the next `ctl start` (or `ctl mode`) — Falco's own `watch_config_files` is disabled deliberately because it is Linux-only upstream.
184
188
185
189
`ctl restart`exposes the same stop → re-add hook → start cycle as a standalone command for users who edit config files directly.
@@ -313,7 +317,7 @@ Windows has no user-level systemd or launchd equivalent, so Prempti uses a **Pow
All three platforms share `ctl health` (synthetic event through the full pipeline), `ctl hook add / remove / status`, `ctl mode`, and `ctl logs` with per-OS tail implementations (`tail -n N [-f]` on Linux/macOS vs `Get-Content -Tail N [-Wait]` on Windows). `--tail` defaults to 100 lines; pass an explicit value to override.
320
+
All three platforms share `ctl health` (synthetic event through the full pipeline), `ctl hook add / remove / status`, `ctl mode`, `ctl default-action`, and `ctl logs` with per-OS tail implementations (`tail -n N [-f]` on Linux/macOS vs `Get-Content -Tail N [-Wait]` on Windows). `--tail` defaults to 100 lines; pass an explicit value to override.
Copy file name to clipboardExpand all lines: docs/hooks/claude-code/SPEC.md
+8-6Lines changed: 8 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,7 +8,7 @@
8
8
9
9
## Overview
10
10
11
-
The Claude Code interceptor is a stateless CLI binary invoked by Claude Code's `PreToolUse` hook on every tool call. It reads the hook JSON from stdin, wraps it in a wire-protocol envelope, sends it to the plugin broker via Unix domain socket, receives a verdict (allow/deny/ask), and writes the hook response to stdout.
11
+
The Claude Code interceptor is a stateless CLI binary invoked by Claude Code's `PreToolUse` hook on every tool call. It reads the hook JSON from stdin, wraps it in a wire-protocol envelope, sends it to the plugin broker via Unix domain socket, receives a verdict (allow/deny/ask/defer), and writes the hook response to stdout.
12
12
13
13
The interceptor is a **thin passthrough** — it does not interpret tool call content, extract fields, or evaluate policies. All semantic processing (field extraction, path resolution, policy evaluation) happens in the plugin broker. This design keeps the interceptor simple, agent-agnostic, and easy to maintain.
14
14
@@ -60,7 +60,7 @@ The interceptor reads `tool_use_id` for correlation and passes the entire JSON a
60
60
61
61
### Output (stdout)
62
62
63
-
The interceptor writes a single JSON line to stdout:
63
+
For an `allow`, `deny`, or `ask` verdict the interceptor writes a single JSON line to stdout:
64
64
65
65
```json
66
66
{
@@ -72,13 +72,15 @@ The interceptor writes a single JSON line to stdout:
72
72
}
73
73
```
74
74
75
-
**Critical invariant**: The interceptor must ALWAYS produce valid JSON on stdout when exiting with code 0. Empty stdout with exit 0 causes Claude Code to silently allow the tool call.
75
+
For a `defer` verdict — the no-rule-match floor when the plugin's `default_action` is `defer`, and the resolution under `monitor` / `passthrough` modes — the interceptor writes **nothing** and exits 0. Claude Code treats exit 0 with no output as "no decision; the normal permission flow applies" (its allowlist/settings decide and it prompts if it normally would). This is deliberately **not**`permissionDecision: "defer"`: per the Claude Code hooks docs that value is a `-p` / Agent SDK feature that pauses the tool call for a wrapper to resume, which would hang an interactive session.
76
+
77
+
**Critical invariant**: On a broker *error* the interceptor fails closed — it emits an explicit `deny` (unless `PREMPTI_FAIL_OPEN=1`), never empty stdout. The only path that intentionally emits empty stdout is the `defer` verdict above. Empty stdout is **not** a silent allow: per the Claude Code hooks docs, exit 0 with no output continues through the normal permission flow (deny rules still apply, ask rules still prompt).
76
78
77
79
### Exit Codes
78
80
79
81
| Code | Meaning | Claude Code behavior |
80
82
|------|---------|---------------------|
81
-
| 0 | Success | Parse stdout JSON for verdict |
83
+
| 0 | Success | Parse stdout JSON for verdict; empty stdout = no decision (normal permission flow)|
82
84
| 2 | Blocking error | Block tool call, feed stderr to Claude as feedback |
83
85
| Other | Non-blocking error | Log to verbose output, allow tool call |
84
86
@@ -131,14 +133,14 @@ The `event` field contains the entire Claude Code hook input verbatim. The broke
131
133
```json
132
134
{
133
135
"id": "<tool_use_id>",
134
-
"decision": "allow|deny|ask",
136
+
"decision": "allow|deny|ask|defer",
135
137
"reason": "optional explanation"
136
138
}
137
139
```
138
140
139
141
**Validation**: The interceptor verifies that:
140
142
-`id` matches the request's correlation ID
141
-
-`decision` is one of `allow`, `deny`, `ask`
143
+
-`decision` is one of `allow`, `deny`, `ask`, `defer` (`defer` renders as empty stdout — see [Output](#output-stdout))
142
144
143
145
Failures trigger `verdict_on_error` (fail-closed by default: deny).
0 commit comments