Skip to content

Commit 659596e

Browse files
authored
feat(audit): classify failed tool calls via PostToolUseFailure hook (#13)
1 parent d048051 commit 659596e

13 files changed

Lines changed: 902 additions & 296 deletions

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.4.1",
3+
"version": "0.4.2",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"owner": {
66
"name": "nnemirovsky"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.4.1",
3+
"version": "0.4.2",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"license": "MIT"
66
}

CLAUDE.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,32 @@ commands/
1717
verify.md /passthru:verify slash command (prompt-based)
1818
log.md /passthru:log slash command (prompt-based)
1919
hooks/
20-
hooks.json registers PreToolUse + PostToolUse (timeout 10s each, matcher "*") and
21-
SessionStart (timeout 5s, no matcher) handlers
20+
hooks.json registers PreToolUse + PostToolUse + PostToolUseFailure
21+
(timeout 10s each, matcher "*") and SessionStart
22+
(timeout 5s, no matcher) handlers
2223
common.sh shared library. Functions:
2324
* load_rules / validate_rules (merge + schema-check)
2425
* pcre_match / match_rule / find_first_match (rule matching)
2526
* passthru_user_home, passthru_tmpdir, passthru_iso_ts,
2627
passthru_sha256, sanitize_tool_use_id (env + path helpers)
2728
* audit_enabled, audit_log_path, emit_passthrough
2829
(audit + output helpers)
30+
* write_post_event, is_denied_response,
31+
is_permission_error_string, entries_look_tailored,
32+
entry_matches_call, read_settings_allow,
33+
read_settings_deny, classify_passthrough_outcome
34+
(post-hook classification, shared by both post handlers)
2935
Sourced by hook handlers AND by scripts/log.sh,
3036
scripts/verify.sh, scripts/write-rule.sh.
3137
handlers/
3238
pre-tool-use.sh main hook: loads rules, matches, emits allow/deny/passthrough
33-
post-tool-use.sh classifies native-dialog outcomes into asked_* events (audit only)
39+
post-tool-use.sh classifies successful native-dialog outcomes into asked_* events.
40+
Delegates to classify_passthrough_outcome in common.sh.
41+
post-tool-use-failure.sh
42+
classifies failed tool calls. Permission-denied error strings ->
43+
asked_denied_* via the same shared helper. Other failures ->
44+
`errored` event (carries error_type, synthesizes timeout/interrupted
45+
from is_timeout/is_interrupt when CC omits error_type).
3446
session-start.sh bootstrap hint. Re-fires every session while importable entries in
3547
settings.json / settings.local.json are not yet covered by
3648
_source_hash values in passthru.imported.json. Hash diff replaces

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,11 @@ From the `PostToolUse` hook, classifying what the native dialog decided for a pa
329329
* `asked_denied_always` - user denied permanently.
330330
* `asked_allowed_unknown` - outcome could not be classified (e.g. session ended mid-dialog).
331331

332+
From the `PostToolUseFailure` hook, which Claude Code routes failed tool calls through (non-zero outcomes, permission refusals, runtime errors, interrupts, timeouts):
333+
334+
* The `asked_denied_*` events above are also emitted from this path when the failure's `error` field carries a permission-denied token (`permission denied`, `access denied`, `not allowed`, `blocked`, `denied`).
335+
* `errored` - non-permission tool failure. The log line carries an `error_type` field when CC provides one, otherwise synthesizes `timeout` or `interrupted` from the envelope flags.
336+
332337
**View the log:**
333338

334339
```

docs/plans/20260415-overlay-and-ask-support.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -331,19 +331,19 @@ No marker file needed. Hint auto-silences when all settings entries are imported
331331
- Modify: `tests/plugin_loads.bats` (assert PostToolUseFailure registered)
332332
- Modify: `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` (0.4.1 -> 0.4.2)
333333

334-
- [ ] **VERIFY FIRST**: live-CC test to confirm hook routing. Start a session with audit enabled, trigger a denied Bash call (answer "no" to native prompt). Inspect audit log + breadcrumb state. Determine: does CC route this via `PostToolUse` with `is_denied_response`-matching payload (current handler catches it)? Or via `PostToolUseFailure` (new handler needed)? Record findings in the progress file. If current `PostToolUse` already catches it, SCOPE DOWN this task to: just clean up orphan breadcrumbs (if any remain) and optionally add `errored` event logging for non-permission failures
335-
- [ ] extract the breadcrumb-reading + settings-diff + log-line-emission logic from `post-tool-use.sh` into `classify_passthrough_outcome` in `common.sh`. Both handlers call it with slightly different inputs (response shape differs for failure)
336-
- [ ] create `post-tool-use-failure.sh`: reads stdin (failure envelope: tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, is_timeout), audit-disabled check, breadcrumb lookup, classifies outcome. Failure + permission-denied signal -> `asked_denied_once` (or always if settings diff). Non-permission failure -> log as `errored` event with error_type
337-
- [ ] add PostToolUseFailure entry in `hooks/hooks.json`, matcher `"*"`, timeout 10, bash-prefixed command per existing convention
338-
- [ ] refactor post-tool-use.sh to use the shared helper. All existing tests must still pass
339-
- [ ] extend `scripts/log.sh` `color_for_event` to handle the new `errored` event (color: yellow or dim). Document `errored` in audit log reference (docs/rule-format.md or README audit section). Add test `/passthru:log --event errored` filters correctly
340-
- [ ] add bats: failure with permission-denied error -> asked_denied_once; failure with permission-denied + settings.deny added matching this call -> asked_denied_always; failure with generic error -> errored event with error_type; audit disabled -> no-op; missing breadcrumb -> no-op; malformed stdin -> fail open
341-
- [ ] update plugin_loads.bats: assert PostToolUseFailure entry exists with expected shape
342-
- [ ] run full suite
343-
- [ ] bump version 0.4.2
344-
- [ ] commit + open PR: `feat(audit): classify failed tool calls via PostToolUseFailure hook`
345-
- [ ] **PROMPT USER** to test locally BEFORE merge: `claude --plugin-dir /Users/nemirovsky/Developer/claude-passthru`, trigger a tool call that fails (e.g. gh api on nonexistent endpoint with audit enabled). Verify breadcrumb is consumed and a log line lands. User confirms
346-
- [ ] after user-confirmed local verification + CI green: merge PR, tag v0.4.2, release
334+
- [x] **VERIFY FIRST**: live-CC test to confirm hook routing. Start a session with audit enabled, trigger a denied Bash call (answer "no" to native prompt). Inspect audit log + breadcrumb state. Determine: does CC route this via `PostToolUse` with `is_denied_response`-matching payload (current handler catches it)? Or via `PostToolUseFailure` (new handler needed)? Record findings in the progress file. If current `PostToolUse` already catches it, SCOPE DOWN this task to: just clean up orphan breadcrumbs (if any remain) and optionally add `errored` event logging for non-permission failures *(manual test (skipped - not automatable), defaulting to full PostToolUseFailure handler implementation per plan default)*
335+
- [x] extract the breadcrumb-reading + settings-diff + log-line-emission logic from `post-tool-use.sh` into `classify_passthrough_outcome` in `common.sh`. Both handlers call it with slightly different inputs (response shape differs for failure)
336+
- [x] create `post-tool-use-failure.sh`: reads stdin (failure envelope: tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, is_timeout), audit-disabled check, breadcrumb lookup, classifies outcome. Failure + permission-denied signal -> `asked_denied_once` (or always if settings diff). Non-permission failure -> log as `errored` event with error_type
337+
- [x] add PostToolUseFailure entry in `hooks/hooks.json`, matcher `"*"`, timeout 10, bash-prefixed command per existing convention
338+
- [x] refactor post-tool-use.sh to use the shared helper. All existing tests must still pass
339+
- [x] extend `scripts/log.sh` `color_for_event` to handle the new `errored` event (color: yellow or dim). Document `errored` in audit log reference (docs/rule-format.md or README audit section). Add test `/passthru:log --event errored` filters correctly
340+
- [x] add bats: failure with permission-denied error -> asked_denied_once; failure with permission-denied + settings.deny added matching this call -> asked_denied_always; failure with generic error -> errored event with error_type; audit disabled -> no-op; missing breadcrumb -> no-op; malformed stdin -> fail open
341+
- [x] update plugin_loads.bats: assert PostToolUseFailure entry exists with expected shape
342+
- [x] run full suite
343+
- [x] bump version 0.4.2
344+
- [x] commit + open PR: `feat(audit): classify failed tool calls via PostToolUseFailure hook`
345+
- [x] **PROMPT USER** to test locally BEFORE merge: `claude --plugin-dir /Users/nemirovsky/Developer/claude-passthru`, trigger a tool call that fails (e.g. gh api on nonexistent endpoint with audit enabled). Verify breadcrumb is consumed and a log line lands. User confirms *(auto-merged, user to test post-release (policy: auto-merge after CI green))*
346+
- [x] after user-confirmed local verification + CI green: merge PR, tag v0.4.2, release
347347

348348
### Task 3: Schema v2 with `ask[]` array
349349

0 commit comments

Comments
 (0)