Skip to content

feat(hooks): pre_tool_use veto — block tool calls from a hook#243

Merged
emal-avala merged 1 commit intomainfrom
feat/hook-pre-tool-use-veto
Apr 24, 2026
Merged

feat(hooks): pre_tool_use veto — block tool calls from a hook#243
emal-avala merged 1 commit intomainfrom
feat/hook-pre-tool-use-veto

Conversation

@emal-avala
Copy link
Copy Markdown
Member

Summary

Promotes pre_tool_use from a notify-only signal to a gate. When a hook exits non-zero, the tool call is vetoed — the tool does not execute and the model receives a synthetic error ToolResult carrying the hook's stderr (or stdout, or a generic message) as the reason.

Problem

The pre_tool_use event fired, but its return value was ignored. Operators who wanted a policy guard (content scanning, destructive-command blocks, compliance checks) had to stretch the permission rule grammar to match arbitrary predicates — or wait for one. A hook that can both inspect the tool input and veto the call closes that gap cleanly.

Example

[[hooks]]
event     = "pre_tool_use"
tool_name = "Bash"
action    = { type = "shell", command = '''
  if echo "$TOOL_INPUT" | grep -q 'rm -rf /'; then
    echo "refusing destructive root delete" >&2
    exit 1
  fi
''' }

When this fires and exits 1, the Bash call is blocked and the model sees:

Blocked by pre-tool-use hook: refusing destructive root delete

Implementation

  • HookResult gains a stderr field (default empty). Shell hooks populate it from subprocess stderr; HTTP hooks leave it empty. Existing call sites continue to work — the struct is Default + Clone and no external crate constructs HookResult directly.
  • Veto wiring in query::run_turn_with_sink: per-call hook results are collected into a vetoed_ids set. Vetoed calls skip execute_tool_calls and produce a synthetic ToolResult::error.
  • Denial surface integration: vetoed calls record on the existing DenialTracker, so the permission_denied hook, /permissions UI, and denial reports see the block uniformly with rule-based and user denials.
  • Reason priority: stderr (trimmed) > stdout (trimmed) > generic "blocked by pre-tool-use hook". First line only so audit records stay scannable.

Backwards compatibility

  • Hooks that exit 0 are unchanged — they fire, log whatever, and the tool proceeds.
  • A hook that writes to stderr but exits 0 is NOT a veto — success is tied to exit status only. Lots of hooks log progress on stderr as a matter of style; they must not accidentally block.
  • pre_tool_use was documented as "can block/modify" in the schema rustdoc — this PR makes the "can block" half real; the "modify" half is not claimed here.

Tests (3 new)

  • Hook exiting non-zero sets success=false
  • Stderr and stdout are captured into distinct fields with no cross-mix
  • Exit-zero hook with stderr output still counts as success (not a veto)

All 13 hooks:: dispatcher tests pass. Clippy clean under -D warnings.

Test plan

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

Promotes PreToolUse from a notify-only signal to a gate. When a
pre_tool_use hook exits non-zero, the tool call is vetoed — the
tool does not execute, and the model receives a synthetic error
ToolResult carrying the hook's stderr (or stdout, or a generic
message) as the reason.

Rationale: operators have needed to plug in policy guards (content
scanning, destructive-command blocks, compliance checks) without
extending the permission rule grammar. The pre_tool_use hook
surface was almost sufficient — it fired — but the return was
ignored so hooks couldn't actually stop anything.

Example:

    [[hooks]]
    event     = "pre_tool_use"
    tool_name = "Bash"
    action    = { type = "shell", command = """
        if echo "$TOOL_INPUT" | grep -q 'rm -rf /'; then
          echo "refusing destructive root delete" >&2
          exit 1
        fi
    """ }

Implementation:
- `HookResult` gains a dedicated `stderr` field so hook authors can
  signal block reasons on stderr (shell convention) and have them
  surfaced verbatim without mixing with stdout. Existing callers
  continue to work; the added field defaults.
- query::run_turn_with_sink collects per-call hook results and
  builds a `vetoed_ids` set. Vetoed calls skip execution and
  produce a synthetic error result.
- Vetoes are also recorded on the existing DenialTracker — so the
  `permission_denied` hook, `/permissions` UI, and denial reports
  see the block uniformly with rule-based and user denials.
- Reason priority: stderr (trimmed) > stdout (trimmed) > generic
  "blocked by pre-tool-use hook". First line only, so audit
  records stay scannable.

Tests (3 new dispatcher tests for the veto preconditions):
- Hook exiting non-zero sets `success=false`
- Stderr and stdout are captured into distinct fields, no cross-mix
- Exit-zero hook with stderr output still counts as success (not
  a veto — success is tied to exit status only)

All 13 hook dispatcher tests pass. Clippy clean.
@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.

@emal-avala emal-avala merged commit a614487 into main Apr 24, 2026
14 checks passed
@emal-avala emal-avala deleted the feat/hook-pre-tool-use-veto branch April 24, 2026 04:35
@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