From 2f020ed0bb3b9487ebc4671afd969ee5c8727cfa Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 4 Jun 2026 01:52:29 +0900 Subject: [PATCH 1/2] fix: harden public hook actions --- crates/pod/src/hook.rs | 143 +++++++++++++++++++--- crates/pod/src/ipc/interceptor.rs | 192 +++++++++++++++++++++++++++--- crates/pod/src/permission.rs | 74 ++++++++---- crates/pod/src/pod.rs | 9 +- 4 files changed, 355 insertions(+), 63 deletions(-) diff --git a/crates/pod/src/hook.rs b/crates/pod/src/hook.rs index 9f360293..21534e5d 100644 --- a/crates/pod/src/hook.rs +++ b/crates/pod/src/hook.rs @@ -2,8 +2,7 @@ //! //! Hooks are the **public** orchestration extension point. They receive //! read-only summary information about each event in the Worker -//! execution loop and return a control-flow action -//! (continue / skip / abort / pause). +//! execution loop and return a safe public control-flow action. //! //! Hooks intentionally cannot mutate the Worker's context, history, tool //! call, or tool result. Internal mechanisms that need such access (e.g. @@ -18,7 +17,7 @@ use async_trait::async_trait; use llm_worker::interceptor::{ PostToolAction, PreRequestAction, PreToolAction, PromptAction, TurnEndAction, }; -use llm_worker::tool::ToolOutput; +use llm_worker::tool::{ToolOutput, ToolResult}; use serde_json::Value; /// Hook-facing prompt-submit action. @@ -32,7 +31,7 @@ use serde_json::Value; pub enum HookPromptAction { /// Proceed normally. Continue, - /// Cancel with a reason. + /// Cancel this submitted prompt with a reason. Cancel(String), } @@ -45,6 +44,112 @@ impl From for PromptAction { } } +/// Hook-facing pre-LLM-request action. +/// +/// Public hooks may observe the request boundary, cancel the run, or yield +/// control back to the caller. They cannot return +/// `PreRequestAction::ContinueWith(Vec)`; model-visible request/history +/// additions must use durable host-owned paths such as notifications or +/// system-item commits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookPreRequestAction { + /// Proceed normally. + Continue, + /// Cancel the run with a reason. + Cancel(String), + /// Yield control to the caller for host-owned processing/resume. + Yield, +} + +impl From for PreRequestAction { + fn from(action: HookPreRequestAction) -> Self { + match action { + HookPreRequestAction::Continue => PreRequestAction::Continue, + HookPreRequestAction::Cancel(reason) => PreRequestAction::Cancel(reason), + HookPreRequestAction::Yield => PreRequestAction::Yield, + } + } +} + +/// Hook-facing pre-tool-call action. +/// +/// Hooks may continue, skip/pause/abort the call, or deny it with an error +/// string that Pod converts into a synthetic tool result for the current +/// tool call. Hooks cannot mutate the tool call arguments or construct +/// arbitrary `ToolResult` values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookPreToolAction { + /// Proceed with tool execution. + Continue, + /// Skip this tool call without executing it. + Skip, + /// Deny this tool call and commit a synthetic error result. + Deny(String), + /// Abort the entire run. + Abort(String), + /// Pause execution. + Pause, +} + +impl HookPreToolAction { + pub(crate) fn into_worker_action(self, call_id: String) -> PreToolAction { + match self { + HookPreToolAction::Continue => PreToolAction::Continue, + HookPreToolAction::Skip => PreToolAction::Skip, + HookPreToolAction::Deny(reason) => { + PreToolAction::SyntheticResult(ToolResult::error(call_id, reason)) + } + HookPreToolAction::Abort(reason) => PreToolAction::Abort(reason), + HookPreToolAction::Pause => PreToolAction::Pause, + } + } +} + +/// Hook-facing post-tool-call action. +/// +/// Post-tool hooks are observational except that they may abort the run. They +/// cannot rewrite the tool output; adding an explicit bounded transform would +/// require a separate safe public type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookPostToolAction { + /// Proceed normally. + Continue, + /// Abort the entire run. + Abort(String), +} + +impl From for PostToolAction { + fn from(action: HookPostToolAction) -> Self { + match action { + HookPostToolAction::Continue => PostToolAction::Continue, + HookPostToolAction::Abort(reason) => PostToolAction::Abort(reason), + } + } +} + +/// Hook-facing turn-end action. +/// +/// Turn-end hooks may observe a completed turn and optionally pause further +/// execution. They cannot return +/// `TurnEndAction::ContinueWithMessages(Vec)`; public hooks must not +/// append arbitrary model-visible messages at turn boundaries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookTurnEndAction { + /// Finish the turn normally. + Finish, + /// Pause execution. + Pause, +} + +impl From for TurnEndAction { + fn from(action: HookTurnEndAction) -> Self { + match action { + HookTurnEndAction::Finish => TurnEndAction::Finish, + HookTurnEndAction::Pause => TurnEndAction::Pause, + } + } +} + // ============================================================================= // Hook input summary types (read-only) // ============================================================================= @@ -121,8 +226,8 @@ pub struct AbortInfo { /// Marker trait for hook event kinds. /// -/// Each event kind specifies its read-only input and the control-flow -/// action returned by hooks. +/// Each event kind specifies its read-only input and the safe public +/// control-flow action returned by hooks. pub trait HookEventKind: Send + Sync + 'static { /// Read-only input passed to the hook. type Input: Send + Sync; @@ -130,17 +235,17 @@ pub trait HookEventKind: Send + Sync + 'static { type Output; } -/// After receiving user input, before adding to history. +/// After receiving user input, before adding to history; may continue or cancel. pub struct OnPromptSubmit; -/// Before each LLM request. +/// Before each LLM request; may continue, cancel, or yield. pub struct PreLlmRequest; -/// Before each tool is executed. +/// Before each tool is executed; may continue, skip, deny, abort, or pause. pub struct PreToolCall; -/// After each tool completes. +/// After each tool completes; observational except it may abort the run. pub struct PostToolCall; -/// When a turn ends with no tool calls. +/// When a turn ends with no tool calls; observational except it may pause. pub struct OnTurnEnd; -/// When execution is interrupted. +/// When execution is interrupted; observational only. pub struct OnAbort; impl HookEventKind for OnPromptSubmit { @@ -150,22 +255,22 @@ impl HookEventKind for OnPromptSubmit { impl HookEventKind for PreLlmRequest { type Input = PreRequestInfo; - type Output = PreRequestAction; + type Output = HookPreRequestAction; } impl HookEventKind for PreToolCall { type Input = ToolCallSummary; - type Output = PreToolAction; + type Output = HookPreToolAction; } impl HookEventKind for PostToolCall { type Input = ToolResultSummary; - type Output = PostToolAction; + type Output = HookPostToolAction; } impl HookEventKind for OnTurnEnd { type Input = TurnEndInfo; - type Output = TurnEndAction; + type Output = HookTurnEndAction; } impl HookEventKind for OnAbort { @@ -180,9 +285,9 @@ impl HookEventKind for OnAbort { /// Async hook for a specific event kind. /// /// Hooks receive a shared reference to the event's read-only input -/// and return a control-flow action. Multiple hooks can be registered -/// per event; they are evaluated in registration order and -/// short-circuit on the first non-Continue (or non-Finish) result. +/// and return a safe public control-flow action. Multiple hooks can be +/// registered per event; they are evaluated in registration order and +/// short-circuit on the first non-continue action. #[async_trait] pub trait Hook: Send + Sync { async fn call(&self, input: &E::Input) -> E::Output; diff --git a/crates/pod/src/ipc/interceptor.rs b/crates/pod/src/ipc/interceptor.rs index 90e2d614..10df5f60 100644 --- a/crates/pod/src/ipc/interceptor.rs +++ b/crates/pod/src/ipc/interceptor.rs @@ -27,7 +27,8 @@ use session_store::{SystemItem, SystemReminder}; use tools::{TaskEntry, TaskStatus, TaskStore}; use crate::hook::{ - AbortInfo, HookPromptAction, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary, + AbortInfo, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookPromptAction, + HookRegistry, HookTurnEndAction, PreRequestInfo, PromptSubmitInfo, ToolCallSummary, ToolResultSummary, TurnEndInfo, }; use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item}; @@ -343,8 +344,8 @@ impl Interceptor for PodInterceptor { }; for hook in &self.registry.pre_llm_request { let action = hook.call(&info).await; - if !matches!(action, PreRequestAction::Continue) { - return action; + if !matches!(action, HookPreRequestAction::Continue) { + return action.into(); } } PreRequestAction::Continue @@ -358,8 +359,8 @@ impl Interceptor for PodInterceptor { }; for hook in &self.registry.pre_tool_call { let action = hook.call(&summary).await; - if !matches!(action, PreToolAction::Continue) { - return action; + if !matches!(action, HookPreToolAction::Continue) { + return action.into_worker_action(summary.call_id.clone()); } } if is_task_management_tool(&info.call.name) { @@ -381,8 +382,8 @@ impl Interceptor for PodInterceptor { }; for hook in &self.registry.post_tool_call { let action = hook.call(&summary).await; - if !matches!(action, PostToolAction::Continue) { - return action; + if !matches!(action, HookPostToolAction::Continue) { + return action.into(); } } PostToolAction::Continue @@ -403,8 +404,8 @@ impl Interceptor for PodInterceptor { }; for hook in &self.registry.on_turn_end { let action = hook.call(&info).await; - if !matches!(action, TurnEndAction::Finish) { - return action; + if !matches!(action, HookTurnEndAction::Finish) { + return action.into(); } } TurnEndAction::Finish @@ -480,16 +481,19 @@ mod tests { use std::sync::atomic::{AtomicBool, AtomicUsize}; use super::*; - use crate::hook::{Hook, HookRegistryBuilder, PreLlmRequest}; + use crate::hook::{ + Hook, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookRegistryBuilder, + HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall, + }; use session_store::SystemReminderSource; struct CountingHook(Arc); #[async_trait] impl Hook for CountingHook { - async fn call(&self, _info: &PreRequestInfo) -> PreRequestAction { + async fn call(&self, _info: &PreRequestInfo) -> HookPreRequestAction { self.0.fetch_add(1, Ordering::Relaxed); - PreRequestAction::Continue + HookPreRequestAction::Continue } } @@ -516,7 +520,7 @@ mod tests { ) } - async fn call_pre_tool(interceptor: &PodInterceptor, name: &str) { + fn task_tool_call_info(name: &str, input: serde_json::Value) -> ToolCallInfo { let def = tools::task_tools(TaskStore::new()) .into_iter() .find(|def| { @@ -525,15 +529,19 @@ mod tests { }) .expect("task tool definition"); let (meta, tool) = def(); - let mut info = ToolCallInfo { + ToolCallInfo { call: llm_worker::tool::ToolCall { id: "call-id".into(), name: name.into(), - input: serde_json::json!({}), + input, }, meta, tool, - }; + } + } + + async fn call_pre_tool(interceptor: &PodInterceptor, name: &str) { + let mut info = task_tool_call_info(name, serde_json::json!({})); let action = interceptor.pre_tool_call(&mut info).await; assert!(matches!(action, PreToolAction::Continue)); } @@ -739,12 +747,160 @@ mod tests { #[async_trait] impl Hook for AbortingHook { - async fn call(&self, _info: &PreRequestInfo) -> PreRequestAction { + async fn call(&self, _info: &PreRequestInfo) -> HookPreRequestAction { self.0.store(true, Ordering::Relaxed); - PreRequestAction::Cancel("nope".into()) + HookPreRequestAction::Cancel("nope".into()) } } + #[tokio::test] + async fn public_pre_tool_hook_deny_becomes_synthetic_error_and_short_circuits() { + struct DenyToolHook(Arc); + struct CountingToolHook(Arc); + + #[async_trait] + impl Hook for DenyToolHook { + async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction { + self.0.fetch_add(1, Ordering::Relaxed); + assert_eq!(input.call_id, "call-id"); + assert_eq!(input.tool_name, "TaskList"); + assert_eq!(input.arguments, serde_json::json!({"scope": "all"})); + HookPreToolAction::Deny("blocked by public hook".into()) + } + } + + #[async_trait] + impl Hook for CountingToolHook { + async fn call(&self, _input: &ToolCallSummary) -> HookPreToolAction { + self.0.fetch_add(1, Ordering::Relaxed); + HookPreToolAction::Continue + } + } + + let first_count = Arc::new(AtomicUsize::new(0)); + let second_count = Arc::new(AtomicUsize::new(0)); + let mut builder = HookRegistryBuilder::new(); + builder.add_pre_tool_call(DenyToolHook(first_count.clone())); + builder.add_pre_tool_call(CountingToolHook(second_count.clone())); + let registry = Arc::new(builder.build()); + let interceptor = PodInterceptor::new( + registry, + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + TaskStore::new(), + Arc::new(TaskReminderState::new()), + PromptCatalog::builtins_only().unwrap(), + None, + ); + let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"})); + + let action = interceptor.pre_tool_call(&mut info).await; + + match action { + PreToolAction::SyntheticResult(result) => { + assert_eq!(result.tool_use_id, "call-id"); + assert_eq!(result.summary, "blocked by public hook"); + assert_eq!(result.content, None); + assert!(result.is_error); + } + other => panic!("expected synthetic denial, got {other:?}"), + } + assert_eq!(first_count.load(Ordering::Relaxed), 1); + assert_eq!(second_count.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn public_post_tool_hooks_observe_output_but_only_abort() { + struct AbortAfterToolHook(Arc); + + #[async_trait] + impl Hook for AbortAfterToolHook { + async fn call(&self, input: &ToolResultSummary) -> HookPostToolAction { + self.0.fetch_add(1, Ordering::Relaxed); + assert_eq!(input.call_id, "call-id"); + assert_eq!(input.tool_name, "TaskList"); + assert!(!input.is_error); + assert_eq!(input.output.summary, "ok"); + assert_eq!(input.output.content.as_deref(), Some("full")); + HookPostToolAction::Abort("post tool abort".into()) + } + } + + let count = Arc::new(AtomicUsize::new(0)); + let mut builder = HookRegistryBuilder::new(); + builder.add_post_tool_call(AbortAfterToolHook(count.clone())); + let registry = Arc::new(builder.build()); + let interceptor = PodInterceptor::new( + registry, + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + TaskStore::new(), + Arc::new(TaskReminderState::new()), + PromptCatalog::builtins_only().unwrap(), + None, + ); + let info = task_tool_call_info("TaskList", serde_json::json!({})); + let mut result_info = ToolResultInfo { + call: info.call, + result: llm_worker::tool::ToolResult::from_output( + "call-id", + ToolOutput { + summary: "ok".into(), + content: Some("full".into()), + }, + ), + meta: info.meta, + tool: info.tool, + }; + + let action = interceptor.post_tool_call(&mut result_info).await; + + assert_eq!(action, PostToolAction::Abort("post tool abort".to_string())); + assert_eq!(count.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn public_turn_end_hooks_are_observational_or_pause_only() { + struct PauseTurnEndHook(Arc); + + #[async_trait] + impl Hook for PauseTurnEndHook { + async fn call(&self, input: &TurnEndInfo) -> HookTurnEndAction { + self.0.fetch_add(1, Ordering::Relaxed); + assert_eq!(input.turn_index, 0); + assert_eq!(input.tool_calls_count, 0); + assert_eq!(input.final_text_preview, "done"); + HookTurnEndAction::Pause + } + } + + let count = Arc::new(AtomicUsize::new(0)); + let mut builder = HookRegistryBuilder::new(); + builder.add_on_turn_end(PauseTurnEndHook(count.clone())); + let registry = Arc::new(builder.build()); + let interceptor = PodInterceptor::new( + registry, + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + TaskStore::new(), + Arc::new(TaskReminderState::new()), + PromptCatalog::builtins_only().unwrap(), + None, + ); + let history = vec![Item::user_message("hi"), Item::assistant_message("done")]; + + let action = interceptor.on_turn_end(&history).await; + + assert!(matches!(action, TurnEndAction::Pause)); + assert_eq!(count.load(Ordering::Relaxed), 1); + } + #[tokio::test] async fn pending_history_appends_drains_buffer_into_items() { let registry = Arc::new(HookRegistryBuilder::new().build()); diff --git a/crates/pod/src/permission.rs b/crates/pod/src/permission.rs index a024d81f..d4418061 100644 --- a/crates/pod/src/permission.rs +++ b/crates/pod/src/permission.rs @@ -1,13 +1,11 @@ use async_trait::async_trait; -use llm_worker::interceptor::PreToolAction; use llm_worker::llm_client::client::LlmClient; -use llm_worker::tool::ToolResult; use manifest::{ToolPermissionAction, ToolPermissionConfig}; use serde_json::Value; use session_store::Store; use crate::Pod; -use crate::hook::{Hook, PreToolCall, ToolCallSummary}; +use crate::hook::{Hook, HookPreToolAction, PreToolCall, ToolCallSummary}; /// Built-in manifest permission policy for `PreToolCall`. /// @@ -47,34 +45,28 @@ impl Pod { #[async_trait] impl Hook for PermissionHook { - async fn call(&self, input: &ToolCallSummary) -> PreToolAction { + async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction { match self.action_for(input) { - ToolPermissionAction::Allow => PreToolAction::Continue, - ToolPermissionAction::Deny => PreToolAction::SyntheticResult(permission_denied(input)), + ToolPermissionAction::Allow => HookPreToolAction::Continue, + ToolPermissionAction::Deny => HookPreToolAction::Deny(permission_denied_message(input)), ToolPermissionAction::Ask => { - PreToolAction::SyntheticResult(permission_ask_unsupported(input)) + HookPreToolAction::Deny(permission_ask_unsupported_message(input)) } } } } -fn permission_denied(input: &ToolCallSummary) -> ToolResult { - ToolResult::error( - input.call_id.clone(), - format!( - "permission denied: tool `{}` arguments matched the manifest permission policy", - input.tool_name - ), +fn permission_denied_message(input: &ToolCallSummary) -> String { + format!( + "permission denied: tool `{}` arguments matched the manifest permission policy", + input.tool_name ) } -fn permission_ask_unsupported(input: &ToolCallSummary) -> ToolResult { - ToolResult::error( - input.call_id.clone(), - format!( - "permission ask unsupported: tool `{}` requires approval, but this runtime has no permission approval protocol; denied fail-closed", - input.tool_name - ), +fn permission_ask_unsupported_message(input: &ToolCallSummary) -> String { + format!( + "permission ask unsupported: tool `{}` requires approval, but this runtime has no permission approval protocol; denied fail-closed", + input.tool_name ) } @@ -123,6 +115,7 @@ fn wildcard_match(pattern: &str, text: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::hook::HookPreToolAction; use manifest::ToolPermissionRule; fn summary(tool_name: &str, arguments: Value) -> ToolCallSummary { @@ -168,6 +161,45 @@ mod tests { assert_eq!(hook.action_for(&input), ToolPermissionAction::Deny); } + #[tokio::test] + async fn deny_and_ask_fail_closed_as_public_deny_actions() { + let deny = PermissionHook::new(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: Vec::new(), + }); + let denied = deny + .call(&summary( + "Bash", + serde_json::json!({ "command": "rm -rf target" }), + )) + .await; + match denied { + HookPreToolAction::Deny(message) => { + assert!(message.contains("permission denied")); + assert!(message.contains("Bash")); + } + other => panic!("expected fail-closed deny action, got {other:?}"), + } + + let ask = PermissionHook::new(ToolPermissionConfig { + default_action: ToolPermissionAction::Ask, + rules: Vec::new(), + }); + let asked = ask + .call(&summary( + "Bash", + serde_json::json!({ "command": "git status" }), + )) + .await; + match asked { + HookPreToolAction::Deny(message) => { + assert!(message.contains("permission ask unsupported")); + assert!(message.contains("denied fail-closed")); + } + other => panic!("expected ask fail-closed deny action, got {other:?}"), + } + } + #[test] fn target_prefers_known_builtin_argument_fields() { assert_eq!( diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index aefe3f24..3f5b2f85 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -29,8 +29,8 @@ use manifest::{ use crate::compact::state::CompactState; use crate::compact::usage_tracker::UsageTracker; use crate::hook::{ - Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest, - PreRequestInfo, PreToolCall, + Hook, HookPreRequestAction, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, + PostToolCall, PreLlmRequest, PreRequestInfo, PreToolCall, }; use crate::ipc::alerter::Alerter; use crate::ipc::interceptor::{PodInterceptor, TaskReminderState}; @@ -43,7 +43,6 @@ use crate::runtime::dir; use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError}; use crate::workflow::WorkflowResolveError; use async_trait::async_trait; -use llm_worker::interceptor::PreRequestAction; use protocol::{ AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment, }; @@ -221,9 +220,9 @@ struct UsageTrackingHook { #[async_trait] impl Hook for UsageTrackingHook { - async fn call(&self, info: &PreRequestInfo) -> PreRequestAction { + async fn call(&self, info: &PreRequestInfo) -> HookPreRequestAction { self.tracker.note_request(info.item_count); - PreRequestAction::Continue + HookPreRequestAction::Continue } } From a4e30e292abf5c640b923e3307a75eded366351a Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 4 Jun 2026 02:02:52 +0900 Subject: [PATCH 2/2] fix: remove public hook skip action --- crates/pod/src/hook.rs | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/pod/src/hook.rs b/crates/pod/src/hook.rs index 21534e5d..a24d4d2b 100644 --- a/crates/pod/src/hook.rs +++ b/crates/pod/src/hook.rs @@ -73,16 +73,14 @@ impl From for PreRequestAction { /// Hook-facing pre-tool-call action. /// -/// Hooks may continue, skip/pause/abort the call, or deny it with an error +/// Hooks may continue, pause/abort the call, or deny it with an error /// string that Pod converts into a synthetic tool result for the current -/// tool call. Hooks cannot mutate the tool call arguments or construct -/// arbitrary `ToolResult` values. +/// tool call. Hooks cannot express the internal no-result skip path, mutate +/// the tool call arguments, or construct arbitrary `ToolResult` values. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HookPreToolAction { /// Proceed with tool execution. Continue, - /// Skip this tool call without executing it. - Skip, /// Deny this tool call and commit a synthetic error result. Deny(String), /// Abort the entire run. @@ -95,7 +93,6 @@ impl HookPreToolAction { pub(crate) fn into_worker_action(self, call_id: String) -> PreToolAction { match self { HookPreToolAction::Continue => PreToolAction::Continue, - HookPreToolAction::Skip => PreToolAction::Skip, HookPreToolAction::Deny(reason) => { PreToolAction::SyntheticResult(ToolResult::error(call_id, reason)) } @@ -239,7 +236,8 @@ pub trait HookEventKind: Send + Sync + 'static { pub struct OnPromptSubmit; /// Before each LLM request; may continue, cancel, or yield. pub struct PreLlmRequest; -/// Before each tool is executed; may continue, skip, deny, abort, or pause. +/// Before each tool is executed; may continue, deny with a synthetic result, +/// abort, or pause. pub struct PreToolCall; /// After each tool completes; observational except it may abort the run. pub struct PostToolCall; @@ -362,3 +360,32 @@ pub struct HookRegistry { pub(crate) on_turn_end: Vec>>, pub(crate) on_abort: Vec>>, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_pre_tool_hook_actions_cannot_emit_internal_no_result_skip() { + let continue_action = HookPreToolAction::Continue.into_worker_action("call_1".into()); + assert!(matches!(continue_action, PreToolAction::Continue)); + + let deny_action = + HookPreToolAction::Deny("blocked".into()).into_worker_action("call_2".into()); + match deny_action { + PreToolAction::SyntheticResult(result) => { + assert_eq!(result.tool_use_id, "call_2"); + assert_eq!(result.summary, "blocked"); + assert!(result.is_error); + } + other => panic!("public deny must produce synthetic result, got {other:?}"), + } + + let abort_action = + HookPreToolAction::Abort("stop".into()).into_worker_action("call_3".into()); + assert!(matches!(abort_action, PreToolAction::Abort(reason) if reason == "stop")); + + let pause_action = HookPreToolAction::Pause.into_worker_action("call_4".into()); + assert!(matches!(pause_action, PreToolAction::Pause)); + } +}