Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit 5bee22b

Browse files
committed
Prevent invalid hook configs from poisoning merged runtime settings
Validate hook arrays in each config file before deep-merging so malformed entries fail with source-path context instead of surfacing later as a merged hook parse error. Constraint: Runtime hook config currently supports only string command arrays Rejected: Add hook-specific schema logic inside deep_merge_objects | keeps generic merge helper decoupled from config semantics Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep hook validation source-aware before generic config merges so file-specific errors remain diagnosable Tested: cargo build --workspace; cargo test --workspace Not-tested: live claw --help against a malformed external user config
1 parent 5b9e47e commit 5bee22b

1 file changed

Lines changed: 56 additions & 10 deletions

File tree

rust/crates/runtime/src/config.rs

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ impl ConfigLoader {
242242
let Some(value) = read_optional_json_object(&entry.path)? else {
243243
continue;
244244
};
245+
validate_optional_hooks_config(&value, &entry.path)?;
245246
merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
246247
deep_merge_objects(&mut merged, &value);
247248
loaded_entries.push(entry);
@@ -617,24 +618,32 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, Co
617618
let Some(object) = root.as_object() else {
618619
return Ok(RuntimeHookConfig::default());
619620
};
621+
parse_optional_hooks_config_object(object, "merged settings.hooks")
622+
}
623+
624+
fn parse_optional_hooks_config_object(
625+
object: &BTreeMap<String, JsonValue>,
626+
context: &str,
627+
) -> Result<RuntimeHookConfig, ConfigError> {
620628
let Some(hooks_value) = object.get("hooks") else {
621629
return Ok(RuntimeHookConfig::default());
622630
};
623-
let hooks = expect_object(hooks_value, "merged settings.hooks")?;
631+
let hooks = expect_object(hooks_value, context)?;
624632
Ok(RuntimeHookConfig {
625-
pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
626-
.unwrap_or_default(),
627-
post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
633+
pre_tool_use: optional_string_array(hooks, "PreToolUse", context)?.unwrap_or_default(),
634+
post_tool_use: optional_string_array(hooks, "PostToolUse", context)?.unwrap_or_default(),
635+
post_tool_use_failure: optional_string_array(hooks, "PostToolUseFailure", context)?
628636
.unwrap_or_default(),
629-
post_tool_use_failure: optional_string_array(
630-
hooks,
631-
"PostToolUseFailure",
632-
"merged settings.hooks",
633-
)?
634-
.unwrap_or_default(),
635637
})
636638
}
637639

640+
fn validate_optional_hooks_config(
641+
root: &BTreeMap<String, JsonValue>,
642+
path: &Path,
643+
) -> Result<(), ConfigError> {
644+
parse_optional_hooks_config_object(root, &format!("{}: hooks", path.display())).map(|_| ())
645+
}
646+
638647
fn parse_optional_permission_rules(
639648
root: &JsonValue,
640649
) -> Result<RuntimePermissionRuleConfig, ConfigError> {
@@ -1513,6 +1522,43 @@ mod tests {
15131522
assert!(target.contains_key("sandbox"));
15141523
}
15151524

1525+
#[test]
1526+
fn rejects_invalid_hook_entries_before_merge() {
1527+
// given
1528+
let root = temp_dir();
1529+
let cwd = root.join("project");
1530+
let home = root.join("home").join(".claw");
1531+
let project_settings = cwd.join(".claw").join("settings.json");
1532+
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
1533+
fs::create_dir_all(&home).expect("home config dir");
1534+
1535+
fs::write(
1536+
home.join("settings.json"),
1537+
r#"{"hooks":{"PreToolUse":["base"]}}"#,
1538+
)
1539+
.expect("write user settings");
1540+
fs::write(
1541+
&project_settings,
1542+
r#"{"hooks":{"PreToolUse":["project",42]}}"#,
1543+
)
1544+
.expect("write invalid project settings");
1545+
1546+
// when
1547+
let error = ConfigLoader::new(&cwd, &home)
1548+
.load()
1549+
.expect_err("config should fail");
1550+
1551+
// then
1552+
let rendered = error.to_string();
1553+
assert!(rendered.contains(&format!(
1554+
"{}: hooks: field PreToolUse must contain only strings",
1555+
project_settings.display()
1556+
)));
1557+
assert!(!rendered.contains("merged settings.hooks"));
1558+
1559+
fs::remove_dir_all(root).expect("cleanup temp dir");
1560+
}
1561+
15161562
#[test]
15171563
fn permission_mode_aliases_resolve_to_expected_modes() {
15181564
// given / when / then

0 commit comments

Comments
 (0)