aboutsummaryrefslogtreecommitdiff
path: root/src/integration/claude_settings.rs
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-13 16:04:22 +0300
committerGitHub <noreply@github.com>2026-09-13 15:04:22 +0200
commita5e1c94157ee2d0d25fb92563330d262f2b5d80b (patch)
tree70aecfd656d44d197dfa8c0009d18c0979e7d73a /src/integration/claude_settings.rs
parent9631f8ee2105c2f6c88248ba6e1a0f9ff21d3708 (diff)
fix: restrict claude session hooks to native sources (#4046)
refs #4018 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/integration/claude_settings.rs')
-rw-r--r--src/integration/claude_settings.rs65
1 files changed, 60 insertions, 5 deletions
diff --git a/src/integration/claude_settings.rs b/src/integration/claude_settings.rs
index 5bc61d75..5fa2df88 100644
--- a/src/integration/claude_settings.rs
+++ b/src/integration/claude_settings.rs
@@ -14,6 +14,10 @@ use super::config_edit::{
is_matching_command_hook,
};
+// Claude's documented SessionStart sources. Grok imports Claude hooks but uses
+// `new`/`load`; filter before it starts an unnecessary hook process.
+const SESSION_START_MATCHER: &str = "^(startup|resume|clear|compact|fork)$";
+
struct HookRemoval {
event: &'static str,
actions: &'static [&'static str],
@@ -74,7 +78,7 @@ pub(crate) fn install(content: &str, settings_path: &Path, hook_path: &Path) ->
"SessionStart",
hook_command(hook_path, Some("session")),
10,
- Some("*"),
+ Some(SESSION_START_MATCHER),
)?;
if desired == original {
@@ -345,7 +349,7 @@ fn removal_commands(policy: &HookRemoval, hook_path: &Path) -> Vec<String> {
fn canonical_hook_value(hook_path: &Path) -> Value {
serde_json_value!({
- "matcher": "*",
+ "matcher": SESSION_START_MATCHER,
"hooks": [{
"type": "command",
"command": hook_command(hook_path, Some("session")),
@@ -357,7 +361,7 @@ fn canonical_hook_value(hook_path: &Path) -> Value {
fn canonical_hook_input(hook_path: &Path) -> CstInputValue {
let command = hook_command(hook_path, Some("session"));
json!({
- matcher: "*",
+ matcher: SESSION_START_MATCHER,
hooks: [{
"type": "command",
command: command,
@@ -515,7 +519,7 @@ fn append_to_container(
fn canonical_hook_json(hook_path: &Path) -> io::Result<String> {
let command = serde_json::to_string(&hook_command(hook_path, Some("session")))?;
Ok(format!(
- "{{\"matcher\":\"*\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}}]}}"
+ "{{\"matcher\":\"{SESSION_START_MATCHER}\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}}]}}"
))
}
@@ -676,11 +680,29 @@ mod tests {
}
#[test]
+ fn install_scopes_claude_session_start_sources() {
+ let (settings_path, hook_path) = paths();
+ let installed = install("{}", settings_path, hook_path).unwrap();
+ let settings: Value = serde_json::from_str(&installed).unwrap();
+ let matcher = settings["hooks"]["SessionStart"][0]["matcher"]
+ .as_str()
+ .unwrap();
+ assert_eq!(matcher, "^(startup|resume|clear|compact|fork)$");
+ let pattern = regex::Regex::new(matcher).unwrap();
+ for source in ["startup", "resume", "clear", "compact", "fork"] {
+ assert!(pattern.is_match(source), "Claude source: {source}");
+ }
+ for source in ["new", "load", "", "future-source", "startup-extra"] {
+ assert!(!pattern.is_match(source), "non-Claude source: {source}");
+ }
+ }
+
+ #[test]
fn install_is_a_byte_exact_noop_for_a_canonical_hook() {
let (settings_path, hook_path) = paths();
let command = serde_json::to_string(&hook_command(hook_path, Some("session"))).unwrap();
let input = format!(
- "{{\"hooks\":{{\"SessionStart\":[{{\"hooks\":[{{\"timeout\":10,\"command\":{command},\"type\":\"command\"}}],\"matcher\":\"*\"}}]}},\"escaped\":\"\\u0061\"}} \r\n\r\n"
+ "{{\"hooks\":{{\"SessionStart\":[{{\"hooks\":[{{\"timeout\":10,\"command\":{command},\"type\":\"command\"}}],\"matcher\":\"{SESSION_START_MATCHER}\"}}]}},\"escaped\":\"\\u0061\"}} \r\n\r\n"
);
let updated = install(&input, settings_path, hook_path).unwrap();
@@ -689,6 +711,39 @@ mod tests {
}
#[test]
+ fn install_migrates_wildcard_session_start_and_preserves_user_hook() {
+ let (settings_path, hook_path) = paths();
+ let command = serde_json::to_string(&hook_command(hook_path, Some("session"))).unwrap();
+ let user_hook = r#"{ "type" : "command", "command" : "echo keep", "timeout" : 3 }"#;
+ let input = format!(
+ "{{\n \"hooks\": {{\n \"SessionStart\": [{{\"matcher\":\"*\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}},{user_hook}]}}]\n }}\n}}\n\n"
+ );
+ let installed = install(&input, settings_path, hook_path).unwrap();
+ assert!(installed.contains(user_hook));
+ assert!(installed.ends_with("}\n\n"));
+ let settings: Value = serde_json::from_str(&installed).unwrap();
+ let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
+ assert_eq!(groups.len(), 2);
+ assert_eq!(groups[0]["matcher"], "*");
+ assert_eq!(groups[0]["hooks"].as_array().unwrap().len(), 1);
+ assert_eq!(groups[0]["hooks"][0]["command"], "echo keep");
+ assert_eq!(groups[1], canonical_hook_value(hook_path));
+ assert_eq!(
+ install(&installed, settings_path, hook_path).unwrap(),
+ installed
+ );
+
+ let removed = uninstall(&installed, settings_path, hook_path).unwrap();
+ assert!(removed.contains(user_hook));
+ assert!(!removed.contains(&command));
+ let settings: Value = serde_json::from_str(&removed).unwrap();
+ assert_eq!(
+ settings["hooks"]["SessionStart"].as_array().unwrap().len(),
+ 1
+ );
+ }
+
+ #[test]
fn install_preserves_canonical_session_start_position_during_migration() {
let (settings_path, hook_path) = paths();
let canonical = canonical_hook_json(hook_path).unwrap();