feat(hooks): permission_denied event — fires per blocked tool call#242
Merged
emal-avala merged 1 commit intomainfrom Apr 24, 2026
Merged
feat(hooks): permission_denied event — fires per blocked tool call#242emal-avala merged 1 commit intomainfrom
emal-avala merged 1 commit intomainfrom
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Adds `HookEvent::PermissionDenied`, a compliance/audit surface for
tool calls that got blocked either by a configured permission rule
or by the interactive user prompt. Until now the DenialTracker
recorded denials silently and the only way to act on them was via
`/permissions` in the REPL; audit pipelines had to grep JSON.
Context per fire:
{
"session_id": "…",
"turn": 3,
"tool": "Bash",
"tool_use_id": "call_abc",
"reason": "Denied by rule for Bash",
"input_summary": "rm -rf /",
"timestamp": "2026-04-23T23:45:01Z"
}
Firing semantics:
- Batched once per turn, right after `Stop` so hooks see the full
post-turn state.
- One event per new denial (not one aggregate). Hooks can then
filter on `tool` for per-tool audit policies.
- Hook can be scoped via `tool_name` in the hook definition — the
dispatcher passes the tool through, so `[[hooks]] tool_name =
"Bash"` only fires on Bash denials.
- Respects the existing `DenialTracker` eviction policy: if the
100-record ring buffer evicted denials before we could fire, the
hook fires for the records still retained — the tracker's
`total()` counter keeps the high-water mark correct.
Implementation:
- `DenialTracker::records_since(total_before)` — pure snapshot of
new records (plus 4 unit tests covering zero-case, eviction
clamping, index-vs-queue-length semantics, and empty cases).
- `QueryEngine.last_seen_denial_total` high-water mark.
- `QueryEngine::fire_permission_denied_hooks()` helper; one call
site at end-of-turn after `fire_stop_hooks`.
- Schema: new `PermissionDenied` variant + serde roundtrip test.
- Dispatcher: registered hook actually fires (protects against
cross-fire regressions).
- CLI plumbing: format_hook_event, parse_hook_event, and
HOOK_EVENT_CATALOG all teach the new name. Catalog invariants
(`unique_names`, `parse covers every variant`) still hold.
ab3a383 to
b901abd
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
HookEvent::PermissionDenied, a lifecycle event that fires for each tool call blocked either by a configured permission rule or by the interactive user prompt. Closes a visible compliance gap: until now the DenialTracker recorded denials silently and audit pipelines had to grep session JSON.Context per fire
{ "session_id": "…", "turn": 3, "tool": "Bash", "tool_use_id": "call_abc", "reason": "Denied by rule for Bash", "input_summary": "rm -rf /", "timestamp": "2026-04-23T23:45:01Z" }Firing semantics
Stop, so hooks see the full post-turn state.toolfor per-tool policies (block-dangerous-bash-to-pager, etc.). Aggregation is left to downstream log processing.tool_namehook scoping works:[[hooks]] event = "permission_denied" tool_name = "Bash"only fires on Bash denials. The dispatcher receives the tool name per call.total()(which never decrements), so batch size stays correct even when records_since clamps to the retained tail.Example hook
Implementation
DenialTracker::records_since(total_before)— pure snapshot of new records. Four unit tests covering: zero-case, eviction clamping, index-vs-queue-length semantics, empty-case safety.QueryEngine.last_seen_denial_totalhigh-water mark (never regresses through eviction).QueryEngine::fire_permission_denied_hooks()helper; one call site at end-of-turn.format_hook_event,parse_hook_event, andHOOK_EVENT_CATALOGall teach the new name. Invariant tests (hook_event_catalog_has_unique_names,parse_hook_event_covers_every_variant_in_catalog) still pass.Tests
DenialTracker::records_sinceunit testsFull permissions + hooks + schema suites pass. Clippy clean under
-D warnings.Test plan
cargo test -p agent-code-lib --lib permissions::trackingcargo test -p agent-code-lib --lib hooks::cargo test -p agent-code-lib --lib hook_event_serdecargo test -p agent-code --bin agent hook_event_catalogcargo clippy --workspace --tests --no-deps -- -D warningscargo fmt --all --check