Skip to content

Commit cb2e6a0

Browse files
leogrpoiana
authored andcommitted
docs: document default_action and the defer verdict; fix stale empty-stdout claim
Signed-off-by: Leonardo Grasso <me@leonardograsso.com>
1 parent ffd44e8 commit cb2e6a0

9 files changed

Lines changed: 84 additions & 44 deletions

File tree

CLAUDE.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The project targets **Claude Code** as its primary integration on **Linux, macOS
2929
2. **Event delivery** — The interceptor sends the event to the plugin's embedded broker via Unix domain socket.
3030
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.
3131
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.
3333
6. **Verdict delivery** — The interceptor communicates the verdict to the coding agent using the standard hook response format.
3434

3535
### Components
@@ -57,11 +57,11 @@ Rule verdicts are encoded in the `tags:` field of Falco rules, not in the `outpu
5757

5858
- `tags: [coding_agent_deny]` — block the tool call
5959
- `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)
6161

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`).
6363

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).
6565

6666
### Catch-all seen rule + HTTP verdict resolution
6767

@@ -174,12 +174,16 @@ Note: Falco's alert delivery is asynchronous — alerts are pushed to an interna
174174
### Operational modes
175175

176176
Three plugin modes, switchable without reinstallation via `premptictl mode <guardrails|monitor|passthrough>`:
177-
- **Guardrails** (default) — verdicts enforced (deny/ask/allow).
178-
- **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.
180180

181181
The three modes are mutually exclusive — `mode:` is a single string, so only one is active at a time.
182182

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+
183187
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.
184188

185189
`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
313317
| `disable` | `systemctl --user disable` | `launchctl unload -w <plist>` | `reg delete <Run key>` |
314318
| `status` | `systemctl --user status` | `launchctl list <label>` | `tasklist /FI "IMAGENAME eq falco.exe"` |
315319

316-
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.
317321

318322
## Technology Stack
319323

configs/README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,34 @@ Key settings:
2020
Plugin-specific configuration fragment. Merged into `falco.yaml` via `config_files` (append strategy).
2121

2222
Contains:
23-
- Plugin definition (`coding_agent`) with `init_config` (mode, socket path, HTTP port, verdict tags)
23+
- Plugin definition (`coding_agent`) with `init_config` (mode, `default_action`, socket path, HTTP port, verdict tags)
2424
- `load_plugins` list
2525
- `rules_files` (default rules → user rules → seen rule)
2626
- `http_output` configuration
2727

28+
#### `default_action` (no-rule-match floor)
29+
30+
In `guardrails` mode, an event matching no deny/ask rule resolves with the
31+
`default_action` floor: `allow` (default — Prempti approves, skipping the
32+
agent's own prompt) or `defer` (Prempti steps aside; the agent's own
33+
permission system decides). deny/ask verdicts are unaffected. Change it via
34+
`premptictl default-action <allow|defer>`. Ignored in `monitor`/`passthrough`
35+
(those always defer).
36+
2837
#### `mode: passthrough` (Experimental)
2938

3039
Embedding-only mode. When `mode: passthrough` is set, all interceptor
31-
requests are resolved as `allow` immediately at register, without
40+
requests are resolved as `defer` immediately at register, without
3241
waiting for rule evaluation. Events are still enqueued for Falco so
3342
observability via `http_output` and `falco.log` is preserved.
3443

3544
Use only when embedding Prempti inside a host agent that handles
3645
alerts via its own pipeline.
3746

3847
This is distinct from `mode: monitor`: monitor waits for rule
39-
evaluation and then forces an `allow` verdict (synchronous, so
48+
evaluation and then resolves as `defer` (synchronous, so
4049
would-deny/would-ask log lines fire), while passthrough short-circuits
41-
at register and skips the wait entirely.
50+
at register and skips the wait entirely. Both ignore `default_action`.
4251

4352
## Path Expansion
4453

docs/hooks/claude-code/SPEC.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
## Overview
1010

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

1313
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.
1414

@@ -60,7 +60,7 @@ The interceptor reads `tool_use_id` for correlation and passes the entire JSON a
6060

6161
### Output (stdout)
6262

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:
6464

6565
```json
6666
{
@@ -72,13 +72,15 @@ The interceptor writes a single JSON line to stdout:
7272
}
7373
```
7474

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).
7678

7779
### Exit Codes
7880

7981
| Code | Meaning | Claude Code behavior |
8082
|------|---------|---------------------|
81-
| 0 | Success | Parse stdout JSON for verdict |
83+
| 0 | Success | Parse stdout JSON for verdict; empty stdout = no decision (normal permission flow) |
8284
| 2 | Blocking error | Block tool call, feed stderr to Claude as feedback |
8385
| Other | Non-blocking error | Log to verbose output, allow tool call |
8486

@@ -131,14 +133,14 @@ The `event` field contains the entire Claude Code hook input verbatim. The broke
131133
```json
132134
{
133135
"id": "<tool_use_id>",
134-
"decision": "allow|deny|ask",
136+
"decision": "allow|deny|ask|defer",
135137
"reason": "optional explanation"
136138
}
137139
```
138140

139141
**Validation**: The interceptor verifies that:
140142
- `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))
142144

143145
Failures trigger `verdict_on_error` (fail-closed by default: deny).
144146

0 commit comments

Comments
 (0)