mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-04 13:37:09 +08:00
fix: parse object-style hook config
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user