Skip to content

feat(hooks): permission_denied event — fires per blocked tool call#242

Merged
emal-avala merged 1 commit intomainfrom
feat/hook-permission-denied
Apr 24, 2026
Merged

feat(hooks): permission_denied event — fires per blocked tool call#242
emal-avala merged 1 commit intomainfrom
feat/hook-permission-denied

Conversation

@emal-avala
Copy link
Copy Markdown
Member

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

  • Batched once per turn, right after Stop, so hooks see the full post-turn state.
  • One event per new denial — hooks can filter on tool for per-tool policies (block-dangerous-bash-to-pager, etc.). Aggregation is left to downstream log processing.
  • tool_name hook scoping works: [[hooks]] event = "permission_denied" tool_name = "Bash" only fires on Bash denials. The dispatcher receives the tool name per call.
  • Eviction-aware: the existing 100-record DenialTracker ring buffer can evict old records; the engine's high-water mark tracks total() (which never decrements), so batch size stays correct even when records_since clamps to the retained tail.

Example hook

[[hooks]]
event  = "permission_denied"
action = { type = "http", url = "https://audit.example.com/ingest", method = "POST" }

[[hooks]]
event     = "permission_denied"
tool_name = "Bash"
action    = { type = "shell", command = "./on-bash-denial.sh" }

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_total high-water mark (never regresses through eviction).
  • QueryEngine::fire_permission_denied_hooks() helper; one call site at end-of-turn.
  • Schema: new variant + serde roundtrip test.
  • Dispatcher: registered hook fires (protects against cross-fire regressions).
  • CLI: format_hook_event, parse_hook_event, and HOOK_EVENT_CATALOG all teach the new name. Invariant tests (hook_event_catalog_has_unique_names, parse_hook_event_covers_every_variant_in_catalog) still pass.

Tests

  • 4 new DenialTracker::records_since unit tests
  • 1 new schema serde roundtrip
  • 1 new dispatcher fire test
  • Existing CLI catalog invariants continue to pass

Full permissions + hooks + schema suites pass. Clippy clean under -D warnings.

Test plan

  • cargo test -p agent-code-lib --lib permissions::tracking
  • cargo test -p agent-code-lib --lib hooks::
  • cargo test -p agent-code-lib --lib hook_event_serde
  • cargo test -p agent-code --bin agent hook_event_catalog
  • cargo clippy --workspace --tests --no-deps -- -D warnings
  • cargo fmt --all --check

@chatgpt-codex-connector
Copy link
Copy Markdown

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.
@emal-avala emal-avala force-pushed the feat/hook-permission-denied branch from ab3a383 to b901abd Compare April 24, 2026 04:39
@emal-avala emal-avala merged commit c8ac788 into main Apr 24, 2026
14 checks passed
@emal-avala emal-avala deleted the feat/hook-permission-denied branch April 24, 2026 05:04
@emal-avala emal-avala mentioned this pull request Apr 24, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant