mirror of
https://github.com/instructkr/claude-code.git
synced 2026-06-04 11:36:44 +00:00
fix: parse object-style hook config
This commit is contained in:
22
USAGE.md
22
USAGE.md
@@ -519,6 +519,28 @@ Runtime config is loaded in this order, with later entries overriding earlier on
|
|||||||
4. `<repo>/.claw/settings.json`
|
4. `<repo>/.claw/settings.json`
|
||||||
5. `<repo>/.claw/settings.local.json`
|
5. `<repo>/.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
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -135,9 +135,16 @@ pub struct ProviderFallbackConfig {
|
|||||||
/// Hook command lists grouped by lifecycle stage.
|
/// Hook command lists grouped by lifecycle stage.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct RuntimeHookConfig {
|
pub struct RuntimeHookConfig {
|
||||||
pre_tool_use: Vec<String>,
|
pre_tool_use: Vec<RuntimeHookCommand>,
|
||||||
post_tool_use: Vec<String>,
|
post_tool_use: Vec<RuntimeHookCommand>,
|
||||||
post_tool_use_failure: Vec<String>,
|
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.
|
/// 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)
|
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 {
|
impl RuntimeHookConfig {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
pre_tool_use: Vec<String>,
|
pre_tool_use: Vec<String>,
|
||||||
post_tool_use: Vec<String>,
|
post_tool_use: Vec<String>,
|
||||||
post_tool_use_failure: 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 {
|
||||||
Self {
|
Self {
|
||||||
pre_tool_use,
|
pre_tool_use,
|
||||||
@@ -838,12 +909,22 @@ impl RuntimeHookConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[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
|
&self.pre_tool_use
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_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
|
&self.post_tool_use
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,20 +936,72 @@ impl RuntimeHookConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn extend(&mut self, other: &Self) {
|
pub fn extend(&mut self, other: &Self) {
|
||||||
extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
|
extend_unique_hook_commands(&mut self.pre_tool_use, other.pre_tool_use_entries());
|
||||||
extend_unique(&mut self.post_tool_use, other.post_tool_use());
|
extend_unique_hook_commands(&mut self.post_tool_use, other.post_tool_use_entries());
|
||||||
extend_unique(
|
extend_unique_hook_commands(
|
||||||
&mut self.post_tool_use_failure,
|
&mut self.post_tool_use_failure,
|
||||||
other.post_tool_use_failure(),
|
other.post_tool_use_failure_entries(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[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
|
&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 {
|
impl RuntimePermissionRuleConfig {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
@@ -1043,9 +1176,11 @@ fn parse_optional_hooks_config_object(
|
|||||||
};
|
};
|
||||||
let hooks = expect_object(hooks_value, context)?;
|
let hooks = expect_object(hooks_value, context)?;
|
||||||
Ok(RuntimeHookConfig {
|
Ok(RuntimeHookConfig {
|
||||||
pre_tool_use: optional_string_array(hooks, "PreToolUse", context)?.unwrap_or_default(),
|
pre_tool_use: optional_hook_command_array(hooks, "PreToolUse", context)?
|
||||||
post_tool_use: optional_string_array(hooks, "PostToolUse", context)?.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
post_tool_use_failure: optional_string_array(hooks, "PostToolUseFailure", context)?
|
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(),
|
.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(
|
fn optional_string_map(
|
||||||
object: &BTreeMap<String, JsonValue>,
|
object: &BTreeMap<String, JsonValue>,
|
||||||
key: &str,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
|
deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
|
||||||
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig,
|
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig,
|
||||||
RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
RuntimeHookCommand, RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
};
|
};
|
||||||
use crate::json::JsonValue;
|
use crate::json::JsonValue;
|
||||||
use crate::sandbox::FilesystemIsolationMode;
|
use crate::sandbox::FilesystemIsolationMode;
|
||||||
@@ -1695,6 +1918,65 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
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]
|
#[test]
|
||||||
fn parses_sandbox_config() {
|
fn parses_sandbox_config() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ enum FieldType {
|
|||||||
Bool,
|
Bool,
|
||||||
Object,
|
Object,
|
||||||
StringArray,
|
StringArray,
|
||||||
|
HookArray,
|
||||||
RulesImport,
|
RulesImport,
|
||||||
Number,
|
Number,
|
||||||
}
|
}
|
||||||
@@ -104,6 +105,7 @@ impl FieldType {
|
|||||||
Self::Object => "an object",
|
Self::Object => "an object",
|
||||||
Self::StringArray => "an array of strings",
|
Self::StringArray => "an array of strings",
|
||||||
Self::RulesImport => "a string or 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",
|
Self::Number => "a number",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +118,10 @@ impl FieldType {
|
|||||||
Self::StringArray => value
|
Self::StringArray => value
|
||||||
.as_array()
|
.as_array()
|
||||||
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
|
.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 => {
|
Self::RulesImport => {
|
||||||
value.as_str().is_some()
|
value.as_str().is_some()
|
||||||
|| value
|
|| value
|
||||||
@@ -218,15 +224,15 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
|||||||
const HOOKS_FIELDS: &[FieldSpec] = &[
|
const HOOKS_FIELDS: &[FieldSpec] = &[
|
||||||
FieldSpec {
|
FieldSpec {
|
||||||
name: "PreToolUse",
|
name: "PreToolUse",
|
||||||
expected: FieldType::StringArray,
|
expected: FieldType::HookArray,
|
||||||
},
|
},
|
||||||
FieldSpec {
|
FieldSpec {
|
||||||
name: "PostToolUse",
|
name: "PostToolUse",
|
||||||
expected: FieldType::StringArray,
|
expected: FieldType::HookArray,
|
||||||
},
|
},
|
||||||
FieldSpec {
|
FieldSpec {
|
||||||
name: "PostToolUseFailure",
|
name: "PostToolUseFailure",
|
||||||
expected: FieldType::StringArray,
|
expected: FieldType::HookArray,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -717,6 +723,29 @@ mod tests {
|
|||||||
assert_eq!(result.errors[0].field, "hooks.BadHook");
|
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]
|
#[test]
|
||||||
fn validates_rules_import_string_and_array_forms() {
|
fn validates_rules_import_string_and_array_forms() {
|
||||||
for source in [
|
for source in [
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
|
||||||
use crate::permissions::PermissionOverride;
|
use crate::permissions::PermissionOverride;
|
||||||
|
|
||||||
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
|
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
|
||||||
@@ -182,7 +182,7 @@ impl HookRunner {
|
|||||||
) -> HookRunResult {
|
) -> HookRunResult {
|
||||||
Self::run_commands(
|
Self::run_commands(
|
||||||
HookEvent::PreToolUse,
|
HookEvent::PreToolUse,
|
||||||
self.config.pre_tool_use(),
|
self.config.pre_tool_use_entries(),
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_input,
|
tool_input,
|
||||||
None,
|
None,
|
||||||
@@ -232,7 +232,7 @@ impl HookRunner {
|
|||||||
) -> HookRunResult {
|
) -> HookRunResult {
|
||||||
Self::run_commands(
|
Self::run_commands(
|
||||||
HookEvent::PostToolUse,
|
HookEvent::PostToolUse,
|
||||||
self.config.post_tool_use(),
|
self.config.post_tool_use_entries(),
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_input,
|
tool_input,
|
||||||
Some(tool_output),
|
Some(tool_output),
|
||||||
@@ -282,7 +282,7 @@ impl HookRunner {
|
|||||||
) -> HookRunResult {
|
) -> HookRunResult {
|
||||||
Self::run_commands(
|
Self::run_commands(
|
||||||
HookEvent::PostToolUseFailure,
|
HookEvent::PostToolUseFailure,
|
||||||
self.config.post_tool_use_failure(),
|
self.config.post_tool_use_failure_entries(),
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_input,
|
tool_input,
|
||||||
Some(tool_error),
|
Some(tool_error),
|
||||||
@@ -312,7 +312,7 @@ impl HookRunner {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn run_commands(
|
fn run_commands(
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
commands: &[String],
|
commands: &[RuntimeHookCommand],
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
tool_input: &str,
|
tool_input: &str,
|
||||||
tool_output: Option<&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 payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
|
||||||
let mut result = HookRunResult::allow(Vec::new());
|
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() {
|
if let Some(reporter) = reporter.as_deref_mut() {
|
||||||
reporter.on_event(&HookProgressEvent::Started {
|
reporter.on_event(&HookProgressEvent::Started {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
match Self::run_command(
|
match Self::run_command(
|
||||||
command,
|
command_text,
|
||||||
event,
|
event,
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_input,
|
tool_input,
|
||||||
@@ -366,7 +370,7 @@ impl HookRunner {
|
|||||||
reporter.on_event(&HookProgressEvent::Completed {
|
reporter.on_event(&HookProgressEvent::Completed {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
merge_parsed_hook_output(&mut result, parsed);
|
merge_parsed_hook_output(&mut result, parsed);
|
||||||
@@ -376,7 +380,7 @@ impl HookRunner {
|
|||||||
reporter.on_event(&HookProgressEvent::Completed {
|
reporter.on_event(&HookProgressEvent::Completed {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
merge_parsed_hook_output(&mut result, parsed);
|
merge_parsed_hook_output(&mut result, parsed);
|
||||||
@@ -388,7 +392,7 @@ impl HookRunner {
|
|||||||
reporter.on_event(&HookProgressEvent::Completed {
|
reporter.on_event(&HookProgressEvent::Completed {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
merge_parsed_hook_output(&mut result, parsed);
|
merge_parsed_hook_output(&mut result, parsed);
|
||||||
@@ -400,7 +404,7 @@ impl HookRunner {
|
|||||||
reporter.on_event(&HookProgressEvent::Cancelled {
|
reporter.on_event(&HookProgressEvent::Cancelled {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
result.cancelled = true;
|
result.cancelled = true;
|
||||||
@@ -825,7 +829,7 @@ mod tests {
|
|||||||
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
|
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
|
||||||
HookRunner,
|
HookRunner,
|
||||||
};
|
};
|
||||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
|
||||||
use crate::permissions::PermissionOverride;
|
use crate::permissions::PermissionOverride;
|
||||||
|
|
||||||
struct RecordingReporter {
|
struct RecordingReporter {
|
||||||
@@ -851,6 +855,37 @@ mod tests {
|
|||||||
assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
|
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]
|
#[test]
|
||||||
fn denies_exit_code_two() {
|
fn denies_exit_code_two() {
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ pub use config::{
|
|||||||
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
||||||
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
||||||
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||||
CLAW_SETTINGS_SCHEMA_NAME,
|
CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user