diff options
| author | akbash <akbash@herdr.dev> | 2026-09-13 16:04:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-13 15:04:22 +0200 |
| commit | a5e1c94157ee2d0d25fb92563330d262f2b5d80b (patch) | |
| tree | 70aecfd656d44d197dfa8c0009d18c0979e7d73a /src | |
| parent | 9631f8ee2105c2f6c88248ba6e1a0f9ff21d3708 (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')
| -rw-r--r-- | src/integration/assets/claude/herdr-agent-state.ps1 | 2 | ||||
| -rw-r--r-- | src/integration/assets/claude/herdr-agent-state.sh | 2 | ||||
| -rw-r--r-- | src/integration/claude_settings.rs | 65 | ||||
| -rw-r--r-- | src/integration/mod.rs | 2 | ||||
| -rw-r--r-- | src/integration/tests.rs | 24 |
5 files changed, 81 insertions, 14 deletions
diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/src/integration/assets/claude/herdr-agent-state.ps1 index fc596865..d62c5b9f 100644 --- a/src/integration/assets/claude/herdr-agent-state.ps1 +++ b/src/integration/assets/claude/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=9 +# HERDR_INTEGRATION_VERSION=10 param([string]$Action = "") diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index 5d11a80c..e0b88ce7 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=9 +# HERDR_INTEGRATION_VERSION=10 set -eu 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(); diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 55dcb2be..a40ad970 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -38,7 +38,7 @@ const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/claude/herdr-agent-state.sh") }; -const CLAUDE_INTEGRATION_VERSION: u32 = 9; +const CLAUDE_INTEGRATION_VERSION: u32 = 10; const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { diff --git a/src/integration/tests.rs b/src/integration/tests.rs index e8aad72b..4c2178cc 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -957,7 +957,10 @@ fn install_claude_writes_hook_and_updates_settings() { ); assert_eq!(hook_content, CLAUDE_HOOK_ASSET); assert!(settings["permissions"]["allow"].is_array()); - assert_eq!(settings["hooks"]["SessionStart"][0]["matcher"], "*"); + assert_eq!( + settings["hooks"]["SessionStart"][0]["matcher"], + "^(startup|resume|clear|compact|fork)$" + ); assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] .as_str() .unwrap() @@ -1105,7 +1108,7 @@ fn install_claude_removes_deprecated_completion_hooks_and_preserves_user_hooks() } #[test] -fn claude_v1_integration_status_is_outdated() { +fn claude_v9_integration_status_is_outdated_until_reinstalled() { let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); @@ -1114,7 +1117,7 @@ fn claude_v1_integration_status_is_outdated() { let hook_path = claude_hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME); fs::write( &hook_path, - "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=1\n", + "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=9\n", ) .unwrap(); std::env::set_var("HOME", &home); @@ -1126,10 +1129,19 @@ fn claude_v1_integration_status_is_outdated() { .unwrap(); assert_eq!(claude.path, hook_path); - assert_eq!(claude.installed_version, Some(1)); - assert_eq!(claude.expected_version, 9); + assert_eq!(claude.installed_version, Some(9)); + assert_eq!(claude.expected_version, 10); assert_eq!(claude.state, IntegrationStatusKind::Outdated); + install_claude().unwrap(); + let status = integration_status_at( + crate::api::schema::IntegrationTarget::Claude, + hook_path, + CLAUDE_INTEGRATION_VERSION, + ); + assert_eq!(status.installed_version, Some(10)); + assert_eq!(status.state, IntegrationStatusKind::Current); + std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } @@ -1157,7 +1169,7 @@ fn claude_v2_integration_status_is_outdated() { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(2)); - assert_eq!(claude.expected_version, 9); + assert_eq!(claude.expected_version, 10); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); |
