fix: parse object-style hook config

This commit is contained in:
bellman
2026-06-03 21:23:00 +09:00
parent 9c8375da99
commit 6388a2ba3f
5 changed files with 411 additions and 43 deletions

View File

@@ -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<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
pre_tool_use: Vec<RuntimeHookCommand>,
post_tool_use: Vec<RuntimeHookCommand>,
post_tool_use_failure: Vec<RuntimeHookCommand>,
}
/// A hook command plus optional tool matcher from object-style hook config.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeHookCommand {
command: String,
matcher: Option<String>,
}
/// 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<String>) -> Self {
Self {
command: command.into(),
matcher: None,
}
}
#[must_use]
pub fn with_matcher(command: impl Into<String>, matcher: Option<String>) -> 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<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
) -> 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<RuntimeHookCommand>,
post_tool_use: Vec<RuntimeHookCommand>,
post_tool_use_failure: Vec<RuntimeHookCommand>,
) -> 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<String> {
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<String> {
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<String> {
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<String> {
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::<Vec<_>>();
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<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<Vec<RuntimeHookCommand>>, 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<String, JsonValue>,
context: &str,
key: &str,
index: usize,
) -> Result<Option<String>, 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<RuntimeHookCommand>,
values: &[RuntimeHookCommand],
) {
for value in values {
if !target.iter().any(|existing| existing == value) {
target.push(value.clone());
}
}
}
fn optional_string_map(
object: &BTreeMap<String, JsonValue>,
key: &str,
@@ -1546,24 +1781,12 @@ fn deep_merge_objects(
}
}
fn extend_unique(target: &mut Vec<String>, values: &[String]) {
for value in values {
push_unique(target, value.clone());
}
}
fn push_unique(target: &mut Vec<String>, 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();

View File

@@ -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 [

View File

@@ -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(

View File

@@ -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,
};