From 94199beabb1e1fa456ce9e25449d82ba67a06e1b Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 04:20:16 +0000 Subject: [PATCH 1/6] wip: hook pipeline progress --- rust/crates/runtime/src/config.rs | 75 ++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 368e7c5..9601eb5 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -42,6 +42,7 @@ pub struct RuntimeFeatureConfig { oauth: Option, model: Option, permission_mode: Option, + permission_rules: RuntimePermissionRuleConfig, sandbox: SandboxConfig, } @@ -49,6 +50,14 @@ pub struct RuntimeFeatureConfig { pub struct RuntimeHookConfig { pre_tool_use: Vec, post_tool_use: Vec, + post_tool_use_failure: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RuntimePermissionRuleConfig { + allow: Vec, + deny: Vec, + ask: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -235,6 +244,7 @@ impl ConfigLoader { oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, model: parse_optional_model(&merged_value), permission_mode: parse_optional_permission_mode(&merged_value)?, + permission_rules: parse_optional_permission_rules(&merged_value)?, sandbox: parse_optional_sandbox_config(&merged_value)?, }; @@ -344,6 +354,11 @@ impl RuntimeFeatureConfig { self.permission_mode } + #[must_use] + pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig { + &self.permission_rules + } + #[must_use] pub fn sandbox(&self) -> &SandboxConfig { &self.sandbox @@ -352,10 +367,15 @@ impl RuntimeFeatureConfig { impl RuntimeHookConfig { #[must_use] - pub fn new(pre_tool_use: Vec, post_tool_use: Vec) -> Self { + pub fn new( + pre_tool_use: Vec, + post_tool_use: Vec, + post_tool_use_failure: Vec, + ) -> Self { Self { pre_tool_use, post_tool_use, + post_tool_use_failure, } } @@ -368,6 +388,33 @@ impl RuntimeHookConfig { pub fn post_tool_use(&self) -> &[String] { &self.post_tool_use } + + #[must_use] + pub fn post_tool_use_failure(&self) -> &[String] { + &self.post_tool_use_failure + } +} + +impl RuntimePermissionRuleConfig { + #[must_use] + pub fn new(allow: Vec, deny: Vec, ask: Vec) -> Self { + Self { allow, deny, ask } + } + + #[must_use] + pub fn allow(&self) -> &[String] { + &self.allow + } + + #[must_use] + pub fn deny(&self) -> &[String] { + &self.deny + } + + #[must_use] + pub fn ask(&self) -> &[String] { + &self.ask + } } impl McpConfigCollection { @@ -481,6 +528,32 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result Result { + let Some(object) = root.as_object() else { + return Ok(RuntimePermissionRuleConfig::default()); + }; + let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) else { + return Ok(RuntimePermissionRuleConfig::default()); + }; + + Ok(RuntimePermissionRuleConfig { + allow: optional_string_array(permissions, "allow", "merged settings.permissions")? + .unwrap_or_default(), + deny: optional_string_array(permissions, "deny", "merged settings.permissions")? + .unwrap_or_default(), + ask: optional_string_array(permissions, "ask", "merged settings.permissions")? + .unwrap_or_default(), }) } From eb89fc95e746692af44c871a1278fab83311e3f2 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 04:30:25 +0000 Subject: [PATCH 2/6] wip: hook-pipeline progress --- rust/crates/runtime/src/config.rs | 19 +- rust/crates/runtime/src/conversation.rs | 156 ++++-- rust/crates/runtime/src/hooks.rs | 624 +++++++++++++++++++++-- rust/crates/runtime/src/lib.rs | 10 +- rust/crates/runtime/src/permissions.rs | 495 +++++++++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 19 +- 6 files changed, 1184 insertions(+), 139 deletions(-) diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 9601eb5..bb91474 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -316,6 +316,11 @@ impl RuntimeConfig { self.feature_config.permission_mode } + #[must_use] + pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig { + &self.feature_config.permission_rules + } + #[must_use] pub fn sandbox(&self) -> &SandboxConfig { &self.feature_config.sandbox @@ -916,7 +921,7 @@ mod tests { .expect("write user compat config"); fs::write( home.join("settings.json"), - r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#, + r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan","allow":["Read"],"deny":["Bash(rm -rf)"]}}"#, ) .expect("write user settings"); fs::write( @@ -926,7 +931,7 @@ mod tests { .expect("write project compat config"); fs::write( cwd.join(".claude").join("settings.json"), - r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#, + r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"],"PostToolUseFailure":["project-failure"]},"permissions":{"ask":["Edit"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#, ) .expect("write project settings"); fs::write( @@ -971,6 +976,16 @@ mod tests { .contains_key("PostToolUse")); assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]); assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]); + assert_eq!( + loaded.hooks().post_tool_use_failure(), + &["project-failure".to_string()] + ); + assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]); + assert_eq!( + loaded.permission_rules().deny(), + &["Bash(rm -rf)".to_string()] + ); + assert_eq!(loaded.permission_rules().ask(), &["Edit".to_string()]); assert!(loaded.mcp().get("home").is_some()); assert!(loaded.mcp().get("project").is_some()); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 4ffbabc..2c5f6ea 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -5,8 +5,10 @@ use crate::compact::{ compact_session, estimate_session_tokens, CompactionConfig, CompactionResult, }; use crate::config::RuntimeFeatureConfig; -use crate::hooks::{HookRunResult, HookRunner}; -use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter}; +use crate::hooks::{HookAbortSignal, HookProgressReporter, HookRunResult, HookRunner}; +use crate::permissions::{ + PermissionContext, PermissionOutcome, PermissionPolicy, PermissionPrompter, +}; use crate::session::{ContentBlock, ConversationMessage, Session}; use crate::usage::{TokenUsage, UsageTracker}; @@ -97,6 +99,8 @@ pub struct ConversationRuntime { max_iterations: usize, usage_tracker: UsageTracker, hook_runner: HookRunner, + hook_abort_signal: HookAbortSignal, + hook_progress_reporter: Option>, } impl ConversationRuntime @@ -118,7 +122,7 @@ where tool_executor, permission_policy, system_prompt, - RuntimeFeatureConfig::default(), + &RuntimeFeatureConfig::default(), ) } @@ -129,7 +133,7 @@ where tool_executor: T, permission_policy: PermissionPolicy, system_prompt: Vec, - feature_config: RuntimeFeatureConfig, + feature_config: &RuntimeFeatureConfig, ) -> Self { let usage_tracker = UsageTracker::from_session(&session); Self { @@ -140,7 +144,8 @@ where system_prompt, max_iterations: usize::MAX, usage_tracker, - hook_runner: HookRunner::from_feature_config(&feature_config), + hook_runner: HookRunner::from_feature_config(feature_config), + hook_abort_signal: HookAbortSignal::default(), } } @@ -150,6 +155,22 @@ where self } + #[must_use] + pub fn with_hook_abort_signal(mut self, hook_abort_signal: HookAbortSignal) -> Self { + self.hook_abort_signal = hook_abort_signal; + self + } + + #[must_use] + pub fn with_hook_progress_reporter( + mut self, + hook_progress_reporter: Box, + ) -> Self { + self.hook_progress_reporter = Some(hook_progress_reporter); + self + } + + #[allow(clippy::too_many_lines)] pub fn run_turn( &mut self, user_input: impl Into, @@ -199,55 +220,94 @@ where } for (tool_use_id, tool_name, input) in pending_tool_uses { - let permission_outcome = if let Some(prompt) = prompter.as_mut() { - self.permission_policy - .authorize(&tool_name, &input, Some(*prompt)) + let pre_hook_result = self.hook_runner.run_pre_tool_use_with_context( + &tool_name, + &input, + Some(&self.hook_abort_signal), + self.hook_progress_reporter.as_deref_mut(), + ); + let effective_input = pre_hook_result + .updated_input_json() + .map_or_else(|| input.clone(), ToOwned::to_owned); + let permission_context = PermissionContext::new( + pre_hook_result.permission_decision(), + pre_hook_result.permission_reason().map(ToOwned::to_owned), + ); + + let permission_outcome = if pre_hook_result.is_cancelled() { + PermissionOutcome::Deny { + reason: format_hook_message( + &pre_hook_result, + &format!("PreToolUse hook cancelled tool `{tool_name}`"), + ), + } + } else if pre_hook_result.is_denied() { + PermissionOutcome::Deny { + reason: format_hook_message( + &pre_hook_result, + &format!("PreToolUse hook denied tool `{tool_name}`"), + ), + } + } else if let Some(prompt) = prompter.as_mut() { + self.permission_policy.authorize_with_context( + &tool_name, + &effective_input, + &permission_context, + Some(*prompt), + ) } else { - self.permission_policy.authorize(&tool_name, &input, None) + self.permission_policy.authorize_with_context( + &tool_name, + &effective_input, + &permission_context, + None, + ) }; let result_message = match permission_outcome { PermissionOutcome::Allow => { - let pre_hook_result = self.hook_runner.run_pre_tool_use(&tool_name, &input); - if pre_hook_result.is_denied() { - let deny_message = format!("PreToolUse hook denied tool `{tool_name}`"); - ConversationMessage::tool_result( - tool_use_id, - tool_name, - format_hook_message(&pre_hook_result, &deny_message), - true, + let (mut output, mut is_error) = + match self.tool_executor.execute(&tool_name, &effective_input) { + Ok(output) => (output, false), + Err(error) => (error.to_string(), true), + }; + output = merge_hook_feedback(pre_hook_result.messages(), output, false); + + let post_hook_result = if is_error { + self.hook_runner.run_post_tool_use_failure_with_context( + &tool_name, + &effective_input, + &output, + Some(&self.hook_abort_signal), + self.hook_progress_reporter.as_deref_mut(), ) } else { - let (mut output, mut is_error) = - match self.tool_executor.execute(&tool_name, &input) { - Ok(output) => (output, false), - Err(error) => (error.to_string(), true), - }; - output = merge_hook_feedback(pre_hook_result.messages(), output, false); - - let post_hook_result = self - .hook_runner - .run_post_tool_use(&tool_name, &input, &output, is_error); - if post_hook_result.is_denied() { - is_error = true; - } - output = merge_hook_feedback( - post_hook_result.messages(), - output, - post_hook_result.is_denied(), - ); - - ConversationMessage::tool_result( - tool_use_id, - tool_name, - output, - is_error, + self.hook_runner.run_post_tool_use_with_context( + &tool_name, + &effective_input, + &output, + false, + Some(&self.hook_abort_signal), + self.hook_progress_reporter.as_deref_mut(), ) + }; + if post_hook_result.is_denied() || post_hook_result.is_cancelled() { + is_error = true; } + output = merge_hook_feedback( + post_hook_result.messages(), + output, + post_hook_result.is_denied() || post_hook_result.is_cancelled(), + ); + + ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error) } - PermissionOutcome::Deny { reason } => { - ConversationMessage::tool_result(tool_use_id, tool_name, reason, true) - } + PermissionOutcome::Deny { reason } => ConversationMessage::tool_result( + tool_use_id, + tool_name, + merge_hook_feedback(pre_hook_result.messages(), reason, true), + true, + ), }; self.session.messages.push(result_message.clone()); tool_results.push(result_message); @@ -609,9 +669,10 @@ mod tests { }), PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], - RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( + &RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( vec![shell_snippet("printf 'blocked by hook'; exit 2")], Vec::new(), + Vec::new(), )), ); @@ -675,9 +736,10 @@ mod tests { StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())), PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], - RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( + &RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( vec![shell_snippet("printf 'pre hook ran'")], vec![shell_snippet("printf 'post hook ran'")], + Vec::new(), )), ); @@ -697,7 +759,7 @@ mod tests { "post hook should preserve non-error result: {output:?}" ); assert!( - output.contains("4"), + output.contains('4'), "tool output missing value: {output:?}" ); assert!( diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs index 36756a0..3af6cbd 100644 --- a/rust/crates/runtime/src/hooks.rs +++ b/rust/crates/runtime/src/hooks.rs @@ -1,29 +1,92 @@ use std::ffi::OsStr; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::Duration; -use serde_json::json; +use serde_json::{json, Value}; +use tokio::io::AsyncWriteExt; +use tokio::process::Command as TokioCommand; +use tokio::runtime::Builder; +use tokio::time::sleep; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; +use crate::permissions::PermissionOverride; + +pub type HookPermissionDecision = PermissionOverride; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HookEvent { PreToolUse, PostToolUse, + PostToolUseFailure, } impl HookEvent { - fn as_str(self) -> &'static str { + #[must_use] + pub fn as_str(self) -> &'static str { match self { Self::PreToolUse => "PreToolUse", Self::PostToolUse => "PostToolUse", + Self::PostToolUseFailure => "PostToolUseFailure", } } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookProgressEvent { + Started { + event: HookEvent, + tool_name: String, + command: String, + }, + Completed { + event: HookEvent, + tool_name: String, + command: String, + }, + Cancelled { + event: HookEvent, + tool_name: String, + command: String, + }, +} + +pub trait HookProgressReporter { + fn on_event(&mut self, event: &HookProgressEvent); +} + +#[derive(Debug, Clone, Default)] +pub struct HookAbortSignal { + aborted: Arc, +} + +impl HookAbortSignal { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn abort(&self) { + self.aborted.store(true, Ordering::SeqCst); + } + + #[must_use] + pub fn is_aborted(&self) -> bool { + self.aborted.load(Ordering::SeqCst) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookRunResult { denied: bool, + cancelled: bool, messages: Vec, + permission_override: Option, + permission_reason: Option, + updated_input: Option, } impl HookRunResult { @@ -31,7 +94,11 @@ impl HookRunResult { pub fn allow(messages: Vec) -> Self { Self { denied: false, + cancelled: false, messages, + permission_override: None, + permission_reason: None, + updated_input: None, } } @@ -40,10 +107,40 @@ impl HookRunResult { self.denied } + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.cancelled + } + #[must_use] pub fn messages(&self) -> &[String] { &self.messages } + + #[must_use] + pub fn permission_override(&self) -> Option { + self.permission_override + } + + #[must_use] + pub fn permission_decision(&self) -> Option { + self.permission_override + } + + #[must_use] + pub fn permission_reason(&self) -> Option<&str> { + self.permission_reason.as_deref() + } + + #[must_use] + pub fn updated_input(&self) -> Option<&str> { + self.updated_input.as_deref() + } + + #[must_use] + pub fn updated_input_json(&self) -> Option<&str> { + self.updated_input() + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -64,6 +161,17 @@ impl HookRunner { #[must_use] pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult { + self.run_pre_tool_use_with_context(tool_name, tool_input, None, None) + } + + #[must_use] + pub fn run_pre_tool_use_with_context( + &self, + tool_name: &str, + tool_input: &str, + abort_signal: Option<&HookAbortSignal>, + reporter: Option<&mut dyn HookProgressReporter>, + ) -> HookRunResult { self.run_commands( HookEvent::PreToolUse, self.config.pre_tool_use(), @@ -71,9 +179,21 @@ impl HookRunner { tool_input, None, false, + abort_signal, + reporter, ) } + #[must_use] + pub fn run_pre_tool_use_with_signal( + &self, + tool_name: &str, + tool_input: &str, + abort_signal: Option<&HookAbortSignal>, + ) -> HookRunResult { + self.run_pre_tool_use_with_context(tool_name, tool_input, abort_signal, None) + } + #[must_use] pub fn run_post_tool_use( &self, @@ -81,6 +201,26 @@ impl HookRunner { tool_input: &str, tool_output: &str, is_error: bool, + ) -> HookRunResult { + self.run_post_tool_use_with_context( + tool_name, + tool_input, + tool_output, + is_error, + None, + None, + ) + } + + #[must_use] + pub fn run_post_tool_use_with_context( + &self, + tool_name: &str, + tool_input: &str, + tool_output: &str, + is_error: bool, + abort_signal: Option<&HookAbortSignal>, + reporter: Option<&mut dyn HookProgressReporter>, ) -> HookRunResult { self.run_commands( HookEvent::PostToolUse, @@ -89,9 +229,79 @@ impl HookRunner { tool_input, Some(tool_output), is_error, + abort_signal, + reporter, ) } + #[must_use] + pub fn run_post_tool_use_with_signal( + &self, + tool_name: &str, + tool_input: &str, + tool_output: &str, + is_error: bool, + abort_signal: Option<&HookAbortSignal>, + ) -> HookRunResult { + self.run_post_tool_use_with_context( + tool_name, + tool_input, + tool_output, + is_error, + abort_signal, + None, + ) + } + + #[must_use] + pub fn run_post_tool_use_failure( + &self, + tool_name: &str, + tool_input: &str, + tool_error: &str, + ) -> HookRunResult { + self.run_post_tool_use_failure_with_context(tool_name, tool_input, tool_error, None, None) + } + + #[must_use] + pub fn run_post_tool_use_failure_with_context( + &self, + tool_name: &str, + tool_input: &str, + tool_error: &str, + abort_signal: Option<&HookAbortSignal>, + reporter: Option<&mut dyn HookProgressReporter>, + ) -> HookRunResult { + self.run_commands( + HookEvent::PostToolUseFailure, + self.config.post_tool_use_failure(), + tool_name, + tool_input, + Some(tool_error), + true, + abort_signal, + reporter, + ) + } + + #[must_use] + pub fn run_post_tool_use_failure_with_signal( + &self, + tool_name: &str, + tool_input: &str, + tool_error: &str, + abort_signal: Option<&HookAbortSignal>, + ) -> HookRunResult { + self.run_post_tool_use_failure_with_context( + tool_name, + tool_input, + tool_error, + abort_signal, + None, + ) + } + + #[allow(clippy::too_many_arguments)] fn run_commands( &self, event: HookEvent, @@ -100,25 +310,40 @@ impl HookRunner { tool_input: &str, tool_output: Option<&str>, is_error: bool, + abort_signal: Option<&HookAbortSignal>, + mut reporter: Option<&mut dyn HookProgressReporter>, ) -> HookRunResult { if commands.is_empty() { return HookRunResult::allow(Vec::new()); } - let payload = json!({ - "hook_event_name": event.as_str(), - "tool_name": tool_name, - "tool_input": parse_tool_input(tool_input), - "tool_input_json": tool_input, - "tool_output": tool_output, - "tool_result_is_error": is_error, - }) - .to_string(); + if abort_signal.is_some_and(HookAbortSignal::is_aborted) { + return HookRunResult { + denied: false, + cancelled: true, + messages: vec![format!( + "{} hook cancelled before execution", + event.as_str() + )], + permission_override: None, + permission_reason: None, + updated_input: None, + }; + } - let mut messages = Vec::new(); + let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string(); + let mut result = HookRunResult::allow(Vec::new()); for command in commands { - match self.run_command( + if let Some(reporter) = reporter.as_deref_mut() { + reporter.on_event(&HookProgressEvent::Started { + event, + tool_name: tool_name.to_string(), + command: command.clone(), + }); + } + + match Self::run_command( command, event, tool_name, @@ -126,31 +351,60 @@ impl HookRunner { tool_output, is_error, &payload, + abort_signal, ) { - HookCommandOutcome::Allow { message } => { - if let Some(message) = message { - messages.push(message); + HookCommandOutcome::Allow { parsed } => { + if let Some(reporter) = reporter.as_deref_mut() { + reporter.on_event(&HookProgressEvent::Completed { + event, + tool_name: tool_name.to_string(), + command: command.clone(), + }); } + merge_parsed_hook_output(&mut result, parsed); } - HookCommandOutcome::Deny { message } => { - let message = message.unwrap_or_else(|| { - format!("{} hook denied tool `{tool_name}`", event.as_str()) - }); - messages.push(message); - return HookRunResult { - denied: true, - messages, - }; + HookCommandOutcome::Deny { parsed } => { + if let Some(reporter) = reporter.as_deref_mut() { + reporter.on_event(&HookProgressEvent::Completed { + event, + tool_name: tool_name.to_string(), + command: command.clone(), + }); + } + merge_parsed_hook_output(&mut result, parsed); + result.denied = true; + return result; + } + HookCommandOutcome::Warn { message } => { + if let Some(reporter) = reporter.as_deref_mut() { + reporter.on_event(&HookProgressEvent::Completed { + event, + tool_name: tool_name.to_string(), + command: command.clone(), + }); + } + result.messages.push(message); + } + HookCommandOutcome::Cancelled { message } => { + if let Some(reporter) = reporter.as_deref_mut() { + reporter.on_event(&HookProgressEvent::Cancelled { + event, + tool_name: tool_name.to_string(), + command: command.clone(), + }); + } + result.cancelled = true; + result.messages.push(message); + return result; } - HookCommandOutcome::Warn { message } => messages.push(message), } } - HookRunResult::allow(messages) + result } + #[allow(clippy::too_many_arguments)] fn run_command( - &self, command: &str, event: HookEvent, tool_name: &str, @@ -158,11 +412,12 @@ impl HookRunner { tool_output: Option<&str>, is_error: bool, payload: &str, + abort_signal: Option<&HookAbortSignal>, ) -> HookCommandOutcome { let mut child = shell_command(command); - child.stdin(std::process::Stdio::piped()); - child.stdout(std::process::Stdio::piped()); - child.stderr(std::process::Stdio::piped()); + child.stdin(Stdio::piped()); + child.stdout(Stdio::piped()); + child.stderr(Stdio::piped()); child.env("HOOK_EVENT", event.as_str()); child.env("HOOK_TOOL_NAME", tool_name); child.env("HOOK_TOOL_INPUT", tool_input); @@ -171,19 +426,30 @@ impl HookRunner { child.env("HOOK_TOOL_OUTPUT", tool_output); } - match child.output_with_stdin(payload.as_bytes()) { - Ok(output) => { + match child.output_with_stdin(payload.as_bytes(), abort_signal) { + Ok(CommandExecution::Finished(output)) => { let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let message = (!stdout.is_empty()).then_some(stdout); + let parsed = parse_hook_output(&stdout); match output.status.code() { - Some(0) => HookCommandOutcome::Allow { message }, - Some(2) => HookCommandOutcome::Deny { message }, + Some(0) => { + if parsed.deny { + HookCommandOutcome::Deny { parsed } + } else { + HookCommandOutcome::Allow { parsed } + } + } + Some(2) => HookCommandOutcome::Deny { + parsed: parsed.with_fallback_message(format!( + "{} hook denied tool `{tool_name}`", + event.as_str() + )), + }, Some(code) => HookCommandOutcome::Warn { message: format_hook_warning( command, code, - message.as_deref(), + parsed.primary_message(), stderr.as_str(), ), }, @@ -195,6 +461,12 @@ impl HookRunner { }, } } + Ok(CommandExecution::Cancelled) => HookCommandOutcome::Cancelled { + message: format!( + "{} hook `{command}` cancelled while handling `{tool_name}`", + event.as_str() + ), + }, Err(error) => HookCommandOutcome::Warn { message: format!( "{} hook `{command}` failed to start for `{tool_name}`: {error}", @@ -206,12 +478,131 @@ impl HookRunner { } enum HookCommandOutcome { - Allow { message: Option }, - Deny { message: Option }, + Allow { parsed: ParsedHookOutput }, + Deny { parsed: ParsedHookOutput }, Warn { message: String }, + Cancelled { message: String }, } -fn parse_tool_input(tool_input: &str) -> serde_json::Value { +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct ParsedHookOutput { + messages: Vec, + deny: bool, + permission_override: Option, + permission_reason: Option, + updated_input: Option, +} + +impl ParsedHookOutput { + fn with_fallback_message(mut self, fallback: String) -> Self { + if self.messages.is_empty() { + self.messages.push(fallback); + } + self + } + + fn primary_message(&self) -> Option<&str> { + self.messages.first().map(String::as_str) + } +} + +fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput) { + target.messages.extend(parsed.messages); + if parsed.permission_override.is_some() { + target.permission_override = parsed.permission_override; + } + if parsed.permission_reason.is_some() { + target.permission_reason = parsed.permission_reason; + } + if parsed.updated_input.is_some() { + target.updated_input = parsed.updated_input; + } +} + +fn parse_hook_output(stdout: &str) -> ParsedHookOutput { + if stdout.is_empty() { + return ParsedHookOutput::default(); + } + + let Ok(Value::Object(root)) = serde_json::from_str::(stdout) else { + return ParsedHookOutput { + messages: vec![stdout.to_string()], + ..ParsedHookOutput::default() + }; + }; + + let mut parsed = ParsedHookOutput::default(); + + if let Some(message) = root.get("systemMessage").and_then(Value::as_str) { + parsed.messages.push(message.to_string()); + } + if let Some(message) = root.get("reason").and_then(Value::as_str) { + parsed.messages.push(message.to_string()); + } + if root.get("continue").and_then(Value::as_bool) == Some(false) + || root.get("decision").and_then(Value::as_str) == Some("block") + { + parsed.deny = true; + } + + if let Some(Value::Object(specific)) = root.get("hookSpecificOutput") { + if let Some(Value::String(additional_context)) = specific.get("additionalContext") { + parsed.messages.push(additional_context.clone()); + } + if let Some(decision) = specific.get("permissionDecision").and_then(Value::as_str) { + parsed.permission_override = match decision { + "allow" => Some(PermissionOverride::Allow), + "deny" => Some(PermissionOverride::Deny), + "ask" => Some(PermissionOverride::Ask), + _ => None, + }; + } + if let Some(reason) = specific + .get("permissionDecisionReason") + .and_then(Value::as_str) + { + parsed.permission_reason = Some(reason.to_string()); + } + if let Some(updated_input) = specific.get("updatedInput") { + parsed.updated_input = serde_json::to_string(updated_input).ok(); + } + } + + if parsed.messages.is_empty() { + parsed.messages.push(stdout.to_string()); + } + + parsed +} + +fn hook_payload( + event: HookEvent, + tool_name: &str, + tool_input: &str, + tool_output: Option<&str>, + is_error: bool, +) -> Value { + match event { + HookEvent::PostToolUseFailure => json!({ + "hook_event_name": event.as_str(), + "tool_name": tool_name, + "tool_input": parse_tool_input(tool_input), + "tool_input_json": tool_input, + "tool_error": tool_output, + "tool_result_is_error": true, + }), + _ => json!({ + "hook_event_name": event.as_str(), + "tool_name": tool_name, + "tool_input": parse_tool_input(tool_input), + "tool_input_json": tool_input, + "tool_output": tool_output, + "tool_result_is_error": is_error, + }), + } +} + +fn parse_tool_input(tool_input: &str) -> Value { serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input })) } @@ -255,17 +646,17 @@ impl CommandWithStdin { Self { command } } - fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self { + fn stdin(&mut self, cfg: Stdio) -> &mut Self { self.command.stdin(cfg); self } - fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self { + fn stdout(&mut self, cfg: Stdio) -> &mut Self { self.command.stdout(cfg); self } - fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self { + fn stderr(&mut self, cfg: Stdio) -> &mut Self { self.command.stderr(cfg); self } @@ -279,26 +670,77 @@ impl CommandWithStdin { self } - fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result { - let mut child = self.command.spawn()?; - if let Some(mut child_stdin) = child.stdin.take() { - use std::io::Write; - child_stdin.write_all(stdin)?; - } - child.wait_with_output() + fn output_with_stdin( + &mut self, + stdin: &[u8], + abort_signal: Option<&HookAbortSignal>, + ) -> std::io::Result { + let runtime = Builder::new_current_thread().enable_all().build()?; + let mut command = + TokioCommand::from(std::mem::replace(&mut self.command, Command::new("true"))); + let stdin = stdin.to_vec(); + let abort_signal = abort_signal.cloned(); + runtime.block_on(async move { + let mut child = command.spawn()?; + if let Some(mut child_stdin) = child.stdin.take() { + child_stdin.write_all(&stdin).await?; + } + + loop { + if abort_signal + .as_ref() + .is_some_and(HookAbortSignal::is_aborted) + { + let _ = child.start_kill(); + let _ = child.wait().await; + return Ok(CommandExecution::Cancelled); + } + + if let Some(status) = child.try_wait()? { + let output = child.wait_with_output().await?; + debug_assert_eq!(output.status.code(), status.code()); + return Ok(CommandExecution::Finished(output)); + } + + sleep(Duration::from_millis(20)).await; + } + }) } } +enum CommandExecution { + Finished(std::process::Output), + Cancelled, +} + #[cfg(test)] mod tests { - use super::{HookRunResult, HookRunner}; + use std::thread; + use std::time::Duration; + + use super::{ + HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, + HookRunner, + }; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; + use crate::permissions::PermissionOverride; + + struct RecordingReporter { + events: Vec, + } + + impl HookProgressReporter for RecordingReporter { + fn on_event(&mut self, event: &HookProgressEvent) { + self.events.push(event.clone()); + } + } #[test] fn allows_exit_code_zero_and_captures_stdout() { let runner = HookRunner::new(RuntimeHookConfig::new( vec![shell_snippet("printf 'pre ok'")], Vec::new(), + Vec::new(), )); let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#); @@ -311,6 +753,7 @@ mod tests { let runner = HookRunner::new(RuntimeHookConfig::new( vec![shell_snippet("printf 'blocked by hook'; exit 2")], Vec::new(), + Vec::new(), )); let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#); @@ -325,6 +768,7 @@ mod tests { RuntimeHookConfig::new( vec![shell_snippet("printf 'warning hook'; exit 1")], Vec::new(), + Vec::new(), ), )); @@ -337,6 +781,82 @@ mod tests { .any(|message| message.contains("allowing tool execution to continue"))); } + #[test] + fn parses_pre_hook_permission_override_and_updated_input() { + let runner = HookRunner::new(RuntimeHookConfig::new( + vec![shell_snippet( + r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#, + )], + Vec::new(), + Vec::new(), + )); + + let result = runner.run_pre_tool_use("bash", r#"{"command":"pwd"}"#); + + assert_eq!( + result.permission_override(), + Some(PermissionOverride::Allow) + ); + assert_eq!(result.permission_reason(), Some("hook ok")); + assert_eq!(result.updated_input(), Some(r#"{"command":"git status"}"#)); + assert!(result.messages().iter().any(|message| message == "updated")); + } + + #[test] + fn runs_post_tool_use_failure_hooks() { + let runner = HookRunner::new(RuntimeHookConfig::new( + Vec::new(), + Vec::new(), + vec![shell_snippet("printf 'failure hook ran'")], + )); + + let result = + runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed"); + + assert!(!result.is_denied()); + assert_eq!(result.messages(), &["failure hook ran".to_string()]); + } + + #[test] + fn abort_signal_cancels_long_running_hook_and_reports_progress() { + let runner = HookRunner::new(RuntimeHookConfig::new( + vec![shell_snippet("sleep 5")], + Vec::new(), + Vec::new(), + )); + let abort_signal = HookAbortSignal::new(); + let abort_signal_for_thread = abort_signal.clone(); + let mut reporter = RecordingReporter { events: Vec::new() }; + + thread::spawn(move || { + thread::sleep(Duration::from_millis(100)); + abort_signal_for_thread.abort(); + }); + + let result = runner.run_pre_tool_use_with_context( + "bash", + r#"{"command":"sleep 5"}"#, + Some(&abort_signal), + Some(&mut reporter), + ); + + assert!(result.is_cancelled()); + assert!(reporter.events.iter().any(|event| matches!( + event, + HookProgressEvent::Started { + event: HookEvent::PreToolUse, + .. + } + ))); + assert!(reporter.events.iter().any(|event| matches!( + event, + HookProgressEvent::Cancelled { + event: HookEvent::PreToolUse, + .. + } + ))); + } + #[cfg(windows)] fn shell_snippet(script: &str) -> String { script.replace('\'', "\"") diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index da745e5..f56291a 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -28,7 +28,7 @@ pub use config::{ McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, - ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + RuntimePermissionRuleConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, @@ -39,7 +39,9 @@ pub use file_ops::{ GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput, }; -pub use hooks::{HookEvent, HookRunResult, HookRunner}; +pub use hooks::{ + HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner, +}; pub use mcp::{ mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp, scoped_mcp_config_hash, unwrap_ccr_proxy_url, @@ -64,8 +66,8 @@ pub use oauth::{ PkceChallengeMethod, PkceCodePair, }; pub use permissions::{ - PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision, - PermissionPrompter, PermissionRequest, + PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy, + PermissionPromptDecision, PermissionPrompter, PermissionRequest, }; pub use prompt::{ load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError, diff --git a/rust/crates/runtime/src/permissions.rs b/rust/crates/runtime/src/permissions.rs index bed2eab..3acf5c1 100644 --- a/rust/crates/runtime/src/permissions.rs +++ b/rust/crates/runtime/src/permissions.rs @@ -1,5 +1,9 @@ use std::collections::BTreeMap; +use serde_json::Value; + +use crate::config::RuntimePermissionRuleConfig; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum PermissionMode { ReadOnly, @@ -22,12 +26,49 @@ impl PermissionMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionOverride { + Allow, + Deny, + Ask, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PermissionContext { + override_decision: Option, + override_reason: Option, +} + +impl PermissionContext { + #[must_use] + pub fn new( + override_decision: Option, + override_reason: Option, + ) -> Self { + Self { + override_decision, + override_reason, + } + } + + #[must_use] + pub fn override_decision(&self) -> Option { + self.override_decision + } + + #[must_use] + pub fn override_reason(&self) -> Option<&str> { + self.override_reason.as_deref() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PermissionRequest { pub tool_name: String, pub input: String, pub current_mode: PermissionMode, pub required_mode: PermissionMode, + pub reason: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -50,6 +91,9 @@ pub enum PermissionOutcome { pub struct PermissionPolicy { active_mode: PermissionMode, tool_requirements: BTreeMap, + allow_rules: Vec, + deny_rules: Vec, + ask_rules: Vec, } impl PermissionPolicy { @@ -58,6 +102,9 @@ impl PermissionPolicy { Self { active_mode, tool_requirements: BTreeMap::new(), + allow_rules: Vec::new(), + deny_rules: Vec::new(), + ask_rules: Vec::new(), } } @@ -72,6 +119,26 @@ impl PermissionPolicy { self } + #[must_use] + pub fn with_permission_rules(mut self, config: &RuntimePermissionRuleConfig) -> Self { + self.allow_rules = config + .allow() + .iter() + .map(|rule| PermissionRule::parse(rule)) + .collect(); + self.deny_rules = config + .deny() + .iter() + .map(|rule| PermissionRule::parse(rule)) + .collect(); + self.ask_rules = config + .ask() + .iter() + .map(|rule| PermissionRule::parse(rule)) + .collect(); + self + } + #[must_use] pub fn active_mode(&self) -> PermissionMode { self.active_mode @@ -90,38 +157,121 @@ impl PermissionPolicy { &self, tool_name: &str, input: &str, - mut prompter: Option<&mut dyn PermissionPrompter>, + prompter: Option<&mut dyn PermissionPrompter>, ) -> PermissionOutcome { - let current_mode = self.active_mode(); - let required_mode = self.required_mode_for(tool_name); - if current_mode == PermissionMode::Allow || current_mode >= required_mode { - return PermissionOutcome::Allow; + self.authorize_with_context(tool_name, input, &PermissionContext::default(), prompter) + } + + #[must_use] + #[allow(clippy::too_many_lines)] + pub fn authorize_with_context( + &self, + tool_name: &str, + input: &str, + context: &PermissionContext, + prompter: Option<&mut dyn PermissionPrompter>, + ) -> PermissionOutcome { + if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) { + return PermissionOutcome::Deny { + reason: format!( + "Permission to use {tool_name} has been denied by rule '{}'", + rule.raw + ), + }; } - let request = PermissionRequest { - tool_name: tool_name.to_string(), - input: input.to_string(), - current_mode, - required_mode, - }; + let current_mode = self.active_mode(); + let required_mode = self.required_mode_for(tool_name); + let ask_rule = Self::find_matching_rule(&self.ask_rules, tool_name, input); + let allow_rule = Self::find_matching_rule(&self.allow_rules, tool_name, input); + + match context.override_decision() { + Some(PermissionOverride::Deny) => { + return PermissionOutcome::Deny { + reason: context.override_reason().map_or_else( + || format!("tool '{tool_name}' denied by hook"), + ToOwned::to_owned, + ), + }; + } + Some(PermissionOverride::Ask) => { + let reason = context.override_reason().map_or_else( + || format!("tool '{tool_name}' requires approval due to hook guidance"), + ToOwned::to_owned, + ); + return Self::prompt_or_deny( + tool_name, + input, + current_mode, + required_mode, + Some(reason), + prompter, + ); + } + Some(PermissionOverride::Allow) => { + if let Some(rule) = ask_rule { + let reason = format!( + "tool '{tool_name}' requires approval due to ask rule '{}'", + rule.raw + ); + return Self::prompt_or_deny( + tool_name, + input, + current_mode, + required_mode, + Some(reason), + prompter, + ); + } + if allow_rule.is_some() + || current_mode == PermissionMode::Allow + || current_mode >= required_mode + { + return PermissionOutcome::Allow; + } + } + None => {} + } + + if let Some(rule) = ask_rule { + let reason = format!( + "tool '{tool_name}' requires approval due to ask rule '{}'", + rule.raw + ); + return Self::prompt_or_deny( + tool_name, + input, + current_mode, + required_mode, + Some(reason), + prompter, + ); + } + + if allow_rule.is_some() + || current_mode == PermissionMode::Allow + || current_mode >= required_mode + { + return PermissionOutcome::Allow; + } if current_mode == PermissionMode::Prompt || (current_mode == PermissionMode::WorkspaceWrite && required_mode == PermissionMode::DangerFullAccess) { - return match prompter.as_mut() { - Some(prompter) => match prompter.decide(&request) { - PermissionPromptDecision::Allow => PermissionOutcome::Allow, - PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason }, - }, - None => PermissionOutcome::Deny { - reason: format!( - "tool '{tool_name}' requires approval to escalate from {} to {}", - current_mode.as_str(), - required_mode.as_str() - ), - }, - }; + let reason = Some(format!( + "tool '{tool_name}' requires approval to escalate from {} to {}", + current_mode.as_str(), + required_mode.as_str() + )); + return Self::prompt_or_deny( + tool_name, + input, + current_mode, + required_mode, + reason, + prompter, + ); } PermissionOutcome::Deny { @@ -132,14 +282,191 @@ impl PermissionPolicy { ), } } + + fn prompt_or_deny( + tool_name: &str, + input: &str, + current_mode: PermissionMode, + required_mode: PermissionMode, + reason: Option, + mut prompter: Option<&mut dyn PermissionPrompter>, + ) -> PermissionOutcome { + let request = PermissionRequest { + tool_name: tool_name.to_string(), + input: input.to_string(), + current_mode, + required_mode, + reason: reason.clone(), + }; + + match prompter.as_mut() { + Some(prompter) => match prompter.decide(&request) { + PermissionPromptDecision::Allow => PermissionOutcome::Allow, + PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason }, + }, + None => PermissionOutcome::Deny { + reason: reason.unwrap_or_else(|| { + format!( + "tool '{tool_name}' requires approval to run while mode is {}", + current_mode.as_str() + ) + }), + }, + } + } + + fn find_matching_rule<'a>( + rules: &'a [PermissionRule], + tool_name: &str, + input: &str, + ) -> Option<&'a PermissionRule> { + rules.iter().find(|rule| rule.matches(tool_name, input)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PermissionRule { + raw: String, + tool_name: String, + matcher: PermissionRuleMatcher, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PermissionRuleMatcher { + Any, + Exact(String), + Prefix(String), +} + +impl PermissionRule { + fn parse(raw: &str) -> Self { + let trimmed = raw.trim(); + let open = find_first_unescaped(trimmed, '('); + let close = find_last_unescaped(trimmed, ')'); + + if let (Some(open), Some(close)) = (open, close) { + if close == trimmed.len() - 1 && open < close { + let tool_name = trimmed[..open].trim(); + let content = &trimmed[open + 1..close]; + if !tool_name.is_empty() { + let matcher = parse_rule_matcher(content); + return Self { + raw: trimmed.to_string(), + tool_name: tool_name.to_string(), + matcher, + }; + } + } + } + + Self { + raw: trimmed.to_string(), + tool_name: trimmed.to_string(), + matcher: PermissionRuleMatcher::Any, + } + } + + fn matches(&self, tool_name: &str, input: &str) -> bool { + if self.tool_name != tool_name { + return false; + } + + match &self.matcher { + PermissionRuleMatcher::Any => true, + PermissionRuleMatcher::Exact(expected) => { + extract_permission_subject(input).is_some_and(|candidate| candidate == *expected) + } + PermissionRuleMatcher::Prefix(prefix) => extract_permission_subject(input) + .is_some_and(|candidate| candidate.starts_with(prefix)), + } + } +} + +fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher { + let unescaped = unescape_rule_content(content.trim()); + if unescaped.is_empty() || unescaped == "*" { + PermissionRuleMatcher::Any + } else if let Some(prefix) = unescaped.strip_suffix(":*") { + PermissionRuleMatcher::Prefix(prefix.to_string()) + } else { + PermissionRuleMatcher::Exact(unescaped) + } +} + +fn unescape_rule_content(content: &str) -> String { + content + .replace(r"\(", "(") + .replace(r"\)", ")") + .replace(r"\\", r"\") +} + +fn find_first_unescaped(value: &str, needle: char) -> Option { + let mut escaped = false; + for (idx, ch) in value.char_indices() { + if ch == '\\' { + escaped = !escaped; + continue; + } + if ch == needle && !escaped { + return Some(idx); + } + escaped = false; + } + None +} + +fn find_last_unescaped(value: &str, needle: char) -> Option { + let chars = value.char_indices().collect::>(); + for (pos, (idx, ch)) in chars.iter().enumerate().rev() { + if *ch != needle { + continue; + } + let mut backslashes = 0; + for (_, prev) in chars[..pos].iter().rev() { + if *prev == '\\' { + backslashes += 1; + } else { + break; + } + } + if backslashes % 2 == 0 { + return Some(*idx); + } + } + None +} + +fn extract_permission_subject(input: &str) -> Option { + let parsed = serde_json::from_str::(input).ok(); + if let Some(Value::Object(object)) = parsed { + for key in [ + "command", + "path", + "file_path", + "filePath", + "notebook_path", + "notebookPath", + "url", + "pattern", + "code", + "message", + ] { + if let Some(value) = object.get(key).and_then(Value::as_str) { + return Some(value.to_string()); + } + } + } + + (!input.trim().is_empty()).then(|| input.to_string()) } #[cfg(test)] mod tests { use super::{ - PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision, - PermissionPrompter, PermissionRequest, + PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy, + PermissionPromptDecision, PermissionPrompter, PermissionRequest, }; + use crate::config::RuntimePermissionRuleConfig; struct RecordingPrompter { seen: Vec, @@ -229,4 +556,120 @@ mod tests { PermissionOutcome::Deny { reason } if reason == "not now" )); } + + #[test] + fn applies_rule_based_denials_and_allows() { + let rules = RuntimePermissionRuleConfig::new( + vec!["bash(git:*)".to_string()], + vec!["bash(rm -rf:*)".to_string()], + Vec::new(), + ); + let policy = PermissionPolicy::new(PermissionMode::ReadOnly) + .with_tool_requirement("bash", PermissionMode::DangerFullAccess) + .with_permission_rules(&rules); + + assert_eq!( + policy.authorize("bash", r#"{"command":"git status"}"#, None), + PermissionOutcome::Allow + ); + assert!(matches!( + policy.authorize("bash", r#"{"command":"rm -rf /tmp/x"}"#, None), + PermissionOutcome::Deny { reason } if reason.contains("denied by rule") + )); + } + + #[test] + fn ask_rules_force_prompt_even_when_mode_allows() { + let rules = RuntimePermissionRuleConfig::new( + Vec::new(), + Vec::new(), + vec!["bash(git:*)".to_string()], + ); + let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess) + .with_tool_requirement("bash", PermissionMode::DangerFullAccess) + .with_permission_rules(&rules); + let mut prompter = RecordingPrompter { + seen: Vec::new(), + allow: true, + }; + + let outcome = policy.authorize("bash", r#"{"command":"git status"}"#, Some(&mut prompter)); + + assert_eq!(outcome, PermissionOutcome::Allow); + assert_eq!(prompter.seen.len(), 1); + assert!(prompter.seen[0] + .reason + .as_deref() + .is_some_and(|reason| reason.contains("ask rule"))); + } + + #[test] + fn hook_allow_still_respects_ask_rules() { + let rules = RuntimePermissionRuleConfig::new( + Vec::new(), + Vec::new(), + vec!["bash(git:*)".to_string()], + ); + let policy = PermissionPolicy::new(PermissionMode::ReadOnly) + .with_tool_requirement("bash", PermissionMode::DangerFullAccess) + .with_permission_rules(&rules); + let context = PermissionContext::new( + Some(PermissionOverride::Allow), + Some("hook approved".to_string()), + ); + let mut prompter = RecordingPrompter { + seen: Vec::new(), + allow: true, + }; + + let outcome = policy.authorize_with_context( + "bash", + r#"{"command":"git status"}"#, + &context, + Some(&mut prompter), + ); + + assert_eq!(outcome, PermissionOutcome::Allow); + assert_eq!(prompter.seen.len(), 1); + } + + #[test] + fn hook_deny_short_circuits_permission_flow() { + let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess) + .with_tool_requirement("bash", PermissionMode::DangerFullAccess); + let context = PermissionContext::new( + Some(PermissionOverride::Deny), + Some("blocked by hook".to_string()), + ); + + assert_eq!( + policy.authorize_with_context("bash", "{}", &context, None), + PermissionOutcome::Deny { + reason: "blocked by hook".to_string(), + } + ); + } + + #[test] + fn hook_ask_forces_prompt() { + let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess) + .with_tool_requirement("bash", PermissionMode::DangerFullAccess); + let context = PermissionContext::new( + Some(PermissionOverride::Ask), + Some("hook requested confirmation".to_string()), + ); + let mut prompter = RecordingPrompter { + seen: Vec::new(), + allow: true, + }; + + let outcome = policy.authorize_with_context("bash", "{}", &context, Some(&mut prompter)); + + assert_eq!(outcome, PermissionOutcome::Allow); + assert_eq!(prompter.seen.len(), 1); + assert_eq!( + prompter.seen[0].reason.as_deref(), + Some("hook requested confirmation") + ); + } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 5f8a7a6..ff241eb 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1922,13 +1922,14 @@ fn build_runtime( permission_mode: PermissionMode, ) -> Result, Box> { + let feature_config = build_runtime_feature_config()?; Ok(ConversationRuntime::new_with_features( session, AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?, CliToolExecutor::new(allowed_tools, emit_output), - permission_policy(permission_mode), + permission_policy(permission_mode, &feature_config), system_prompt, - build_runtime_feature_config()?, + &feature_config, )) } @@ -2673,12 +2674,14 @@ impl ToolExecutor for CliToolExecutor { } } -fn permission_policy(mode: PermissionMode) -> PermissionPolicy { - tool_permission_specs() - .into_iter() - .fold(PermissionPolicy::new(mode), |policy, spec| { - policy.with_tool_requirement(spec.name, spec.required_permission) - }) +fn permission_policy( + mode: PermissionMode, + feature_config: &runtime::RuntimeFeatureConfig, +) -> PermissionPolicy { + tool_permission_specs().into_iter().fold( + PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()), + |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission), + ) } fn tool_permission_specs() -> Vec { From 9efd029e26f1764c134fdf0b380f6cb6b34c3e94 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 04:40:18 +0000 Subject: [PATCH 3/6] wip: hook-pipeline progress --- rust/crates/runtime/src/conversation.rs | 105 ++++++++++++++++----- rust/crates/runtime/src/hooks.rs | 54 ++++------- rust/crates/rusty-claude-cli/src/main.rs | 33 ++++--- rust/crates/rusty-claude-cli/src/render.rs | 4 +- 4 files changed, 124 insertions(+), 72 deletions(-) diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 2c5f6ea..358e1cc 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -5,7 +5,7 @@ use crate::compact::{ compact_session, estimate_session_tokens, CompactionConfig, CompactionResult, }; use crate::config::RuntimeFeatureConfig; -use crate::hooks::{HookAbortSignal, HookProgressReporter, HookRunResult, HookRunner}; +use crate::hooks::{HookAbortSignal, HookRunResult, HookRunner}; use crate::permissions::{ PermissionContext, PermissionOutcome, PermissionPolicy, PermissionPrompter, }; @@ -100,7 +100,6 @@ pub struct ConversationRuntime { usage_tracker: UsageTracker, hook_runner: HookRunner, hook_abort_signal: HookAbortSignal, - hook_progress_reporter: Option>, } impl ConversationRuntime @@ -122,18 +121,19 @@ where tool_executor, permission_policy, system_prompt, - &RuntimeFeatureConfig::default(), + RuntimeFeatureConfig::default(), ) } #[must_use] + #[allow(clippy::needless_pass_by_value)] pub fn new_with_features( session: Session, api_client: C, tool_executor: T, permission_policy: PermissionPolicy, system_prompt: Vec, - feature_config: &RuntimeFeatureConfig, + feature_config: RuntimeFeatureConfig, ) -> Self { let usage_tracker = UsageTracker::from_session(&session); Self { @@ -144,8 +144,9 @@ where system_prompt, max_iterations: usize::MAX, usage_tracker, - hook_runner: HookRunner::from_feature_config(feature_config), + hook_runner: HookRunner::from_feature_config(&feature_config), hook_abort_signal: HookAbortSignal::default(), + hook_progress_reporter: None, } } @@ -220,17 +221,12 @@ where } for (tool_use_id, tool_name, input) in pending_tool_uses { - let pre_hook_result = self.hook_runner.run_pre_tool_use_with_context( - &tool_name, - &input, - Some(&self.hook_abort_signal), - self.hook_progress_reporter.as_deref_mut(), - ); + let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input); let effective_input = pre_hook_result - .updated_input_json() + .updated_input() .map_or_else(|| input.clone(), ToOwned::to_owned); let permission_context = PermissionContext::new( - pre_hook_result.permission_decision(), + pre_hook_result.permission_override(), pre_hook_result.permission_reason().map(ToOwned::to_owned), ); @@ -274,21 +270,17 @@ where output = merge_hook_feedback(pre_hook_result.messages(), output, false); let post_hook_result = if is_error { - self.hook_runner.run_post_tool_use_failure_with_context( + self.run_post_tool_use_failure_hook( &tool_name, &effective_input, &output, - Some(&self.hook_abort_signal), - self.hook_progress_reporter.as_deref_mut(), ) } else { - self.hook_runner.run_post_tool_use_with_context( + self.run_post_tool_use_hook( &tool_name, &effective_input, &output, false, - Some(&self.hook_abort_signal), - self.hook_progress_reporter.as_deref_mut(), ) }; if post_hook_result.is_denied() || post_hook_result.is_cancelled() { @@ -322,6 +314,77 @@ where }) } + fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult { + if let Some(reporter) = self.hook_progress_reporter.as_mut() { + self.hook_runner.run_pre_tool_use_with_context( + tool_name, + input, + Some(&self.hook_abort_signal), + Some(reporter.as_mut()), + ) + } else { + self.hook_runner.run_pre_tool_use_with_context( + tool_name, + input, + Some(&self.hook_abort_signal), + None, + ) + } + } + + fn run_post_tool_use_hook( + &mut self, + tool_name: &str, + input: &str, + output: &str, + is_error: bool, + ) -> HookRunResult { + if let Some(reporter) = self.hook_progress_reporter.as_mut() { + self.hook_runner.run_post_tool_use_with_context( + tool_name, + input, + output, + is_error, + Some(&self.hook_abort_signal), + Some(reporter.as_mut()), + ) + } else { + self.hook_runner.run_post_tool_use_with_context( + tool_name, + input, + output, + is_error, + Some(&self.hook_abort_signal), + None, + ) + } + } + + fn run_post_tool_use_failure_hook( + &mut self, + tool_name: &str, + input: &str, + output: &str, + ) -> HookRunResult { + if let Some(reporter) = self.hook_progress_reporter.as_mut() { + self.hook_runner.run_post_tool_use_failure_with_context( + tool_name, + input, + output, + Some(&self.hook_abort_signal), + Some(reporter.as_mut()), + ) + } else { + self.hook_runner.run_post_tool_use_failure_with_context( + tool_name, + input, + output, + Some(&self.hook_abort_signal), + None, + ) + } + } + #[must_use] pub fn compact(&self, config: CompactionConfig) -> CompactionResult { compact_session(&self.session, config) @@ -669,7 +732,7 @@ mod tests { }), PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], - &RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( + RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( vec![shell_snippet("printf 'blocked by hook'; exit 2")], Vec::new(), Vec::new(), @@ -736,7 +799,7 @@ mod tests { StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())), PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], - &RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( + RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( vec![shell_snippet("printf 'pre hook ran'")], vec![shell_snippet("printf 'post hook ran'")], Vec::new(), diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs index 3af6cbd..4ef5a6c 100644 --- a/rust/crates/runtime/src/hooks.rs +++ b/rust/crates/runtime/src/hooks.rs @@ -1,16 +1,14 @@ use std::ffi::OsStr; +use std::io::Write; use std::process::{Command, Stdio}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; +use std::thread; use std::time::Duration; use serde_json::{json, Value}; -use tokio::io::AsyncWriteExt; -use tokio::process::Command as TokioCommand; -use tokio::runtime::Builder; -use tokio::time::sleep; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; use crate::permissions::PermissionOverride; @@ -172,7 +170,7 @@ impl HookRunner { abort_signal: Option<&HookAbortSignal>, reporter: Option<&mut dyn HookProgressReporter>, ) -> HookRunResult { - self.run_commands( + Self::run_commands( HookEvent::PreToolUse, self.config.pre_tool_use(), tool_name, @@ -222,7 +220,7 @@ impl HookRunner { abort_signal: Option<&HookAbortSignal>, reporter: Option<&mut dyn HookProgressReporter>, ) -> HookRunResult { - self.run_commands( + Self::run_commands( HookEvent::PostToolUse, self.config.post_tool_use(), tool_name, @@ -272,7 +270,7 @@ impl HookRunner { abort_signal: Option<&HookAbortSignal>, reporter: Option<&mut dyn HookProgressReporter>, ) -> HookRunResult { - self.run_commands( + Self::run_commands( HookEvent::PostToolUseFailure, self.config.post_tool_use_failure(), tool_name, @@ -303,7 +301,6 @@ impl HookRunner { #[allow(clippy::too_many_arguments)] fn run_commands( - &self, event: HookEvent, commands: &[String], tool_name: &str, @@ -675,36 +672,23 @@ impl CommandWithStdin { stdin: &[u8], abort_signal: Option<&HookAbortSignal>, ) -> std::io::Result { - let runtime = Builder::new_current_thread().enable_all().build()?; - let mut command = - TokioCommand::from(std::mem::replace(&mut self.command, Command::new("true"))); - let stdin = stdin.to_vec(); - let abort_signal = abort_signal.cloned(); - runtime.block_on(async move { - let mut child = command.spawn()?; - if let Some(mut child_stdin) = child.stdin.take() { - child_stdin.write_all(&stdin).await?; + let mut child = self.command.spawn()?; + if let Some(mut child_stdin) = child.stdin.take() { + child_stdin.write_all(stdin)?; + } + + loop { + if abort_signal.is_some_and(HookAbortSignal::is_aborted) { + let _ = child.kill(); + let _ = child.wait_with_output(); + return Ok(CommandExecution::Cancelled); } - loop { - if abort_signal - .as_ref() - .is_some_and(HookAbortSignal::is_aborted) - { - let _ = child.start_kill(); - let _ = child.wait().await; - return Ok(CommandExecution::Cancelled); - } - - if let Some(status) = child.try_wait()? { - let output = child.wait_with_output().await?; - debug_assert_eq!(output.status.code(), status.code()); - return Ok(CommandExecution::Finished(output)); - } - - sleep(Duration::from_millis(20)).await; + match child.try_wait()? { + Some(_) => return child.wait_with_output().map(CommandExecution::Finished), + None => thread::sleep(Duration::from_millis(20)), } - }) + } } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index ff241eb..935bade 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1923,14 +1923,15 @@ fn build_runtime( ) -> Result, Box> { let feature_config = build_runtime_feature_config()?; - Ok(ConversationRuntime::new_with_features( + let runtime = ConversationRuntime::new_with_features( session, AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?, CliToolExecutor::new(allowed_tools, emit_output), permission_policy(permission_mode, &feature_config), system_prompt, - &feature_config, - )) + feature_config, + ); + Ok(runtime) } struct CliPermissionPrompter { @@ -1953,6 +1954,9 @@ impl runtime::PermissionPrompter for CliPermissionPrompter { println!(" Tool {}", request.tool_name); println!(" Current mode {}", self.current_mode.as_str()); println!(" Required mode {}", request.required_mode.as_str()); + if let Some(reason) = &request.reason { + println!(" Reason {reason}"); + } println!(" Input {}", request.input); print!("Approve this tool call? [y/N]: "); let _ = io::stdout().flush(); @@ -2365,13 +2369,15 @@ fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { .get("backgroundTaskId") .and_then(|value| value.as_str()) { - lines[0].push_str(&format!(" backgrounded ({task_id})")); + use std::fmt::Write as _; + let _ = write!(lines[0], " backgrounded ({task_id})"); } else if let Some(status) = parsed .get("returnCodeInterpretation") .and_then(|value| value.as_str()) .filter(|status| !status.is_empty()) { - lines[0].push_str(&format!(" {status}")); + use std::fmt::Write as _; + let _ = write!(lines[0], " {status}"); } if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { @@ -2393,15 +2399,15 @@ fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { let path = extract_tool_path(file); let start_line = file .get("startLine") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(1); let num_lines = file .get("numLines") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let total_lines = file .get("totalLines") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(num_lines); let content = file .get("content") @@ -2427,8 +2433,7 @@ fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { let line_count = parsed .get("content") .and_then(|value| value.as_str()) - .map(|content| content.lines().count()) - .unwrap_or(0); + .map_or(0, |content| content.lines().count()); format!( "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", if kind == "create" { "Wrote" } else { "Updated" }, @@ -2459,7 +2464,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { let path = extract_tool_path(parsed); let suffix = if parsed .get("replaceAll") - .and_then(|value| value.as_bool()) + .and_then(serde_json::Value::as_bool) .unwrap_or(false) { " (replace all)" @@ -2487,7 +2492,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { let num_files = parsed .get("numFiles") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let filenames = parsed .get("filenames") @@ -2511,11 +2516,11 @@ fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { let num_matches = parsed .get("numMatches") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let num_files = parsed .get("numFiles") - .and_then(|value| value.as_u64()) + .and_then(serde_json::Value::as_u64) .unwrap_or(0); let content = parsed .get("content") diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index 465c5a4..d8d8796 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -286,7 +286,7 @@ impl TerminalRenderer { ) { match event { Event::Start(Tag::Heading { level, .. }) => { - self.start_heading(state, level as u8, output) + Self::start_heading(state, level as u8, output); } Event::End(TagEnd::Paragraph) => output.push_str("\n\n"), Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output), @@ -426,7 +426,7 @@ impl TerminalRenderer { } } - fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) { + fn start_heading(state: &mut RenderState, level: u8, output: &mut String) { state.heading_level = Some(level); if !output.is_empty() { output.push('\n'); From 555a2454562297cedf2c21579ed22fe16f6a5caa Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 04:50:26 +0000 Subject: [PATCH 4/6] wip: hook progress UI + documentation --- rust/crates/runtime/src/conversation.rs | 145 ++++++++++++----------- rust/crates/rusty-claude-cli/src/main.rs | 38 +++++- 2 files changed, 110 insertions(+), 73 deletions(-) diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 358e1cc..07ac8bc 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -5,7 +5,7 @@ use crate::compact::{ compact_session, estimate_session_tokens, CompactionConfig, CompactionResult, }; use crate::config::RuntimeFeatureConfig; -use crate::hooks::{HookAbortSignal, HookRunResult, HookRunner}; +use crate::hooks::{HookAbortSignal, HookProgressReporter, HookRunResult, HookRunner}; use crate::permissions::{ PermissionContext, PermissionOutcome, PermissionPolicy, PermissionPrompter, }; @@ -100,6 +100,7 @@ pub struct ConversationRuntime { usage_tracker: UsageTracker, hook_runner: HookRunner, hook_abort_signal: HookAbortSignal, + hook_progress_reporter: Option>, } impl ConversationRuntime @@ -171,6 +172,77 @@ where self } + fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult { + if let Some(reporter) = self.hook_progress_reporter.as_mut() { + self.hook_runner.run_pre_tool_use_with_context( + tool_name, + input, + Some(&self.hook_abort_signal), + Some(reporter.as_mut()), + ) + } else { + self.hook_runner.run_pre_tool_use_with_context( + tool_name, + input, + Some(&self.hook_abort_signal), + None, + ) + } + } + + fn run_post_tool_use_hook( + &mut self, + tool_name: &str, + input: &str, + output: &str, + is_error: bool, + ) -> HookRunResult { + if let Some(reporter) = self.hook_progress_reporter.as_mut() { + self.hook_runner.run_post_tool_use_with_context( + tool_name, + input, + output, + is_error, + Some(&self.hook_abort_signal), + Some(reporter.as_mut()), + ) + } else { + self.hook_runner.run_post_tool_use_with_context( + tool_name, + input, + output, + is_error, + Some(&self.hook_abort_signal), + None, + ) + } + } + + fn run_post_tool_use_failure_hook( + &mut self, + tool_name: &str, + input: &str, + output: &str, + ) -> HookRunResult { + if let Some(reporter) = self.hook_progress_reporter.as_mut() { + self.hook_runner.run_post_tool_use_failure_with_context( + tool_name, + input, + output, + Some(&self.hook_abort_signal), + Some(reporter.as_mut()), + ) + } else { + self.hook_runner.run_post_tool_use_failure_with_context( + tool_name, + input, + output, + Some(&self.hook_abort_signal), + None, + ) + } + } + #[allow(clippy::too_many_lines)] pub fn run_turn( &mut self, @@ -314,77 +386,6 @@ where }) } - fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult { - if let Some(reporter) = self.hook_progress_reporter.as_mut() { - self.hook_runner.run_pre_tool_use_with_context( - tool_name, - input, - Some(&self.hook_abort_signal), - Some(reporter.as_mut()), - ) - } else { - self.hook_runner.run_pre_tool_use_with_context( - tool_name, - input, - Some(&self.hook_abort_signal), - None, - ) - } - } - - fn run_post_tool_use_hook( - &mut self, - tool_name: &str, - input: &str, - output: &str, - is_error: bool, - ) -> HookRunResult { - if let Some(reporter) = self.hook_progress_reporter.as_mut() { - self.hook_runner.run_post_tool_use_with_context( - tool_name, - input, - output, - is_error, - Some(&self.hook_abort_signal), - Some(reporter.as_mut()), - ) - } else { - self.hook_runner.run_post_tool_use_with_context( - tool_name, - input, - output, - is_error, - Some(&self.hook_abort_signal), - None, - ) - } - } - - fn run_post_tool_use_failure_hook( - &mut self, - tool_name: &str, - input: &str, - output: &str, - ) -> HookRunResult { - if let Some(reporter) = self.hook_progress_reporter.as_mut() { - self.hook_runner.run_post_tool_use_failure_with_context( - tool_name, - input, - output, - Some(&self.hook_abort_signal), - Some(reporter.as_mut()), - ) - } else { - self.hook_runner.run_post_tool_use_failure_with_context( - tool_name, - input, - output, - Some(&self.hook_abort_signal), - None, - ) - } - } - #[must_use] pub fn compact(&self, config: CompactionConfig) -> CompactionResult { compact_session(&self.session, config) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 935bade..7baed9b 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1923,7 +1923,7 @@ fn build_runtime( ) -> Result, Box> { let feature_config = build_runtime_feature_config()?; - let runtime = ConversationRuntime::new_with_features( + let mut runtime = ConversationRuntime::new_with_features( session, AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?, CliToolExecutor::new(allowed_tools, emit_output), @@ -1931,9 +1931,45 @@ fn build_runtime( system_prompt, feature_config, ); + if emit_output { + runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter)); + } Ok(runtime) } +struct CliHookProgressReporter; + +impl runtime::HookProgressReporter for CliHookProgressReporter { + fn on_event(&mut self, event: &runtime::HookProgressEvent) { + match event { + runtime::HookProgressEvent::Started { + event, + tool_name, + command, + } => eprintln!( + "[hook {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + runtime::HookProgressEvent::Completed { + event, + tool_name, + command, + } => eprintln!( + "[hook done {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + runtime::HookProgressEvent::Cancelled { + event, + tool_name, + command, + } => eprintln!( + "[hook cancelled {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + } + } +} + struct CliPermissionPrompter { current_mode: PermissionMode, } From 197065bfc8112701458ce49b2eece4bac7aabb08 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 05:55:24 +0000 Subject: [PATCH 5/6] feat: hook abort signal + Ctrl-C cancellation pipeline --- rust/crates/rusty-claude-cli/Cargo.toml | 2 +- rust/crates/rusty-claude-cli/src/main.rs | 131 ++++++++++++++++++++++- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/rust/crates/rusty-claude-cli/Cargo.toml b/rust/crates/rusty-claude-cli/Cargo.toml index 2ac6701..c1aa0ce 100644 --- a/rust/crates/rusty-claude-cli/Cargo.toml +++ b/rust/crates/rusty-claude-cli/Cargo.toml @@ -19,7 +19,7 @@ rustyline = "15" runtime = { path = "../runtime" } serde_json = "1" syntect = "5" -tokio = { version = "1", features = ["rt-multi-thread", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] } tools = { path = "../tools" } [lints] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7baed9b..d1b1e18 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -9,6 +9,8 @@ use std::io::{self, Read, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::{self, JoinHandle}; use std::time::{SystemTime, UNIX_EPOCH}; use api::{ @@ -984,6 +986,61 @@ struct LiveCli { session: SessionHandle, } +struct HookAbortMonitor { + stop_tx: Option>, + join_handle: Option>, +} + +impl HookAbortMonitor { + fn spawn(abort_signal: runtime::HookAbortSignal) -> Self { + Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + + runtime.block_on(async move { + let wait_for_stop = tokio::task::spawn_blocking(move || { + let _ = stop_rx.recv(); + }); + + tokio::select! { + result = tokio::signal::ctrl_c() => { + if result.is_ok() { + abort_signal.abort(); + } + } + _ = wait_for_stop => {} + } + }); + }) + } + + fn spawn_with_waiter(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self + where + F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static, + { + let (stop_tx, stop_rx) = mpsc::channel(); + let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal)); + + Self { + stop_tx: Some(stop_tx), + join_handle: Some(join_handle), + } + } + + fn stop(mut self) { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + if let Some(join_handle) = self.join_handle.take() { + let _ = join_handle.join(); + } + } +} + impl LiveCli { fn new( model: String, @@ -1040,6 +1097,19 @@ impl LiveCli { } fn run_turn(&mut self, input: &str) -> Result<(), Box> { + let session = self.runtime.session().clone(); + let hook_abort_signal = runtime::HookAbortSignal::new(); + let mut runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + )? + .with_hook_abort_signal(hook_abort_signal.clone()); + let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); let mut spinner = Spinner::new(); let mut stdout = io::stdout(); spinner.tick( @@ -1048,7 +1118,9 @@ impl LiveCli { &mut stdout, )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + self.runtime = runtime; match result { Ok(_) => { spinner.finish( @@ -1084,6 +1156,7 @@ impl LiveCli { fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> { let session = self.runtime.session().clone(); + let hook_abort_signal = runtime::HookAbortSignal::new(); let mut runtime = build_runtime( session, self.model.clone(), @@ -1092,9 +1165,13 @@ impl LiveCli { false, self.allowed_tools.clone(), self.permission_mode, - )?; + )? + .with_hook_abort_signal(hook_abort_signal.clone()); + let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let summary = runtime.run_turn(input, Some(&mut permission_prompter))?; + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + let summary = result?; self.runtime = runtime; self.persist_session()?; println!( @@ -2871,12 +2948,17 @@ mod tests { normalize_permission_mode, parse_args, parse_git_status_metadata, print_help_to, push_output_block, render_config_report, render_memory_report, render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands, status_context, - CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + CliAction, CliOutputFormat, HookAbortMonitor, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; - use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; + use runtime::{ + AssistantEvent, ContentBlock, ConversationMessage, HookAbortSignal, MessageRole, + PermissionMode, + }; use serde_json::json; use std::path::PathBuf; + use std::sync::mpsc; + use std::time::Duration; #[test] fn defaults_to_repl_when_no_args() { @@ -3535,4 +3617,43 @@ mod tests { if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}" )); } + + #[test] + fn hook_abort_monitor_stops_without_aborting() { + let abort_signal = HookAbortSignal::new(); + let (ready_tx, ready_rx) = mpsc::channel(); + let monitor = HookAbortMonitor::spawn_with_waiter( + abort_signal.clone(), + move |stop_rx, abort_signal| { + ready_tx.send(()).expect("ready signal"); + let _ = stop_rx.recv(); + assert!(!abort_signal.is_aborted()); + }, + ); + + ready_rx.recv().expect("waiter should be ready"); + monitor.stop(); + + assert!(!abort_signal.is_aborted()); + } + + #[test] + fn hook_abort_monitor_propagates_interrupt() { + let abort_signal = HookAbortSignal::new(); + let (done_tx, done_rx) = mpsc::channel(); + let monitor = HookAbortMonitor::spawn_with_waiter( + abort_signal.clone(), + move |_stop_rx, abort_signal| { + abort_signal.abort(); + done_tx.send(()).expect("done signal"); + }, + ); + + done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("interrupt should complete"); + monitor.stop(); + + assert!(abort_signal.is_aborted()); + } } From c38eac7a90ce14b6f8c509bf437c560eb15c6dc0 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 05:58:00 +0000 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20hook-pipeline=20progress=20?= =?UTF-8?q?=E2=80=94=20tests=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/crates/rusty-claude-cli/src/main.rs | 38 +++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index d1b1e18..692fd5d 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1096,20 +1096,34 @@ impl LiveCli { ) } - fn run_turn(&mut self, input: &str) -> Result<(), Box> { - let session = self.runtime.session().clone(); + fn prepare_turn_runtime( + &self, + emit_output: bool, + ) -> Result< + ( + ConversationRuntime, + HookAbortMonitor, + ), + Box, + > { let hook_abort_signal = runtime::HookAbortSignal::new(); - let mut runtime = build_runtime( - session, + let runtime = build_runtime( + self.runtime.session().clone(), self.model.clone(), self.system_prompt.clone(), true, - true, + emit_output, self.allowed_tools.clone(), self.permission_mode, )? .with_hook_abort_signal(hook_abort_signal.clone()); let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); + + Ok((runtime, hook_abort_monitor)) + } + + fn run_turn(&mut self, input: &str) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; let mut spinner = Spinner::new(); let mut stdout = io::stdout(); spinner.tick( @@ -1155,19 +1169,7 @@ impl LiveCli { } fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> { - let session = self.runtime.session().clone(); - let hook_abort_signal = runtime::HookAbortSignal::new(); - let mut runtime = build_runtime( - session, - self.model.clone(), - self.system_prompt.clone(), - true, - false, - self.allowed_tools.clone(), - self.permission_mode, - )? - .with_hook_abort_signal(hook_abort_signal.clone()); - let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let result = runtime.run_turn(input, Some(&mut permission_prompter)); hook_abort_monitor.stop();