feat: run ToolCallBefore hooks before tool execution#2511
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a control-plane hook mechanism ('ToolCallBefore') that allows external hooks to intercept and potentially deny tool calls (with exit code 2) before execution. This is integrated into the TUI engine configuration, turn loop, and message dispatching, and is accompanied by comprehensive unit tests. The feedback highlights two key improvement opportunities: first, optimizing the turn loop by validating tool existence and caller authorization before executing hooks to prevent unnecessary and potentially unsafe hook executions; second, storing the hook executor as an 'Arc' inside the 'App' struct to avoid redundant cloning and heap allocations.
|
Thanks @aboimpinto. I did a quick v0.8.50 triage read. This is useful Phase 2 hooks work, but I am not harvesting it into #2504: it is broader control-plane behavior and the current patch still has at least one ordering issue to resolve before it is release-safe. The key blocker is hook execution order. I would also keep the executor as shared app state instead of rebuilding |
|
Thanks for the detailed review @Hmbown. |
…ocking execute in spawn_blocking - Remove ToolCallBefore observer firing from tool_routing.rs (the turn-loop gate now handles it) to prevent double-firing hooks for each tool call (greptile P1). - Wrap hook_executor.execute() call in tokio::task::spawn_blocking so the Tokio worker thread is not blocked by child.wait_timeout() during hook execution (greptile P1).
Remove unneeded return in utils.rs (crates/tui/src/utils.rs:256) that was caught by clippy on the new commit. Also run cargo fmt to satisfy format checks.
|
Thanks @aboimpinto. I re-reviewed the latest green #2511 head ( Harvest commits on #2504:
Local verification on the updated #2504 branch:
The branch is pushed; #2504 CI is the release-branch proof before merge. |
Summary
Phase 2 of the custom slash command lifecycle / hooks architecture work, stacked on top of Phase 1 (PR #2326).
This PR adds the first hook boundary at tool execution time:
ToolCallBeforehooks receive the tool name, args, mode, workspace, model, and session id before the tool runsThis is the hook-gate slice. The Phase 1 frontmatter/allowed-tools layer (PR #2326) is already merged. Pause/resume/cancel lifecycle work remains separate for Phase 3 on
feat/allowed-tools-enforcement.Scope
ToolCallBeforehook interface with tool metadataNot in this slice
Builds on
PR #2326 (Phase 1 — feat: enforce allowed tools for custom commands) — merged
Issues
Refs #1917 (EPIC: universal PreToolUse/PostToolUse hook layer)
Refs #1891, #1900
Part of #1894, #1895
Validation
Paulo Aboim Pinto
Greptile Summary
This PR introduces the
ToolCallBeforehook gate at tool-execution time (Phase 2 of the custom slash command lifecycle architecture). Hooks configured forToolCallBeforenow receive tool metadata before dispatch and can deny the call by exiting with code 2; the hook executor is offloaded viaspawn_blockingto avoid stalling the Tokio runtime.hook_executortoEngineConfigandOp::SendMessage, seeds it for fresh sessions inrun_tui, and plumbs it throughdispatch_user_messageandbuild_engine_config.ToolCallBeforefiring fromtool_routing.rsto eliminate double-execution; the gate in the turn-loop planning sequence now serves as the single hook execution site.has_background_hooks_for_event()toHookExecutorand emits atracing::warn!when background hooks (which can never produce an exit code) are configured forToolCallBefore.Confidence Score: 5/5
Safe to merge — the gate logic is well-ordered, the blocking syscall is correctly offloaded, and the fresh-session hook_executor initialization fix removes a silent no-op regression.
All three previously flagged defects (double-firing, blocking call in async context, hook_executor None for new sessions) are addressed in this revision. The remaining finding is a per-call log-noise nit that doesn't affect correctness or runtime behavior.
No files require special attention; turn_loop.rs has the most logic but it reads cleanly and tests cover the main exit-code paths.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Model as Model (API stream) participant TurnLoop as Engine::handle_deepseek_turn participant Gate as ToolCallBefore Gate participant Hooks as HookExecutor (spawn_blocking) participant ToolExec as Tool Execution Model->>TurnLoop: ToolCallStarted(name, input) TurnLoop->>Gate: check plan-mode block TurnLoop->>Gate: check command_allows_tool (allowed_tools list) TurnLoop->>Gate: check caller_allowed_for_tool TurnLoop->>Gate: check tool_def exists TurnLoop->>Hooks: execute(ToolCallBefore, context) Hooks-->>TurnLoop: "Vec<HookResult>" alt "exit_code == Some(2)" TurnLoop-->>Model: "blocked_error = permission_denied(reason)" else "exit_code == Some(0) or None" TurnLoop->>ToolExec: dispatch tool call ToolExec-->>TurnLoop: ToolResult endComments Outside Diff (1)
crates/tui/src/core/engine/turn_loop.rs, line 1303-1308 (link)caller_allowed_for_toolunconditionally overwrites a hook-set denial reasonThis check does not guard against an already-set
blocked_error. If aToolCallBeforehook denies the tool (settingblocked_error) and thencaller_allowed_for_toolalso returns false, the hook's human-readable denial message is silently replaced by the generic "does not allow caller" message. The tool is still blocked, but the error shown to the user is misleading. Adding&& blocked_error.is_none()to the condition (as the hook gate already does) preserves the hook's reason when it fires first.Reviews (5): Last reviewed commit: "fix: clippy needless_return and fmt comp..." | Re-trigger Greptile