use std::collections::HashMap;use std::fs;use std::path::Path;use codex_config::AbsolutePathBuf;use codex_config::ConfigLayerEntry;use codex_config::ConfigLayerSource;use codex_config::ConfigLayerStack;use codex_config::ConfigRequirements;use codex_config::ConfigRequirementsToml;use codex_config::Constrained;use codex_config::ConstrainedWithSource;use codex_config::HookEventsToml;use codex_config::HookHandlerConfig;use codex_config::ManagedHooksRequirementsToml;use codex_config::MatcherGroup;use codex_config::RequirementSource;use codex_config::Sourced;use codex_config::TomlValue;use codex_plugin::PluginHookSource;use codex_plugin::PluginId;use codex_protocol::ThreadId;use codex_protocol::protocol::HookOutputEntry;use codex_protocol::protocol::HookOutputEntryKind;use codex_protocol::protocol::HookRunStatus;use codex_protocol::protocol::HookSource;use codex_protocol::protocol::HookTrustStatus;use pretty_assertions::assert_eq;use tempfile::tempdir;use super::ClaudeHooksEngine;use super::CommandShell;use crate::events::pre_tool_use::PreToolUseRequest;fn cwd() -> AbsolutePathBuf { AbsolutePathBuf::current_dir().expect("current dir")}fn managed_hooks_for_current_platform( managed_dir: impl AsRef<Path>, hooks: HookEventsToml,) -> ManagedHooksRequirementsToml { let managed_dir = managed_dir.as_ref().to_path_buf(); ManagedHooksRequirementsToml { managed_dir: if cfg!(windows) { None } else { Some(managed_dir.clone()) }, windows_managed_dir: if cfg!(windows) { Some(managed_dir) } else { None }, hooks, }}fn pre_tool_use_hook_events(command: impl Into<String>) -> HookEventsToml { HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: command.into(), command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), }], }], ..Default::default() }}fn config_toml_with_pre_tool_use(command: &str) -> TomlValue { let mut config_toml = TomlValue::Table(Default::default()); let TomlValue::Table(config_table) = &mut config_toml else { unreachable!("config TOML root should be a table"); }; let mut hooks_table = TomlValue::Table(Default::default()); let TomlValue::Table(hooks_entries) = &mut hooks_table else { unreachable!("hooks entry should be a table"); }; let mut pre_tool_use_group = TomlValue::Table(Default::default()); let TomlValue::Table(pre_tool_use_group_entries) = &mut pre_tool_use_group else { unreachable!("PreToolUse group should be a table"); }; pre_tool_use_group_entries.insert( "matcher".to_string(), TomlValue::String("^Bash$".to_string()), ); let mut handler = TomlValue::Table(Default::default()); let TomlValue::Table(handler_entries) = &mut handler else { unreachable!("PreToolUse handler should be a table"); }; handler_entries.insert("type".to_string(), TomlValue::String("command".to_string())); handler_entries.insert( "command".to_string(), TomlValue::String(command.to_string()), ); handler_entries.insert("timeout".to_string(), TomlValue::Integer(10)); handler_entries.insert( "statusMessage".to_string(), TomlValue::String("checking".to_string()), ); pre_tool_use_group_entries.insert("hooks".to_string(), TomlValue::Array(vec![handler])); hooks_entries.insert( "PreToolUse".to_string(), TomlValue::Array(vec![pre_tool_use_group]), ); config_table.insert("hooks".to_string(), hooks_table); config_toml}fn requirements_with_managed_hooks_only( allow_managed_hooks_only: bool, managed_hooks: Option<ManagedHooksRequirementsToml>,) -> (ConfigRequirements, ConfigRequirementsToml) { ( ConfigRequirements { allow_managed_hooks_only: Some(Sourced::new( allow_managed_hooks_only, RequirementSource::LegacyManagedConfigTomlFromMdm, )), managed_hooks: managed_hooks.clone().map(|hooks| { ConstrainedWithSource::new( Constrained::allow_any(hooks), Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ) }), ..ConfigRequirements::default() }, ConfigRequirementsToml { allow_managed_hooks_only: Some(allow_managed_hooks_only), hooks: managed_hooks, ..ConfigRequirementsToml::default() }, )}#[tokio::test]async fn requirements_managed_hooks_execute_from_managed_dir() { let temp = tempdir().expect("create temp dir"); let managed_dir = AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); let script_path = managed_dir.join("pre_tool_use.py"); let log_path = managed_dir.join("pre_tool_use_log.jsonl"); fs::write( script_path.as_path(), format!( r#"import jsonfrom pathlib import Pathimport syspayload = json.load(sys.stdin)with Path(r"{log_path}").open("a", encoding="utf-8") as handle: handle.write(json.dumps(payload) + "\n")"#, log_path = log_path.display(), ), ) .expect("write managed hook script"); let managed_hooks = managed_hooks_for_current_platform( managed_dir.clone(), HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: format!("python3 {}", script_path.display()), command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), }], }], ..Default::default() }, ); let config_layer_stack = ConfigLayerStack::new( Vec::new(), ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, ConfigRequirementsToml { hooks: Some(managed_hooks), ..ConfigRequirementsToml::default() }, ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); assert_eq!( engine.handlers[0].source, HookSource::LegacyManagedConfigMdm ); let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, bypass_hook_trust: false, config_layer_stack: Some(config_layer_stack.clone()), plugin_hook_sources: Vec::new(), plugin_hook_load_warnings: Vec::new(), shell_program: None, shell_args: Vec::new(), }); assert!(listed.hooks[0].is_managed); let cwd = cwd(); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), subagent: None, cwd: cwd.clone(), transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), tool_name: "Bash".to_string(), matcher_aliases: Vec::new(), tool_use_id: "tool-1".to_string(), tool_input: serde_json::json!({ "command": "echo hello" }), }); assert_eq!(preview.len(), 1); assert_eq!(preview[0].source_path, managed_dir); let outcome = engine .run_pre_tool_use(PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), subagent: None, cwd, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), tool_name: "Bash".to_string(), matcher_aliases: Vec::new(), tool_use_id: "tool-1".to_string(), tool_input: serde_json::json!({ "command": "echo hello" }), }) .await; assert!(!outcome.should_block); let log_contents = fs::read_to_string(log_path).expect("read managed hook log"); assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));}#[tokio::test]async fn requirements_managed_hooks_execute_windows_command_override() { let temp = tempdir().expect("create temp dir"); let managed_dir = AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); let managed_hooks = managed_hooks_for_current_platform( managed_dir, HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "exit 17".to_string(), command_windows: Some("exit /B 19".to_string()), timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), }], }], ..Default::default() }, ); let config_layer_stack = ConfigLayerStack::new( Vec::new(), ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, ConfigRequirementsToml { hooks: Some(managed_hooks), ..ConfigRequirementsToml::default() }, ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); let outcome = engine .run_pre_tool_use(PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), tool_name: "Bash".to_string(), matcher_aliases: Vec::new(), tool_use_id: "tool-1".to_string(), tool_input: serde_json::json!({ "command": "echo hello" }), }) .await; assert!(!outcome.should_block); let expected_exit_code = if cfg!(windows) { 19 } else { 17 }; assert_eq!(outcome.hook_events.len(), 1); assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Failed); assert_eq!( outcome.hook_events[0].run.entries, vec![HookOutputEntry { kind: HookOutputEntryKind::Error, text: format!("hook exited with code {expected_exit_code}"), }] );}#[test]fn unknown_requirement_source_hooks_stay_managed() { let temp = tempdir().expect("create temp dir"); let managed_dir = AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); let managed_hooks = managed_hooks_for_current_platform( managed_dir, HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/managed.py".to_string(), command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), }], }], ..Default::default() }, ); let config_layer_stack = ConfigLayerStack::new( Vec::new(), ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), Some(RequirementSource::Unknown), )), ..ConfigRequirements::default() }, ConfigRequirementsToml { hooks: Some(managed_hooks), ..ConfigRequirementsToml::default() }, ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert_eq!(engine.handlers.len(), 1); assert_eq!(engine.handlers[0].source, HookSource::Unknown); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), Vec::new(), /*bypass_hook_trust*/ false, ); assert_eq!(discovered.hook_entries.len(), 1); assert_eq!(discovered.hook_entries[0].source, HookSource::Unknown); assert_eq!(discovered.hook_entries[0].enabled, true); assert_eq!(discovered.hook_entries[0].is_managed, true); assert_eq!( discovered.hook_entries[0].trust_status, HookTrustStatus::Managed );}#[test]fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { let temp = tempdir().expect("create temp dir"); let managed_dir = AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); let managed_hooks = managed_hooks_for_current_platform( managed_dir.clone(), HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/managed.py".to_string(), command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), }], }], ..Default::default() }, ); let config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path"); let managed_disabled_key = format!("{}:pre_tool_use:0:0", managed_dir.display()); let user_disabled_key = format!("{}:pre_tool_use:0:0", config_path.display()); let user_config = config_with_pre_tool_use_hook_and_states( "python3 /tmp/user.py", [&managed_disabled_key, &user_disabled_key], ); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: config_path, profile: None, }, user_config, )], ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, ConfigRequirementsToml { hooks: Some(managed_hooks), ..ConfigRequirementsToml::default() }, ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert_eq!(engine.handlers.len(), 1); assert_eq!( engine.handlers[0].source, HookSource::LegacyManagedConfigMdm ); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), Vec::new(), /*bypass_hook_trust*/ false, ); assert_eq!(discovered.hook_entries.len(), 2); assert_eq!(discovered.hook_entries[0].key, managed_disabled_key); assert_eq!(discovered.hook_entries[0].enabled, true); assert!(discovered.hook_entries[0].is_managed); assert_eq!( discovered.hook_entries[0].trust_status, HookTrustStatus::Managed ); assert_eq!(discovered.hook_entries[1].key, user_disabled_key); assert_eq!(discovered.hook_entries[1].enabled, false); assert!(!discovered.hook_entries[1].is_managed);}#[test]fn user_disablement_does_not_filter_managed_layer_hooks() { let temp = tempdir().expect("create temp dir"); let managed_config_path = AbsolutePathBuf::try_from(temp.path().join("managed_config.toml")).expect("absolute path"); let user_config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path"); let managed_key = format!("{}:pre_tool_use:0:0", managed_config_path.display()); let config_layer_stack = ConfigLayerStack::new( vec![ ConfigLayerEntry::new( ConfigLayerSource::User { file: user_config_path, profile: None, }, config_with_hook_state(&managed_key, /*enabled*/ false), ), ConfigLayerEntry::new( ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_config_path, }, config_with_pre_tool_use_hook("python3 /tmp/managed-layer.py"), ), ], ConfigRequirements::default(), ConfigRequirementsToml::default(), ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert_eq!(engine.handlers.len(), 1); assert_eq!( engine.handlers[0].source, HookSource::LegacyManagedConfigFile ); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), Vec::new(), /*bypass_hook_trust*/ false, ); assert_eq!(discovered.hook_entries.len(), 1); assert_eq!(discovered.hook_entries[0].key, managed_key); assert_eq!(discovered.hook_entries[0].enabled, true); assert!(discovered.hook_entries[0].is_managed); assert_eq!( discovered.hook_entries[0].trust_status, HookTrustStatus::Managed );}fn config_with_hook_state(key: &str, enabled: bool) -> TomlValue { serde_json::from_value(serde_json::json!({ "hooks": { "state": { (key): { "enabled": enabled, }, }, }, })) .expect("config TOML should deserialize")}fn config_with_pre_tool_use_hook_and_states<const N: usize>( command: &str, disabled_keys: [&str; N],) -> TomlValue { let state = disabled_keys .into_iter() .map(|key| (key.to_string(), serde_json::json!({ "enabled": false }))) .collect::<serde_json::Map<_, _>>(); serde_json::from_value(serde_json::json!({ "hooks": { "state": state, "PreToolUse": [{ "hooks": [{ "type": "command", "command": command, }], }], }, })) .expect("config TOML should deserialize")}fn config_with_pre_tool_use_hook(command: &str) -> TomlValue { serde_json::from_value(serde_json::json!({ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": command, }], }], }, })) .expect("config TOML should deserialize")}fn trusted_plugin_hook_stack( config_path: AbsolutePathBuf, plugin_hook_sources: &[PluginHookSource],) -> ConfigLayerStack { let discovered = super::discovery::discover_handlers( /*config_layer_stack*/ None, plugin_hook_sources.to_vec(), Vec::new(), /*bypass_hook_trust*/ false, ); let state = discovered .hook_entries .into_iter() .map(|entry| { ( entry.key, serde_json::json!({ "trusted_hash": entry.current_hash, }), ) }) .collect::<serde_json::Map<_, _>>(); let config = serde_json::from_value(serde_json::json!({ "hooks": { "state": state, }, })) .expect("config TOML should deserialize"); ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: config_path, profile: None, }, config, )], ConfigRequirements::default(), ConfigRequirementsToml::default(), ) .expect("config layer stack")}#[test]fn requirements_managed_hooks_load_when_managed_dir_is_missing() { let temp = tempdir().expect("create temp dir"); let missing_dir = temp.path().join("missing-managed-hooks"); let managed_hooks = managed_hooks_for_current_platform( missing_dir.clone(), HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "echo hi".to_string(), command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), }], }], ..Default::default() }, ); let config_layer_stack = ConfigLayerStack::new( Vec::new(), ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, ConfigRequirementsToml { hooks: Some(managed_hooks), ..ConfigRequirementsToml::default() }, ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.warnings().is_empty()); let cwd = cwd(); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), subagent: None, cwd, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), tool_name: "Bash".to_string(), matcher_aliases: Vec::new(), tool_use_id: "tool-1".to_string(), tool_input: serde_json::json!({ "command": "echo hello" }), }); assert_eq!(preview.len(), 1); assert_eq!(engine.handlers[0].command, "echo hi"); assert_eq!( engine.handlers[0].source_path, AbsolutePathBuf::try_from(missing_dir).expect("absolute missing dir") );}#[test]fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() { let temp = tempdir().expect("create temp dir"); let config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); let (requirements, requirements_toml) = requirements_with_managed_hooks_only( /*allow_managed_hooks_only*/ false, /*managed_hooks*/ None, ); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: config_path, profile: None, }, config_toml_with_pre_tool_use("python3 /tmp/user-hook.py"), )], requirements, requirements_toml, ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.warnings().is_empty()); assert!(engine.handlers.is_empty()); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), Vec::new(), /*bypass_hook_trust*/ false, ); assert_eq!(discovered.hook_entries.len(), 1); assert!(!discovered.hook_entries[0].is_managed); assert_eq!( discovered.hook_entries[0].command.as_deref(), Some("python3 /tmp/user-hook.py") );}#[test]fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() { let temp = tempdir().expect("create temp dir"); let config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); let mut config_toml = config_toml_with_pre_tool_use("python3 /tmp/user-hook.py"); let TomlValue::Table(config_table) = &mut config_toml else { unreachable!("config TOML root should be a table"); }; config_table.insert( "allow_managed_hooks_only".to_string(), TomlValue::Boolean(true), ); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: config_path, profile: None, }, config_toml, )], ConfigRequirements::default(), ConfigRequirementsToml::default(), ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.warnings().is_empty()); assert!(engine.handlers.is_empty()); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), Vec::new(), /*bypass_hook_trust*/ false, ); assert_eq!(discovered.hook_entries.len(), 1); assert!(!discovered.hook_entries[0].is_managed); assert_eq!( discovered.hook_entries[0].command.as_deref(), Some("python3 /tmp/user-hook.py") );}#[test]fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() { let temp = tempdir().expect("create temp dir"); let config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); let hooks_json_path = AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path"); fs::write( hooks_json_path.as_path(), r#"{ "hooks": { "PreToolUse": [ { "matcher": "^Bash$", "hooks": [ { "type": "command", "command": "python3 /tmp/json-hook.py" } ] } ] } }"#, ) .expect("write hooks.json"); let (requirements, requirements_toml) = requirements_with_managed_hooks_only( /*allow_managed_hooks_only*/ true, /*managed_hooks*/ None, ); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: config_path, profile: None, }, config_toml_with_pre_tool_use("python3 /tmp/toml-hook.py"), )], requirements, requirements_toml, ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.handlers.is_empty()); assert!(engine.warnings().is_empty());}#[test]fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() { let temp = tempdir().expect("create temp dir"); let plugin_root = AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root"); let plugin_data_root = AbsolutePathBuf::try_from(temp.path().join("plugin-data")).expect("plugin data root"); let source_path = plugin_root.join("hooks/hooks.json"); let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); let plugin_hook_sources = vec![PluginHookSource { plugin_id, plugin_root, plugin_data_root, source_path, source_relative_path: "hooks/hooks.json".to_string(), hooks: pre_tool_use_hook_events("python3 /tmp/plugin-hook.py"), }]; let (requirements, requirements_toml) = requirements_with_managed_hooks_only( /*allow_managed_hooks_only*/ true, /*managed_hooks*/ None, ); let config_layer_stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), plugin_hook_sources, Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.handlers.is_empty()); assert!(engine.warnings().is_empty());}#[test]fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() { let temp = tempdir().expect("create temp dir"); let managed_dir = AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); let system_config_path = AbsolutePathBuf::try_from(temp.path().join("system").join("config.toml")) .expect("absolute system config path"); let system_parent = system_config_path .as_path() .parent() .expect("system config parent"); fs::create_dir_all(system_parent).expect("create system config dir"); let legacy_config_path = AbsolutePathBuf::try_from(temp.path().join("managed_config.toml")) .expect("absolute legacy config path"); let managed_hooks = managed_hooks_for_current_platform( managed_dir, pre_tool_use_hook_events("python3 /tmp/requirements-hook.py"), ); let (requirements, requirements_toml) = requirements_with_managed_hooks_only( /*allow_managed_hooks_only*/ true, Some(managed_hooks), ); let config_layer_stack = ConfigLayerStack::new( vec![ ConfigLayerEntry::new( ConfigLayerSource::Mdm { domain: "com.openai.codex".to_string(), key: "config".to_string(), }, config_toml_with_pre_tool_use("python3 /tmp/mdm-hook.py"), ), ConfigLayerEntry::new( ConfigLayerSource::System { file: system_config_path, }, config_toml_with_pre_tool_use("python3 /tmp/system-hook.py"), ), ConfigLayerEntry::new( ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: legacy_config_path, }, config_toml_with_pre_tool_use("python3 /tmp/legacy-file-hook.py"), ), ConfigLayerEntry::new( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, config_toml_with_pre_tool_use("python3 /tmp/legacy-mdm-hook.py"), ), ], requirements, requirements_toml, ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.warnings().is_empty()); assert_eq!( engine .handlers .iter() .map(|handler| handler.command.as_str()) .collect::<Vec<_>>(), vec![ "python3 /tmp/requirements-hook.py", "python3 /tmp/mdm-hook.py", "python3 /tmp/system-hook.py", "python3 /tmp/legacy-file-hook.py", "python3 /tmp/legacy-mdm-hook.py", ] ); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), Vec::new(), /*bypass_hook_trust*/ false, ); assert!(discovered.hook_entries.iter().all(|entry| entry.is_managed));}#[test]fn discovers_hooks_from_json_and_toml_in_the_same_layer() { let temp = tempdir().expect("create temp dir"); let config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); let hooks_json_path = AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path"); fs::write( hooks_json_path.as_path(), r#"{ "hooks": { "PreToolUse": [ { "matcher": "^Bash$", "hooks": [ { "type": "command", "command": "python3 /tmp/json-hook.py" } ] } ] } }"#, ) .expect("write hooks.json"); let mut config_toml = TomlValue::Table(Default::default()); let TomlValue::Table(config_table) = &mut config_toml else { unreachable!("config TOML root should be a table"); }; let mut hooks_table = TomlValue::Table(Default::default()); let TomlValue::Table(hooks_entries) = &mut hooks_table else { unreachable!("hooks entry should be a table"); }; let mut pre_tool_use_group = TomlValue::Table(Default::default()); let TomlValue::Table(pre_tool_use_group_entries) = &mut pre_tool_use_group else { unreachable!("PreToolUse group should be a table"); }; pre_tool_use_group_entries.insert( "matcher".to_string(), TomlValue::String("^Bash$".to_string()), ); pre_tool_use_group_entries.insert( "hooks".to_string(), TomlValue::Array(vec![TomlValue::Table(Default::default())]), ); let Some(TomlValue::Array(hooks_array)) = pre_tool_use_group_entries.get_mut("hooks") else { unreachable!("PreToolUse hooks should be an array"); }; let Some(TomlValue::Table(handler_entries)) = hooks_array.first_mut() else { unreachable!("PreToolUse handler should be a table"); }; handler_entries.insert("type".to_string(), TomlValue::String("command".to_string())); handler_entries.insert( "command".to_string(), TomlValue::String("python3 /tmp/toml-hook.py".to_string()), ); hooks_entries.insert( "PreToolUse".to_string(), TomlValue::Array(vec![pre_tool_use_group]), ); config_table.insert("hooks".to_string(), hooks_table); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::System { file: config_path.clone(), }, config_toml, )], ConfigRequirements::default(), ConfigRequirementsToml::default(), ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.warnings().iter().any(|warning| { warning.contains("loading hooks from both") && warning.contains(&hooks_json_path.display().to_string()) && warning.contains(&config_path.display().to_string()) })); let cwd = cwd(); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), subagent: None, cwd, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), tool_name: "Bash".to_string(), matcher_aliases: Vec::new(), tool_use_id: "tool-1".to_string(), tool_input: serde_json::json!({ "command": "echo hello" }), }); assert_eq!(preview.len(), 2); assert_eq!( engine .handlers .iter() .map(|handler| handler.source) .collect::<Vec<_>>(), vec![HookSource::System, HookSource::System] ); assert_eq!(preview[0].source_path, hooks_json_path); assert_eq!(preview[1].source_path, config_path);}#[test]fn profile_user_layers_load_shared_hooks_json_once() { let temp = tempdir().expect("create temp dir"); let config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); let profile_path = AbsolutePathBuf::try_from(temp.path().join("work.config.toml")) .expect("absolute profile path"); let hooks_json_path = AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path"); fs::write( hooks_json_path.as_path(), r#"{ "hooks": { "PreToolUse": [ { "matcher": "^Bash$", "hooks": [ { "type": "command", "command": "python3 /tmp/json-hook.py" } ] } ] } }"#, ) .expect("write hooks.json"); let config_layer_stack = ConfigLayerStack::new( vec![ ConfigLayerEntry::new( ConfigLayerSource::User { file: config_path, profile: None, }, TomlValue::Table(Default::default()), ), ConfigLayerEntry::new( ConfigLayerSource::User { file: profile_path, profile: Some("work".to_string()), }, TomlValue::Table(Default::default()), ), ], ConfigRequirements::default(), ConfigRequirementsToml::default(), ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ true, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), tool_name: "Bash".to_string(), matcher_aliases: Vec::new(), tool_use_id: "tool-1".to_string(), tool_input: serde_json::json!({ "command": "echo hello" }), }); assert_eq!(preview.len(), 1); assert_eq!(preview[0].source_path, hooks_json_path); let listed = crate::list_hooks(crate::HooksConfig { feature_enabled: true, bypass_hook_trust: true, config_layer_stack: Some(config_layer_stack), ..Default::default() }); assert!(listed.warnings.is_empty()); assert_eq!(listed.hooks.len(), 1); assert_eq!(listed.hooks[0].source_path, hooks_json_path);}#[test]fn malformed_hooks_json_is_reported_as_startup_warning() { let temp = tempdir().expect("create temp dir"); let config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); let hooks_json_path = AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path"); fs::write( hooks_json_path.as_path(), r#"{ "SessionStart": [ { "hooks": [ { "type": "command", "command": "python3 /tmp/session-start.py" } ] } ] }"#, ) .expect("write hooks.json"); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::System { file: config_path }, TomlValue::Table(Default::default()), )], ConfigRequirements::default(), ConfigRequirementsToml::default(), ) .expect("config layer stack"); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert!(engine.handlers.is_empty()); assert_eq!(engine.warnings().len(), 1); assert!(engine.warnings()[0].contains("failed to parse hooks config")); assert!( engine.warnings()[0].contains(&hooks_json_path.display().to_string()), "warning should identify the malformed file: {}", engine.warnings()[0] ); assert!(engine.warnings()[0].contains("unknown field `SessionStart`"));}#[tokio::test]async fn plugin_hook_sources_run_with_plugin_env_and_plugin_source() { let temp = tempdir().expect("create temp dir"); let plugin_root = AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root"); let plugin_data_root = AbsolutePathBuf::try_from(temp.path().join("plugin-data")).expect("plugin data root"); fs::create_dir_all(plugin_root.join("hooks")).expect("create hooks dir"); let source_path = plugin_root.join("hooks/hooks.json"); let script_path = plugin_root.join("hooks/write_env.py"); fs::write( script_path.as_path(), r#"import jsonimport osprint(json.dumps({ "systemMessage": json.dumps({ "plugin": os.environ.get("PLUGIN_ROOT"), "claude": os.environ.get("CLAUDE_PLUGIN_ROOT"), })}))"#, ) .expect("write hook script"); let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); let plugin_hook_sources = vec![PluginHookSource { plugin_id, plugin_root: plugin_root.clone(), plugin_data_root: plugin_data_root.clone(), source_path: source_path.clone(), source_relative_path: "hooks/hooks.json".to_string(), hooks: HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("Bash".to_string()), hooks: vec![HookHandlerConfig::Command { command: format!("python3 {}", script_path.display()), command_windows: None, timeout_sec: Some(10), r#async: false, status_message: None, }], }], ..Default::default() }, }]; let config_layer_stack = trusted_plugin_hook_stack( AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"), &plugin_hook_sources, ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), plugin_hook_sources.clone(), Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), tool_name: "Bash".to_string(), matcher_aliases: Vec::new(), tool_use_id: "tool-1".to_string(), tool_input: serde_json::json!({ "command": "echo hello" }), }); assert_eq!(preview.len(), 1); assert_eq!(preview[0].source, HookSource::Plugin); assert_eq!(preview[0].source_path, source_path); let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, bypass_hook_trust: false, config_layer_stack: None, plugin_hook_sources, plugin_hook_load_warnings: Vec::new(), shell_program: None, shell_args: Vec::new(), }); assert_eq!( listed.hooks[0].plugin_id.as_deref(), Some("demo-plugin@test-marketplace") ); let outcome = engine .run_pre_tool_use(PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), tool_name: "Bash".to_string(), matcher_aliases: Vec::new(), tool_use_id: "tool-1".to_string(), tool_input: serde_json::json!({ "command": "echo hello" }), }) .await; assert_eq!(outcome.hook_events.len(), 1); assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin); assert_eq!( outcome.hook_events[0].run.status, HookRunStatus::Completed, "hook entries: {:#?}", outcome.hook_events[0].run.entries ); assert_eq!(outcome.hook_events[0].run.entries.len(), 1); assert_eq!( outcome.hook_events[0].run.entries[0].kind, HookOutputEntryKind::Warning ); let logged: serde_json::Value = serde_json::from_str(&outcome.hook_events[0].run.entries[0].text) .expect("parse env payload"); assert_eq!( logged, serde_json::json!({ "plugin": plugin_root.display().to_string(), "claude": plugin_root.display().to_string(), }) );}#[test]fn plugin_hook_sources_expand_plugin_placeholders() { let temp = tempdir().expect("create temp dir"); let plugin_root = AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root"); let plugin_data_root = AbsolutePathBuf::try_from(temp.path().join("plugin-data")).expect("plugin data root"); let source_path = plugin_root.join("hooks/hooks.json"); let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); let plugin_hook_sources = vec![PluginHookSource { plugin_id, plugin_root: plugin_root.clone(), plugin_data_root: plugin_data_root.clone(), source_path, source_relative_path: "hooks/hooks.json".to_string(), hooks: HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("Bash".to_string()), hooks: vec![HookHandlerConfig::Command { command: "run ${PLUGIN_ROOT} ${CLAUDE_PLUGIN_ROOT} ${PLUGIN_DATA} ${CLAUDE_PLUGIN_DATA}" .to_string(), command_windows: None, timeout_sec: Some(5), r#async: false, status_message: None, }], }], ..Default::default() }, }]; let config_layer_stack = trusted_plugin_hook_stack( AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"), &plugin_hook_sources, ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, Some(&config_layer_stack), plugin_hook_sources, Vec::new(), CommandShell { program: String::new(), args: Vec::new(), }, ); assert_eq!( engine.handlers[0].command, format!( "run {} {} {} {}", plugin_root.display(), plugin_root.display(), plugin_data_root.display(), plugin_data_root.display() ) ); assert_eq!( engine.handlers[0].env, HashMap::from([ ("PLUGIN_ROOT".to_string(), plugin_root.display().to_string()), ( "CLAUDE_PLUGIN_ROOT".to_string(), plugin_root.display().to_string() ), ( "PLUGIN_DATA".to_string(), plugin_data_root.display().to_string() ), ( "CLAUDE_PLUGIN_DATA".to_string(), plugin_data_root.display().to_string() ), ]) );}#[test]fn plugin_hook_load_warnings_are_startup_warnings() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*bypass_hook_trust*/ false, /*config_layer_stack*/ None, Vec::new(), vec!["failed plugin hook".to_string()], CommandShell { program: String::new(), args: Vec::new(), }, ); assert_eq!(engine.warnings(), &["failed plugin hook".to_string()]);}