Parcourir la source

Enable compatible tool hooks in the Rust runtime

This threads typed hook settings through runtime config, adds a shell-based hook runner, and executes PreToolUse/PostToolUse around each tool call in the conversation loop. The CLI now rebuilds runtimes with settings-derived hook configuration so user-defined Claw hook commands actually run before and after tools.

Constraint: Hook behavior needed to match Claw-style settings.json hooks without broad plugin/MCP parity work in this change
Rejected: Delay hook loading to the tool executor layer | would miss denied tool calls and duplicate runtime policy plumbing
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep hook execution in the runtime loop so permission decisions and tool results remain wrapped by the same conversation semantics
Tested: cargo test; cargo build --release
Not-tested: Real user hook scripts outside the test harness; broader plugin/skills parity
Yeachan-Heo il y a 2 mois
Parent
commit
b40fb0c464

+ 62 - 0
rust/crates/runtime/src/config.rs

@@ -37,6 +37,7 @@ pub struct RuntimeConfig {
 
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
 pub struct RuntimeFeatureConfig {
+    hooks: RuntimeHookConfig,
     mcp: McpConfigCollection,
     oauth: Option<OAuthConfig>,
     model: Option<String>,
@@ -44,6 +45,12 @@ pub struct RuntimeFeatureConfig {
     sandbox: SandboxConfig,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct RuntimeHookConfig {
+    pre_tool_use: Vec<String>,
+    post_tool_use: Vec<String>,
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
 pub struct McpConfigCollection {
     servers: BTreeMap<String, ScopedMcpServerConfig>,
@@ -221,6 +228,7 @@ impl ConfigLoader {
         let merged_value = JsonValue::Object(merged.clone());
 
         let feature_config = RuntimeFeatureConfig {
+            hooks: parse_optional_hooks_config(&merged_value)?,
             mcp: McpConfigCollection {
                 servers: mcp_servers,
             },
@@ -278,6 +286,11 @@ impl RuntimeConfig {
         &self.feature_config.mcp
     }
 
+    #[must_use]
+    pub fn hooks(&self) -> &RuntimeHookConfig {
+        &self.feature_config.hooks
+    }
+
     #[must_use]
     pub fn oauth(&self) -> Option<&OAuthConfig> {
         self.feature_config.oauth.as_ref()
@@ -300,6 +313,17 @@ impl RuntimeConfig {
 }
 
 impl RuntimeFeatureConfig {
+    #[must_use]
+    pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
+        self.hooks = hooks;
+        self
+    }
+
+    #[must_use]
+    pub fn hooks(&self) -> &RuntimeHookConfig {
+        &self.hooks
+    }
+
     #[must_use]
     pub fn mcp(&self) -> &McpConfigCollection {
         &self.mcp
@@ -326,6 +350,26 @@ impl RuntimeFeatureConfig {
     }
 }
 
+impl RuntimeHookConfig {
+    #[must_use]
+    pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
+        Self {
+            pre_tool_use,
+            post_tool_use,
+        }
+    }
+
+    #[must_use]
+    pub fn pre_tool_use(&self) -> &[String] {
+        &self.pre_tool_use
+    }
+
+    #[must_use]
+    pub fn post_tool_use(&self) -> &[String] {
+        &self.post_tool_use
+    }
+}
+
 impl McpConfigCollection {
     #[must_use]
     pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
@@ -424,6 +468,22 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
         .map(ToOwned::to_owned)
 }
 
+fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
+    let Some(object) = root.as_object() else {
+        return Ok(RuntimeHookConfig::default());
+    };
+    let Some(hooks_value) = object.get("hooks") else {
+        return Ok(RuntimeHookConfig::default());
+    };
+    let hooks = expect_object(hooks_value, "merged settings.hooks")?;
+    Ok(RuntimeHookConfig {
+        pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
+            .unwrap_or_default(),
+        post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
+            .unwrap_or_default(),
+    })
+}
+
 fn parse_optional_permission_mode(
     root: &JsonValue,
 ) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
@@ -836,6 +896,8 @@ mod tests {
             .and_then(JsonValue::as_object)
             .expect("hooks object")
             .contains_key("PostToolUse"));
+        assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
+        assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
         assert!(loaded.mcp().get("home").is_some());
         assert!(loaded.mcp().get("project").is_some());
 

+ 225 - 8
rust/crates/runtime/src/conversation.rs

@@ -4,6 +4,8 @@ use std::fmt::{Display, Formatter};
 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::session::{ContentBlock, ConversationMessage, Session};
 use crate::usage::{TokenUsage, UsageTracker};
@@ -94,6 +96,7 @@ pub struct ConversationRuntime<C, T> {
     system_prompt: Vec<String>,
     max_iterations: usize,
     usage_tracker: UsageTracker,
+    hook_runner: HookRunner,
 }
 
 impl<C, T> ConversationRuntime<C, T>
@@ -108,6 +111,25 @@ where
         tool_executor: T,
         permission_policy: PermissionPolicy,
         system_prompt: Vec<String>,
+    ) -> Self {
+        Self::new_with_features(
+            session,
+            api_client,
+            tool_executor,
+            permission_policy,
+            system_prompt,
+            RuntimeFeatureConfig::default(),
+        )
+    }
+
+    #[must_use]
+    pub fn new_with_features(
+        session: Session,
+        api_client: C,
+        tool_executor: T,
+        permission_policy: PermissionPolicy,
+        system_prompt: Vec<String>,
+        feature_config: RuntimeFeatureConfig,
     ) -> Self {
         let usage_tracker = UsageTracker::from_session(&session);
         Self {
@@ -118,6 +140,7 @@ where
             system_prompt,
             max_iterations: usize::MAX,
             usage_tracker,
+            hook_runner: HookRunner::from_feature_config(&feature_config),
         }
     }
 
@@ -185,19 +208,41 @@ where
 
                 let result_message = match permission_outcome {
                     PermissionOutcome::Allow => {
-                        match self.tool_executor.execute(&tool_name, &input) {
-                            Ok(output) => ConversationMessage::tool_result(
+                        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,
+                            )
+                        } 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,
-                                false,
-                            ),
-                            Err(error) => ConversationMessage::tool_result(
+                                post_hook_result.is_denied(),
+                            );
+
+                            ConversationMessage::tool_result(
                                 tool_use_id,
                                 tool_name,
-                                error.to_string(),
-                                true,
-                            ),
+                                output,
+                                is_error,
+                            )
                         }
                     }
                     PermissionOutcome::Deny { reason } => {
@@ -290,6 +335,32 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
     }
 }
 
+fn format_hook_message(result: &HookRunResult, fallback: &str) -> String {
+    if result.messages().is_empty() {
+        fallback.to_string()
+    } else {
+        result.messages().join("\n")
+    }
+}
+
+fn merge_hook_feedback(messages: &[String], output: String, denied: bool) -> String {
+    if messages.is_empty() {
+        return output;
+    }
+
+    let mut sections = Vec::new();
+    if !output.trim().is_empty() {
+        sections.push(output);
+    }
+    let label = if denied {
+        "Hook feedback (denied)"
+    } else {
+        "Hook feedback"
+    };
+    sections.push(format!("{label}:\n{}", messages.join("\n")));
+    sections.join("\n\n")
+}
+
 type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
 
 #[derive(Default)]
@@ -329,6 +400,7 @@ mod tests {
         StaticToolExecutor,
     };
     use crate::compact::CompactionConfig;
+    use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
     use crate::permissions::{
         PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
         PermissionRequest,
@@ -503,6 +575,141 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn denies_tool_use_when_pre_tool_hook_blocks() {
+        struct SingleCallApiClient;
+        impl ApiClient for SingleCallApiClient {
+            fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
+                if request
+                    .messages
+                    .iter()
+                    .any(|message| message.role == MessageRole::Tool)
+                {
+                    return Ok(vec![
+                        AssistantEvent::TextDelta("blocked".to_string()),
+                        AssistantEvent::MessageStop,
+                    ]);
+                }
+                Ok(vec![
+                    AssistantEvent::ToolUse {
+                        id: "tool-1".to_string(),
+                        name: "blocked".to_string(),
+                        input: r#"{"path":"secret.txt"}"#.to_string(),
+                    },
+                    AssistantEvent::MessageStop,
+                ])
+            }
+        }
+
+        let mut runtime = ConversationRuntime::new_with_features(
+            Session::new(),
+            SingleCallApiClient,
+            StaticToolExecutor::new().register("blocked", |_input| {
+                panic!("tool should not execute when hook denies")
+            }),
+            PermissionPolicy::new(PermissionMode::DangerFullAccess),
+            vec!["system".to_string()],
+            RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
+                vec![shell_snippet("printf 'blocked by hook'; exit 2")],
+                Vec::new(),
+            )),
+        );
+
+        let summary = runtime
+            .run_turn("use the tool", None)
+            .expect("conversation should continue after hook denial");
+
+        assert_eq!(summary.tool_results.len(), 1);
+        let ContentBlock::ToolResult {
+            is_error, output, ..
+        } = &summary.tool_results[0].blocks[0]
+        else {
+            panic!("expected tool result block");
+        };
+        assert!(
+            *is_error,
+            "hook denial should produce an error result: {output}"
+        );
+        assert!(
+            output.contains("denied tool") || output.contains("blocked by hook"),
+            "unexpected hook denial output: {output:?}"
+        );
+    }
+
+    #[test]
+    fn appends_post_tool_hook_feedback_to_tool_result() {
+        struct TwoCallApiClient {
+            calls: usize,
+        }
+
+        impl ApiClient for TwoCallApiClient {
+            fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
+                self.calls += 1;
+                match self.calls {
+                    1 => Ok(vec![
+                        AssistantEvent::ToolUse {
+                            id: "tool-1".to_string(),
+                            name: "add".to_string(),
+                            input: r#"{"lhs":2,"rhs":2}"#.to_string(),
+                        },
+                        AssistantEvent::MessageStop,
+                    ]),
+                    2 => {
+                        assert!(request
+                            .messages
+                            .iter()
+                            .any(|message| message.role == MessageRole::Tool));
+                        Ok(vec![
+                            AssistantEvent::TextDelta("done".to_string()),
+                            AssistantEvent::MessageStop,
+                        ])
+                    }
+                    _ => Err(RuntimeError::new("unexpected extra API call")),
+                }
+            }
+        }
+
+        let mut runtime = ConversationRuntime::new_with_features(
+            Session::new(),
+            TwoCallApiClient { calls: 0 },
+            StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
+            PermissionPolicy::new(PermissionMode::DangerFullAccess),
+            vec!["system".to_string()],
+            RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
+                vec![shell_snippet("printf 'pre hook ran'")],
+                vec![shell_snippet("printf 'post hook ran'")],
+            )),
+        );
+
+        let summary = runtime
+            .run_turn("use add", None)
+            .expect("tool loop succeeds");
+
+        assert_eq!(summary.tool_results.len(), 1);
+        let ContentBlock::ToolResult {
+            is_error, output, ..
+        } = &summary.tool_results[0].blocks[0]
+        else {
+            panic!("expected tool result block");
+        };
+        assert!(
+            !*is_error,
+            "post hook should preserve non-error result: {output:?}"
+        );
+        assert!(
+            output.contains("4"),
+            "tool output missing value: {output:?}"
+        );
+        assert!(
+            output.contains("pre hook ran"),
+            "tool output missing pre hook feedback: {output:?}"
+        );
+        assert!(
+            output.contains("post hook ran"),
+            "tool output missing post hook feedback: {output:?}"
+        );
+    }
+
     #[test]
     fn reconstructs_usage_tracker_from_restored_session() {
         struct SimpleApi;
@@ -581,4 +788,14 @@ mod tests {
             MessageRole::System
         );
     }
+
+    #[cfg(windows)]
+    fn shell_snippet(script: &str) -> String {
+        script.replace('\'', "\"")
+    }
+
+    #[cfg(not(windows))]
+    fn shell_snippet(script: &str) -> String {
+        script.to_string()
+    }
 }

+ 349 - 0
rust/crates/runtime/src/hooks.rs

@@ -0,0 +1,349 @@
+use std::ffi::OsStr;
+use std::process::Command;
+
+use serde_json::json;
+
+use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HookEvent {
+    PreToolUse,
+    PostToolUse,
+}
+
+impl HookEvent {
+    fn as_str(self) -> &'static str {
+        match self {
+            Self::PreToolUse => "PreToolUse",
+            Self::PostToolUse => "PostToolUse",
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct HookRunResult {
+    denied: bool,
+    messages: Vec<String>,
+}
+
+impl HookRunResult {
+    #[must_use]
+    pub fn allow(messages: Vec<String>) -> Self {
+        Self {
+            denied: false,
+            messages,
+        }
+    }
+
+    #[must_use]
+    pub fn is_denied(&self) -> bool {
+        self.denied
+    }
+
+    #[must_use]
+    pub fn messages(&self) -> &[String] {
+        &self.messages
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct HookRunner {
+    config: RuntimeHookConfig,
+}
+
+impl HookRunner {
+    #[must_use]
+    pub fn new(config: RuntimeHookConfig) -> Self {
+        Self { config }
+    }
+
+    #[must_use]
+    pub fn from_feature_config(feature_config: &RuntimeFeatureConfig) -> Self {
+        Self::new(feature_config.hooks().clone())
+    }
+
+    #[must_use]
+    pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
+        self.run_commands(
+            HookEvent::PreToolUse,
+            self.config.pre_tool_use(),
+            tool_name,
+            tool_input,
+            None,
+            false,
+        )
+    }
+
+    #[must_use]
+    pub fn run_post_tool_use(
+        &self,
+        tool_name: &str,
+        tool_input: &str,
+        tool_output: &str,
+        is_error: bool,
+    ) -> HookRunResult {
+        self.run_commands(
+            HookEvent::PostToolUse,
+            self.config.post_tool_use(),
+            tool_name,
+            tool_input,
+            Some(tool_output),
+            is_error,
+        )
+    }
+
+    fn run_commands(
+        &self,
+        event: HookEvent,
+        commands: &[String],
+        tool_name: &str,
+        tool_input: &str,
+        tool_output: Option<&str>,
+        is_error: bool,
+    ) -> 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();
+
+        let mut messages = Vec::new();
+
+        for command in commands {
+            match self.run_command(
+                command,
+                event,
+                tool_name,
+                tool_input,
+                tool_output,
+                is_error,
+                &payload,
+            ) {
+                HookCommandOutcome::Allow { message } => {
+                    if let Some(message) = message {
+                        messages.push(message);
+                    }
+                }
+                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::Warn { message } => messages.push(message),
+            }
+        }
+
+        HookRunResult::allow(messages)
+    }
+
+    fn run_command(
+        &self,
+        command: &str,
+        event: HookEvent,
+        tool_name: &str,
+        tool_input: &str,
+        tool_output: Option<&str>,
+        is_error: bool,
+        payload: &str,
+    ) -> 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.env("HOOK_EVENT", event.as_str());
+        child.env("HOOK_TOOL_NAME", tool_name);
+        child.env("HOOK_TOOL_INPUT", tool_input);
+        child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
+        if let Some(tool_output) = tool_output {
+            child.env("HOOK_TOOL_OUTPUT", tool_output);
+        }
+
+        match child.output_with_stdin(payload.as_bytes()) {
+            Ok(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);
+                match output.status.code() {
+                    Some(0) => HookCommandOutcome::Allow { message },
+                    Some(2) => HookCommandOutcome::Deny { message },
+                    Some(code) => HookCommandOutcome::Warn {
+                        message: format_hook_warning(
+                            command,
+                            code,
+                            message.as_deref(),
+                            stderr.as_str(),
+                        ),
+                    },
+                    None => HookCommandOutcome::Warn {
+                        message: format!(
+                            "{} hook `{command}` terminated by signal while handling `{tool_name}`",
+                            event.as_str()
+                        ),
+                    },
+                }
+            }
+            Err(error) => HookCommandOutcome::Warn {
+                message: format!(
+                    "{} hook `{command}` failed to start for `{tool_name}`: {error}",
+                    event.as_str()
+                ),
+            },
+        }
+    }
+}
+
+enum HookCommandOutcome {
+    Allow { message: Option<String> },
+    Deny { message: Option<String> },
+    Warn { message: String },
+}
+
+fn parse_tool_input(tool_input: &str) -> serde_json::Value {
+    serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
+}
+
+fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
+    let mut message =
+        format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
+    if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
+        message.push_str(": ");
+        message.push_str(stdout);
+    } else if !stderr.is_empty() {
+        message.push_str(": ");
+        message.push_str(stderr);
+    }
+    message
+}
+
+fn shell_command(command: &str) -> CommandWithStdin {
+    #[cfg(windows)]
+    let mut command_builder = {
+        let mut command_builder = Command::new("cmd");
+        command_builder.arg("/C").arg(command);
+        CommandWithStdin::new(command_builder)
+    };
+
+    #[cfg(not(windows))]
+    let command_builder = {
+        let mut command_builder = Command::new("sh");
+        command_builder.arg("-lc").arg(command);
+        CommandWithStdin::new(command_builder)
+    };
+
+    command_builder
+}
+
+struct CommandWithStdin {
+    command: Command,
+}
+
+impl CommandWithStdin {
+    fn new(command: Command) -> Self {
+        Self { command }
+    }
+
+    fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
+        self.command.stdin(cfg);
+        self
+    }
+
+    fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
+        self.command.stdout(cfg);
+        self
+    }
+
+    fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
+        self.command.stderr(cfg);
+        self
+    }
+
+    fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
+    where
+        K: AsRef<OsStr>,
+        V: AsRef<OsStr>,
+    {
+        self.command.env(key, value);
+        self
+    }
+
+    fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
+        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()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{HookRunResult, HookRunner};
+    use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
+
+    #[test]
+    fn allows_exit_code_zero_and_captures_stdout() {
+        let runner = HookRunner::new(RuntimeHookConfig::new(
+            vec![shell_snippet("printf 'pre ok'")],
+            Vec::new(),
+        ));
+
+        let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
+
+        assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
+    }
+
+    #[test]
+    fn denies_exit_code_two() {
+        let runner = HookRunner::new(RuntimeHookConfig::new(
+            vec![shell_snippet("printf 'blocked by hook'; exit 2")],
+            Vec::new(),
+        ));
+
+        let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
+
+        assert!(result.is_denied());
+        assert_eq!(result.messages(), &["blocked by hook".to_string()]);
+    }
+
+    #[test]
+    fn warns_for_other_non_zero_statuses() {
+        let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
+            RuntimeHookConfig::new(
+                vec![shell_snippet("printf 'warning hook'; exit 1")],
+                Vec::new(),
+            ),
+        ));
+
+        let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
+
+        assert!(!result.is_denied());
+        assert!(result
+            .messages()
+            .iter()
+            .any(|message| message.contains("allowing tool execution to continue")));
+    }
+
+    #[cfg(windows)]
+    fn shell_snippet(script: &str) -> String {
+        script.replace('\'', "\"")
+    }
+
+    #[cfg(not(windows))]
+    fn shell_snippet(script: &str) -> String {
+        script.to_string()
+    }
+}

+ 4 - 2
rust/crates/runtime/src/lib.rs

@@ -4,6 +4,7 @@ mod compact;
 mod config;
 mod conversation;
 mod file_ops;
+mod hooks;
 mod json;
 mod mcp;
 mod mcp_client;
@@ -26,8 +27,8 @@ pub use config::{
     ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
     McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
     McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
-    ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig,
-    CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
+    ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
+    ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
 };
 pub use conversation::{
     ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
@@ -38,6 +39,7 @@ pub use file_ops::{
     GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
     WriteFileOutput,
 };
+pub use hooks::{HookEvent, 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,

+ 13 - 3
rust/crates/rusty-claude-cli/src/main.rs

@@ -27,8 +27,8 @@ use runtime::{
     clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
     parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
     AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
-    ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
-    OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
+    ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
+    OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
     Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
 };
 use serde_json::json;
@@ -1903,6 +1903,15 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
     )?)
 }
 
+fn build_runtime_feature_config(
+) -> Result<runtime::RuntimeFeatureConfig, Box<dyn std::error::Error>> {
+    let cwd = env::current_dir()?;
+    Ok(ConfigLoader::default_for(cwd)
+        .load()?
+        .feature_config()
+        .clone())
+}
+
 fn build_runtime(
     session: Session,
     model: String,
@@ -1913,12 +1922,13 @@ fn build_runtime(
     permission_mode: PermissionMode,
 ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
 {
-    Ok(ConversationRuntime::new(
+    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),
         system_prompt,
+        build_runtime_feature_config()?,
     ))
 }