diff --git a/USAGE.md b/USAGE.md index 190dc2c8..0927245f 100644 --- a/USAGE.md +++ b/USAGE.md @@ -519,6 +519,28 @@ Runtime config is loaded in this order, with later entries overriding earlier on 4. `/.claw/settings.json` 5. `/.claw/settings.local.json` +## Hook configuration + +`hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.PostToolUseFailure` accept either legacy command strings or object-style entries with a `matcher` and nested command hooks: + +```json +{ + "hooks": { + "PreToolUse": [ + "echo legacy hook", + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "scripts/audit-bash.sh" } + ] + } + ] + } +} +``` + +Object-style matchers are optional. When present, they match tool names case-insensitively and support `*` wildcards plus comma or pipe separated alternatives. Nested hook `type` may be omitted or set to `"command"`; each nested command runs in configuration order. + ## Project instruction rules In addition to root instruction files such as `CLAUDE.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from: diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 527cddac..d165c17f 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -135,9 +135,16 @@ pub struct ProviderFallbackConfig { /// Hook command lists grouped by lifecycle stage. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeHookConfig { - pre_tool_use: Vec, - post_tool_use: Vec, - post_tool_use_failure: Vec, + pre_tool_use: Vec, + post_tool_use: Vec, + post_tool_use_failure: Vec, +} + +/// A hook command plus optional tool matcher from object-style hook config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeHookCommand { + command: String, + matcher: Option, } /// Raw permission rule lists grouped by allow, deny, and ask behavior. @@ -823,12 +830,76 @@ fn write_settings_root( fs::write(path, format!("{rendered}\n")).map_err(ConfigError::Io) } +impl RuntimeHookCommand { + #[must_use] + pub fn new(command: impl Into) -> Self { + Self { + command: command.into(), + matcher: None, + } + } + + #[must_use] + pub fn with_matcher(command: impl Into, matcher: Option) -> Self { + Self { + command: command.into(), + matcher: matcher.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }), + } + } + + #[must_use] + pub fn command(&self) -> &str { + &self.command + } + + #[must_use] + pub fn matcher(&self) -> Option<&str> { + self.matcher.as_deref() + } + + #[must_use] + pub fn matches_tool(&self, tool_name: &str) -> bool { + self.matcher + .as_deref() + .is_none_or(|matcher| hook_matcher_matches(matcher, tool_name)) + } +} + impl RuntimeHookConfig { #[must_use] pub fn new( pre_tool_use: Vec, post_tool_use: Vec, post_tool_use_failure: Vec, + ) -> Self { + Self::from_hook_commands( + pre_tool_use + .into_iter() + .map(RuntimeHookCommand::new) + .collect(), + post_tool_use + .into_iter() + .map(RuntimeHookCommand::new) + .collect(), + post_tool_use_failure + .into_iter() + .map(RuntimeHookCommand::new) + .collect(), + ) + } + + #[must_use] + pub fn from_hook_commands( + pre_tool_use: Vec, + post_tool_use: Vec, + post_tool_use_failure: Vec, ) -> Self { Self { pre_tool_use, @@ -838,12 +909,22 @@ impl RuntimeHookConfig { } #[must_use] - pub fn pre_tool_use(&self) -> &[String] { + pub fn pre_tool_use(&self) -> Vec { + hook_commands(&self.pre_tool_use) + } + + #[must_use] + pub fn pre_tool_use_entries(&self) -> &[RuntimeHookCommand] { &self.pre_tool_use } #[must_use] - pub fn post_tool_use(&self) -> &[String] { + pub fn post_tool_use(&self) -> Vec { + hook_commands(&self.post_tool_use) + } + + #[must_use] + pub fn post_tool_use_entries(&self) -> &[RuntimeHookCommand] { &self.post_tool_use } @@ -855,20 +936,72 @@ impl RuntimeHookConfig { } pub fn extend(&mut self, other: &Self) { - extend_unique(&mut self.pre_tool_use, other.pre_tool_use()); - extend_unique(&mut self.post_tool_use, other.post_tool_use()); - extend_unique( + extend_unique_hook_commands(&mut self.pre_tool_use, other.pre_tool_use_entries()); + extend_unique_hook_commands(&mut self.post_tool_use, other.post_tool_use_entries()); + extend_unique_hook_commands( &mut self.post_tool_use_failure, - other.post_tool_use_failure(), + other.post_tool_use_failure_entries(), ); } #[must_use] - pub fn post_tool_use_failure(&self) -> &[String] { + pub fn post_tool_use_failure(&self) -> Vec { + hook_commands(&self.post_tool_use_failure) + } + + #[must_use] + pub fn post_tool_use_failure_entries(&self) -> &[RuntimeHookCommand] { &self.post_tool_use_failure } } +fn hook_commands(commands: &[RuntimeHookCommand]) -> Vec { + commands.iter().map(|entry| entry.command.clone()).collect() +} + +fn hook_matcher_matches(matcher: &str, tool_name: &str) -> bool { + matcher + .split([',', '|']) + .map(str::trim) + .filter(|part| !part.is_empty()) + .any(|part| { + part == "*" || part.eq_ignore_ascii_case(tool_name) || wildcard_match(part, tool_name) + }) +} + +fn wildcard_match(pattern: &str, value: &str) -> bool { + if !pattern.contains('*') { + return false; + } + let pattern = pattern.to_ascii_lowercase(); + let value = value.to_ascii_lowercase(); + let parts = pattern.split('*').collect::>(); + let mut remainder = value.as_str(); + let starts_with_wildcard = pattern.starts_with('*'); + let ends_with_wildcard = pattern.ends_with('*'); + + if let Some(first) = parts.first().filter(|part| !part.is_empty()) { + if !starts_with_wildcard && !remainder.starts_with(first) { + return false; + } + if let Some(index) = remainder.find(first) { + remainder = &remainder[index + first.len()..]; + } + } + + for part in parts.iter().skip(1).filter(|part| !part.is_empty()) { + let Some(index) = remainder.find(part) else { + return false; + }; + remainder = &remainder[index + part.len()..]; + } + + ends_with_wildcard + || parts + .last() + .is_none_or(|last| last.is_empty() || remainder.is_empty()) +} + impl RuntimePermissionRuleConfig { #[must_use] pub fn new( @@ -1043,9 +1176,11 @@ fn parse_optional_hooks_config_object( }; let hooks = expect_object(hooks_value, context)?; Ok(RuntimeHookConfig { - pre_tool_use: optional_string_array(hooks, "PreToolUse", context)?.unwrap_or_default(), - post_tool_use: optional_string_array(hooks, "PostToolUse", context)?.unwrap_or_default(), - post_tool_use_failure: optional_string_array(hooks, "PostToolUseFailure", context)? + pre_tool_use: optional_hook_command_array(hooks, "PreToolUse", context)? + .unwrap_or_default(), + post_tool_use: optional_hook_command_array(hooks, "PostToolUse", context)? + .unwrap_or_default(), + post_tool_use_failure: optional_hook_command_array(hooks, "PostToolUseFailure", context)? .unwrap_or_default(), }) } @@ -1500,6 +1635,106 @@ fn optional_string_array( } } +fn optional_hook_command_array( + object: &BTreeMap, + key: &str, + context: &str, +) -> Result>, ConfigError> { + let Some(value) = object.get(key) else { + return Ok(None); + }; + let Some(array) = value.as_array() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} must be an array" + ))); + }; + + let mut commands = Vec::new(); + for (index, item) in array.iter().enumerate() { + if let Some(command) = item.as_str() { + commands.push(RuntimeHookCommand::new(command.to_string())); + continue; + } + + let Some(entry) = item.as_object() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key}[{index}] must be a string or hook object" + ))); + }; + let matcher = optional_hook_matcher(entry, context, key, index)?; + let hooks = entry + .get("hooks") + .and_then(JsonValue::as_array) + .ok_or_else(|| { + ConfigError::Parse(format!( + "{context}: field {key}[{index}].hooks must be an array" + )) + })?; + for (hook_index, hook) in hooks.iter().enumerate() { + let Some(hook_object) = hook.as_object() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key}[{index}].hooks[{hook_index}] must be an object" + ))); + }; + if let Some(hook_type) = hook_object.get("type") { + let Some(hook_type) = hook_type.as_str() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key}[{index}].hooks[{hook_index}].type must be a string" + ))); + }; + if hook_type != "command" { + return Err(ConfigError::Parse(format!( + "{context}: field {key}[{index}].hooks[{hook_index}].type must be \"command\"" + ))); + } + } + let command = hook_object + .get("command") + .and_then(JsonValue::as_str) + .filter(|command| !command.trim().is_empty()) + .ok_or_else(|| { + ConfigError::Parse(format!( + "{context}: field {key}[{index}].hooks[{hook_index}].command must be a non-empty string" + )) + })?; + commands.push(RuntimeHookCommand::with_matcher( + command.to_string(), + matcher.clone(), + )); + } + } + Ok(Some(commands)) +} + +fn optional_hook_matcher( + entry: &BTreeMap, + context: &str, + key: &str, + index: usize, +) -> Result, ConfigError> { + entry + .get("matcher") + .map(|value| { + value.as_str().map(str::to_string).ok_or_else(|| { + ConfigError::Parse(format!( + "{context}: field {key}[{index}].matcher must be a string" + )) + }) + }) + .transpose() +} + +fn extend_unique_hook_commands( + target: &mut Vec, + values: &[RuntimeHookCommand], +) { + for value in values { + if !target.iter().any(|existing| existing == value) { + target.push(value.clone()); + } + } +} + fn optional_string_map( object: &BTreeMap, key: &str, @@ -1546,24 +1781,12 @@ fn deep_merge_objects( } } -fn extend_unique(target: &mut Vec, values: &[String]) { - for value in values { - push_unique(target, value.clone()); - } -} - -fn push_unique(target: &mut Vec, value: String) { - if !target.iter().any(|existing| existing == &value) { - target.push(value); - } -} - #[cfg(test)] mod tests { use super::{ deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig, - RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME, + RuntimeHookCommand, RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME, }; use crate::json::JsonValue; use crate::sandbox::FilesystemIsolationMode; @@ -1695,6 +1918,65 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn parses_object_style_hook_entries_with_matchers() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"bash-one"},{"type":"command","command":"bash-two"}]},{"matcher":"Read*","hooks":[{"command":"read-any"}]}]}}"#, + ) + .expect("write settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + assert_eq!( + loaded.hooks().pre_tool_use(), + vec![ + "legacy".to_string(), + "bash-one".to_string(), + "bash-two".to_string(), + "read-any".to_string(), + ] + ); + let entries = loaded.hooks().pre_tool_use_entries(); + assert_eq!(entries[0], RuntimeHookCommand::new("legacy")); + assert_eq!(entries[1].matcher(), Some("Bash")); + assert!(entries[1].matches_tool("bash")); + assert!(!entries[1].matches_tool("Read")); + assert!(entries[3].matches_tool("ReadFile")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn rejects_object_style_hook_entries_without_command() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command"}]}]}}"#, + ) + .expect("write settings"); + + let error = ConfigLoader::new(&cwd, &home) + .load() + .expect_err("config should reject malformed hook entry"); + + assert!(error + .to_string() + .contains("command must be a non-empty string")); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn parses_sandbox_config() { let root = temp_dir(); diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 4b33e323..4e0bd08a 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -92,6 +92,7 @@ enum FieldType { Bool, Object, StringArray, + HookArray, RulesImport, Number, } @@ -104,6 +105,7 @@ impl FieldType { Self::Object => "an object", Self::StringArray => "an array of strings", Self::RulesImport => "a string or an array of strings", + Self::HookArray => "an array of strings or hook objects", Self::Number => "a number", } } @@ -116,6 +118,10 @@ impl FieldType { Self::StringArray => value .as_array() .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())), + Self::HookArray => value.as_array().is_some_and(|arr| { + arr.iter() + .all(|entry| entry.as_str().is_some() || entry.as_object().is_some()) + }), Self::RulesImport => { value.as_str().is_some() || value @@ -218,15 +224,15 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ const HOOKS_FIELDS: &[FieldSpec] = &[ FieldSpec { name: "PreToolUse", - expected: FieldType::StringArray, + expected: FieldType::HookArray, }, FieldSpec { name: "PostToolUse", - expected: FieldType::StringArray, + expected: FieldType::HookArray, }, FieldSpec { name: "PostToolUseFailure", - expected: FieldType::StringArray, + expected: FieldType::HookArray, }, ]; @@ -717,6 +723,29 @@ mod tests { assert_eq!(result.errors[0].field, "hooks.BadHook"); } + #[test] + fn validates_object_style_hook_entries() { + let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + let result = validate_config_file(object, source, &test_path()); + + assert!(result.errors.is_empty(), "{:?}", result.errors); + } + + #[test] + fn rejects_wrong_hook_entry_types() { + let source = r#"{"hooks":{"PreToolUse":[42]}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + let result = validate_config_file(object, source, &test_path()); + + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "hooks.PreToolUse"); + } + #[test] fn validates_rules_import_string_and_array_forms() { for source in [ diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs index a79c2d5d..2d9f25e7 100644 --- a/rust/crates/runtime/src/hooks.rs +++ b/rust/crates/runtime/src/hooks.rs @@ -11,7 +11,7 @@ use std::time::Duration; use serde_json::{json, Value}; -use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; +use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig}; use crate::permissions::PermissionOverride; const HOOK_PREVIEW_CHAR_LIMIT: usize = 160; @@ -182,7 +182,7 @@ impl HookRunner { ) -> HookRunResult { Self::run_commands( HookEvent::PreToolUse, - self.config.pre_tool_use(), + self.config.pre_tool_use_entries(), tool_name, tool_input, None, @@ -232,7 +232,7 @@ impl HookRunner { ) -> HookRunResult { Self::run_commands( HookEvent::PostToolUse, - self.config.post_tool_use(), + self.config.post_tool_use_entries(), tool_name, tool_input, Some(tool_output), @@ -282,7 +282,7 @@ impl HookRunner { ) -> HookRunResult { Self::run_commands( HookEvent::PostToolUseFailure, - self.config.post_tool_use_failure(), + self.config.post_tool_use_failure_entries(), tool_name, tool_input, Some(tool_error), @@ -312,7 +312,7 @@ impl HookRunner { #[allow(clippy::too_many_arguments)] fn run_commands( event: HookEvent, - commands: &[String], + commands: &[RuntimeHookCommand], tool_name: &str, tool_input: &str, tool_output: Option<&str>, @@ -342,17 +342,21 @@ impl HookRunner { 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 { + for command in commands + .iter() + .filter(|command| command.matches_tool(tool_name)) + { + let command_text = command.command(); if let Some(reporter) = reporter.as_deref_mut() { reporter.on_event(&HookProgressEvent::Started { event, tool_name: tool_name.to_string(), - command: command.clone(), + command: command_text.to_string(), }); } match Self::run_command( - command, + command_text, event, tool_name, tool_input, @@ -366,7 +370,7 @@ impl HookRunner { reporter.on_event(&HookProgressEvent::Completed { event, tool_name: tool_name.to_string(), - command: command.clone(), + command: command_text.to_string(), }); } merge_parsed_hook_output(&mut result, parsed); @@ -376,7 +380,7 @@ impl HookRunner { reporter.on_event(&HookProgressEvent::Completed { event, tool_name: tool_name.to_string(), - command: command.clone(), + command: command_text.to_string(), }); } merge_parsed_hook_output(&mut result, parsed); @@ -388,7 +392,7 @@ impl HookRunner { reporter.on_event(&HookProgressEvent::Completed { event, tool_name: tool_name.to_string(), - command: command.clone(), + command: command_text.to_string(), }); } merge_parsed_hook_output(&mut result, parsed); @@ -400,7 +404,7 @@ impl HookRunner { reporter.on_event(&HookProgressEvent::Cancelled { event, tool_name: tool_name.to_string(), - command: command.clone(), + command: command_text.to_string(), }); } result.cancelled = true; @@ -825,7 +829,7 @@ mod tests { HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner, }; - use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; + use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig}; use crate::permissions::PermissionOverride; struct RecordingReporter { @@ -851,6 +855,37 @@ mod tests { assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()])); } + #[test] + fn object_style_hook_matchers_filter_runtime_execution() { + let runner = HookRunner::new(RuntimeHookConfig::from_hook_commands( + vec![ + RuntimeHookCommand::new(shell_snippet("printf 'legacy'")), + RuntimeHookCommand::with_matcher( + shell_snippet("printf 'bash only'"), + Some("Bash".to_string()), + ), + RuntimeHookCommand::with_matcher( + shell_snippet("printf 'read only'"), + Some("Read*".to_string()), + ), + ], + Vec::new(), + Vec::new(), + )); + + let read_result = runner.run_pre_tool_use("ReadFile", r#"{"path":"README.md"}"#); + let bash_result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#); + + assert_eq!( + read_result, + HookRunResult::allow(vec!["legacy".to_string(), "read only".to_string()]) + ); + assert_eq!( + bash_result, + HookRunResult::allow(vec!["legacy".to_string(), "bash only".to_string()]) + ); + } + #[test] fn denies_exit_code_two() { let runner = HookRunner::new(RuntimeHookConfig::new( diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 48b16d07..64330dc6 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -69,7 +69,7 @@ pub use config::{ McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, - RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, + RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, };