use std::collections::HashMap; use std::collections::HashSet; use std::fs; use std::path::Path; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::HookEventsToml; use codex_config::HookHandlerConfig; use codex_config::HookStateToml; use codex_config::HooksFile; use codex_config::ManagedHooksRequirementsToml; use codex_config::MatcherGroup; use codex_config::RequirementSource; use codex_config::TomlValue; use codex_config::version_for_toml; use codex_plugin::PluginHookSource; use codex_plugin::is_allowlisted_bundled_cleanup_hook; use codex_protocol::protocol::HookEventName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; use super::ConfiguredHandler; use super::ConfiguredHandlerKind; use super::HookListEntry; use super::HookListEntryHandler; use super::dispatcher::hook_event_name_label; use super::plugin_trust::PluginHookTrust; use crate::config_rules::hook_states_from_stack; use crate::engine::HookMatcher; use crate::events::common::matcher_pattern_for_event; use crate::events::session_end::SESSION_END_DEFAULT_TIMEOUT_SEC; use crate::events::session_end::SESSION_END_MAX_TIMEOUT_SEC; use crate::output_spill::AdditionalContextLimit; use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookTrustStatus; #[derive(Default)] pub(crate) struct DiscoveryResult { pub handlers: Vec, pub hook_entries: Vec, pub warnings: Vec, pub required_load_errors: Vec, } pub(crate) fn ensure_required_hooks_loaded(errors: &[String]) -> anyhow::Result<()> { anyhow::ensure!( errors.is_empty(), "failed to load required managed hooks: {}", errors.join("; ") ); Ok(()) } struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, key_source: String, source: HookSource, is_managed: bool, requirement: HookRequirement<'a>, bypass_hook_trust: bool, hook_states: &'a HashMap, env: HashMap, plugin_id: Option, plugin_content_hash: Option<&'a std::io::Result>, config_layer_stack: Option<&'a ConfigLayerStack>, plugin_root: Option<&'a AbsolutePathBuf>, } enum HookRequirement<'a> { Required(&'a mut Vec), Optional, } impl HookHandlerSource<'_> { fn record_load_failure(&mut self, warning: String, warnings: &mut Vec) { if let HookRequirement::Required(required_load_errors) = &mut self.requirement { required_load_errors.push(warning.clone()); } warnings.push(warning); } } struct NormalizedHandler { config: HookHandlerConfig, kind: ConfiguredHandlerKind, timeout_sec: u64, status_message: Option, additional_context_limit: Option, } #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum DiscoveryMode { Runtime, Listing, } #[derive(Clone, Copy)] struct HookDiscoveryPolicy<'a> { config_layer_stack: Option<&'a ConfigLayerStack>, allow_managed_hooks_only: bool, bypass_hook_trust: bool, mode: DiscoveryMode, } impl HookDiscoveryPolicy<'_> { fn allows(self, source: &HookHandlerSource<'_>) -> bool { !self.allow_managed_hooks_only || source.is_managed } } pub(crate) fn discover_handlers( config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: Vec, plugin_hook_load_warnings: Vec, bypass_hook_trust: bool, mode: DiscoveryMode, ) -> DiscoveryResult { let mut handlers = Vec::new(); let mut hook_entries = Vec::new(); let mut warnings = plugin_hook_load_warnings; let mut required_load_errors = Vec::new(); let mut display_order = 0_i64; let mut visited_json_hook_folders = HashSet::new(); let hook_states = hook_states_from_stack(config_layer_stack); let policy = HookDiscoveryPolicy { config_layer_stack, allow_managed_hooks_only: config_layer_stack.is_some_and(|config_layer_stack| { config_layer_stack .requirements() .allow_managed_hooks_only .as_ref() .is_some_and(|requirement| requirement.value) }), bypass_hook_trust, mode, }; if let Some(config_layer_stack) = config_layer_stack { required_load_errors = append_managed_requirement_handlers( &mut handlers, &mut hook_entries, &mut warnings, &mut display_order, config_layer_stack, &hook_states, policy, ); for layer in config_layer_stack.layers_low_to_high() { let (hook_source, is_managed) = hook_metadata_for_config_layer_source(&layer.name); let policy_path = config_toml_source_path(layer); let policy_source = HookHandlerSource { path: &policy_path, key_source: policy_path.display().to_string(), source: hook_source, is_managed, requirement: HookRequirement::Optional, bypass_hook_trust: false, hook_states: &hook_states, env: HashMap::new(), plugin_id: None, plugin_content_hash: None, config_layer_stack: None, plugin_root: None, }; if !policy.allows(&policy_source) { continue; } let json_hooks = match layer.hooks_config_folder() { Some(config_folder) if visited_json_hook_folders.insert(config_folder.clone()) => { load_hooks_json(Some(config_folder.as_path()), &mut warnings) } _ => None, }; let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) = (&json_hooks, &toml_hooks) && !json_events.is_empty() && !toml_events.is_empty() { warnings.push(format!( "loading hooks from both {} and {}; prefer a single representation for this layer", json_source_path.display(), toml_source_path.display() )); } for (source_path, hook_events) in [json_hooks, toml_hooks].into_iter().flatten() { append_hook_events( &mut handlers, &mut hook_entries, &mut warnings, &mut display_order, HookHandlerSource { path: &source_path, key_source: source_path.display().to_string(), source: hook_source, is_managed, requirement: HookRequirement::Optional, bypass_hook_trust: policy.bypass_hook_trust, hook_states: &hook_states, env: HashMap::new(), plugin_id: None, plugin_content_hash: None, config_layer_stack: None, plugin_root: None, }, hook_events, policy, ); } } } append_plugin_hook_sources( &mut handlers, &mut hook_entries, &mut warnings, &mut display_order, plugin_hook_sources, &hook_states, policy, ); DiscoveryResult { handlers, hook_entries, warnings, required_load_errors, } } fn append_managed_requirement_handlers( handlers: &mut Vec, hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, config_layer_stack: &ConfigLayerStack, hook_states: &HashMap, policy: HookDiscoveryPolicy<'_>, ) -> Vec { let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else { return Vec::new(); }; let mut required_load_errors = Vec::new(); let source_path = managed_hooks_source_path(managed_hooks.get(), managed_hooks.source.as_ref()); append_hook_events( handlers, hook_entries, warnings, display_order, HookHandlerSource { path: &source_path, key_source: source_path.display().to_string(), source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), is_managed: true, requirement: HookRequirement::Required(&mut required_load_errors), bypass_hook_trust: false, hook_states, env: HashMap::new(), plugin_id: None, plugin_content_hash: None, config_layer_stack: None, plugin_root: None, }, managed_hooks.get().hooks.as_ref().clone(), policy, ); required_load_errors } fn append_plugin_hook_sources( handlers: &mut Vec, hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, plugin_hook_sources: Vec, hook_states: &HashMap, policy: HookDiscoveryPolicy<'_>, ) { if policy.allow_managed_hooks_only { return; } // Listing inspects current contents; runtime discovery defers hashing until execution. let mut content_hashes = HashMap::new(); for source in plugin_hook_sources { let PluginHookSource { plugin_root, plugin_id, plugin_data_root, source_path, source_relative_path, hooks, } = source; let plugin_id = plugin_id.as_key(); let content_hash = (policy.mode == DiscoveryMode::Listing).then(|| { content_hashes .entry(plugin_root.clone()) .or_insert_with(|| { super::plugin_fingerprint::plugin_content_hash(&plugin_root, &|| false) }) }); let mut env = HashMap::new(); let plugin_root_value = plugin_root.display().to_string(); let plugin_data_root_value = plugin_data_root.display().to_string(); env.insert("PLUGIN_ROOT".to_string(), plugin_root_value.clone()); // For OOTB compat with existing plugins that use this env var. env.insert("CLAUDE_PLUGIN_ROOT".to_string(), plugin_root_value); env.insert("PLUGIN_DATA".to_string(), plugin_data_root_value.clone()); // For OOTB compat with existing plugins that use this env var. env.insert("CLAUDE_PLUGIN_DATA".to_string(), plugin_data_root_value); append_hook_events( handlers, hook_entries, warnings, display_order, HookHandlerSource { path: &source_path, key_source: crate::declarations::plugin_hook_key_source( plugin_id.as_str(), source_relative_path.as_str(), ), source: HookSource::Plugin, is_managed: false, requirement: HookRequirement::Optional, bypass_hook_trust: policy.bypass_hook_trust, hook_states, env, plugin_id: Some(plugin_id), plugin_content_hash: content_hash.as_deref(), config_layer_stack: policy.config_layer_stack, plugin_root: Some(&plugin_root), }, hooks, policy, ); } } fn managed_hooks_source_path( managed_hooks: &ManagedHooksRequirementsToml, requirement_source: Option<&RequirementSource>, ) -> AbsolutePathBuf { if let Some(source_path) = managed_hooks.managed_dir_for_current_platform() && source_path.is_absolute() && let Ok(source_path) = AbsolutePathBuf::from_absolute_path(source_path) { return source_path; } fallback_managed_hooks_source_path(requirement_source) } fn fallback_managed_hooks_source_path( requirement_source: Option<&RequirementSource>, ) -> AbsolutePathBuf { match requirement_source { Some(RequirementSource::SystemRequirementsToml { file }) | Some(RequirementSource::LegacyManagedConfigTomlFromFile { file }) => file.clone(), Some(RequirementSource::MdmManagedPreferences { domain, key }) => { synthetic_layer_path(&format!("/requirements.toml")) } Some(RequirementSource::Composite { .. }) => { synthetic_layer_path("/requirements.toml") } Some(RequirementSource::EnterpriseManaged { id, name }) => { let name = escape_xml_text(name); let id = escape_xml_text(id); synthetic_layer_path(&format!( "/requirements.toml" )) } Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { synthetic_layer_path("/managed_config.toml") } Some(RequirementSource::Unknown) | None => { synthetic_layer_path("/requirements.toml") } } } fn load_hooks_json( config_folder: Option<&Path>, warnings: &mut Vec, ) -> Option<(AbsolutePathBuf, HookEventsToml)> { let source_path = config_folder?.join("hooks.json"); if !source_path.as_path().is_file() { return None; } let contents = match fs::read_to_string(source_path.as_path()) { Ok(contents) => contents, Err(err) => { warnings.push(format!( "failed to read hooks config {}: {err}", source_path.display() )); return None; } }; let parsed: HooksFile = match serde_json::from_str(&contents) { Ok(parsed) => parsed, Err(err) => { warnings.push(format!( "failed to parse hooks config {}: {err}", source_path.display() )); return None; } }; let source_path = AbsolutePathBuf::from_absolute_path(&source_path) .inspect_err(|err| { warnings.push(format!( "failed to normalize hooks config path {}: {err}", source_path.display() )); }) .ok()?; (!parsed.hooks.is_empty()).then_some((source_path, parsed.hooks)) } fn load_toml_hooks_from_layer( layer: &ConfigLayerEntry, warnings: &mut Vec, ) -> Option<(AbsolutePathBuf, HookEventsToml)> { let source_path = config_toml_source_path(layer); let hook_value = layer.config.get("hooks")?.clone(); let parsed = match HookEventsToml::deserialize(hook_value) { Ok(parsed) => parsed, Err(err) => { warnings.push(format!( "failed to parse TOML hooks in {}: {err}", source_path.display() )); return None; } }; (!parsed.is_empty()).then_some((source_path, parsed)) } fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf { match &layer.name { ConfigLayerSource::PackagedDefaults { file } | ConfigLayerSource::System { file } | ConfigLayerSource::User { file, .. } | ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(), ConfigLayerSource::Project { dot_codex_folder } => layer .hooks_config_folder() .unwrap_or_else(|| dot_codex_folder.clone()) .join(CONFIG_TOML_FILE), ConfigLayerSource::Mdm { domain, key } => { synthetic_layer_path(&format!("/{CONFIG_TOML_FILE}")) } ConfigLayerSource::EnterpriseManaged { id, name } => synthetic_layer_path(&format!( "/{CONFIG_TOML_FILE}" )), ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { synthetic_layer_path("/managed_config.toml") } ConfigLayerSource::SessionFlags => synthetic_layer_path("/config.toml"), } } fn synthetic_layer_path(path: &str) -> AbsolutePathBuf { #[cfg(windows)] { AbsolutePathBuf::resolve_path_against_base(path, r"C:\") } #[cfg(not(windows))] { AbsolutePathBuf::resolve_path_against_base(path, "/") } } fn escape_xml_text(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); for ch in value.chars() { match ch { '&' => escaped.push_str("&"), '<' => escaped.push_str("<"), '>' => escaped.push_str(">"), '"' => escaped.push_str("""), '\'' => escaped.push_str("'"), _ => escaped.push(ch), } } escaped } pub(crate) fn discover_environment_hooks( sources: Vec, mut display_order: i64, ) -> DiscoveryResult { let mut discovered = DiscoveryResult::default(); for source in sources { append_environment_hooks(&mut discovered, source, &mut display_order); } discovered } /// Reject unloadable requirements before accepting an environment's configuration. pub fn validate_environment_hooks( environment_id: &str, cwd: &PathUri, hooks: &HookEventsToml, ) -> anyhow::Result<()> { if hooks.is_empty() { return Ok(()); } let source = crate::EnvironmentHookSource { environment_id: environment_id.to_owned(), cwd: cwd.clone(), hooks: hooks.clone(), }; let discovered = discover_environment_hooks(vec![source], /*display_order*/ 0); ensure_required_hooks_loaded(&discovered.required_load_errors) } fn append_environment_hooks( discovered: &mut DiscoveryResult, mut source: crate::EnvironmentHookSource, display_order: &mut i64, ) { let path = synthetic_layer_path(&format!( "/requirements.toml", source.environment_id )); // Resolve overrides against the executor platform before normal local discovery. for (_, groups) in source.hooks.matcher_groups_mut() { for handler in groups.iter_mut().flat_map(|group| &mut group.hooks) { if let HookHandlerConfig::Command { command, command_windows, .. } = handler && let Some(windows) = command_windows.take() && source.cwd.infer_path_convention() == Some(PathConvention::Windows) { *command = windows; } } } let handler_start = discovered.handlers.len(); let entry_start = discovered.hook_entries.len(); append_hook_events( &mut discovered.handlers, &mut discovered.hook_entries, &mut discovered.warnings, display_order, HookHandlerSource { path: &path, key_source: path.display().to_string(), source: HookSource::System, is_managed: true, requirement: HookRequirement::Required(&mut discovered.required_load_errors), bypass_hook_trust: false, hook_states: &HashMap::new(), env: HashMap::new(), plugin_id: None, plugin_content_hash: None, config_layer_stack: None, plugin_root: None, }, source.hooks, HookDiscoveryPolicy { config_layer_stack: None, mode: DiscoveryMode::Runtime, allow_managed_hooks_only: true, bypass_hook_trust: false, }, ); for entry in &mut discovered.hook_entries[entry_start..] { entry.environment_id = Some(source.environment_id.clone()); } for handler in &mut discovered.handlers[handler_start..] { handler.source_path = super::HandlerSourcePath::Environment { environment_id: source.environment_id.clone(), cwd: source.cwd.clone(), source_path: path.clone().into(), }; } } fn append_hook_events( handlers: &mut Vec, hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, mut source: HookHandlerSource<'_>, hook_events: HookEventsToml, policy: HookDiscoveryPolicy<'_>, ) { if !policy.allows(&source) { return; } for (event_name, groups) in hook_events.into_matcher_groups() { append_matcher_groups( handlers, hook_entries, warnings, display_order, &mut source, event_name, groups, ); } } fn append_matcher_groups( handlers: &mut Vec, hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, source: &mut HookHandlerSource<'_>, event_name: codex_protocol::protocol::HookEventName, groups: Vec, ) { for (group_index, group) in groups.into_iter().enumerate() { let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref()); let compiled_matcher = match matcher.map(HookMatcher::new).transpose() { Ok(matcher) => matcher, Err(err) => { let matcher = matcher.unwrap_or_default(); let warning = format!( "invalid matcher {matcher:?} in {}: {err}", source.path.display() ); if group.hooks.is_empty() { warnings.push(warning); } else { source.record_load_failure(warning, warnings); } continue; } }; for (handler_index, handler) in group.hooks.iter().cloned().enumerate() { let normalized = match handler { HookHandlerConfig::Command { command, command_windows, timeout_sec, r#async, status_message, additional_context_limit, } => { let command = if cfg!(windows) { command_windows.unwrap_or(command) } else { command }; if command.trim().is_empty() { source.record_load_failure( format!("skipping empty hook command in {}", source.path.display()), warnings, ); continue; } let timeout_sec = normalize_command_hook( event_name, timeout_sec, source.path.as_path(), warnings, ); let runs_async = r#async && event_name != HookEventName::SessionEnd; if r#async && !runs_async { warnings.push(format!( "running async {} hook synchronously in {}", hook_event_name_label(event_name), source.path.display() )); } let additional_context_limit = if matches!( event_name, codex_protocol::protocol::HookEventName::PreToolUse | codex_protocol::protocol::HookEventName::PostToolUse | codex_protocol::protocol::HookEventName::SessionStart | codex_protocol::protocol::HookEventName::UserPromptSubmit | codex_protocol::protocol::HookEventName::SubagentStart ) { additional_context_limit } else { if additional_context_limit.is_some() { warnings.push(format!( "ignoring additionalContextLimit for {event_name:?} hook in {}: this event cannot emit additionalContext", source.path.display() )); } None }; let normalized_additional_context_limit = additional_context_limit .filter(|limit| *limit != DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT); let config = HookHandlerConfig::Command { command: command.clone(), command_windows: None, timeout_sec: Some(timeout_sec), r#async, status_message: status_message.clone(), additional_context_limit: normalized_additional_context_limit, }; let command = source.env.iter().fold(command, |command, (key, value)| { command.replace(&format!("${{{key}}}"), value) }); NormalizedHandler { config, kind: ConfiguredHandlerKind::Command { command, env: source.env.clone(), r#async: runs_async, }, timeout_sec, status_message, additional_context_limit, } } HookHandlerConfig::McpTool { server, tool, input, timeout_sec, status_message, } => { if event_name == HookEventName::SessionEnd { source.record_load_failure( format!( "skipping MCP tool hook in {}: {} MCP hooks are not supported", source.path.display(), hook_event_name_label(event_name), ), warnings, ); continue; } if server.trim().is_empty() || tool.trim().is_empty() { source.record_load_failure( format!( "skipping MCP tool hook in {}: server and tool must not be empty", source.path.display() ), warnings, ); continue; } let timeout_sec = normalize_command_hook( event_name, timeout_sec, source.path.as_path(), warnings, ); let config = HookHandlerConfig::McpTool { server: server.clone(), tool: tool.clone(), input: input.clone(), timeout_sec: Some(timeout_sec), status_message: status_message.clone(), }; NormalizedHandler { config, kind: ConfiguredHandlerKind::McpTool { server, tool, input, }, timeout_sec, status_message, additional_context_limit: None, } } HookHandlerConfig::Prompt {} => { source.record_load_failure( format!( "skipping prompt hook in {}: prompt hooks are not supported yet", source.path.display() ), warnings, ); continue; } HookHandlerConfig::Agent {} => { source.record_load_failure( format!( "skipping agent hook in {}: agent hooks are not supported yet", source.path.display() ), warnings, ); continue; } }; let NormalizedHandler { config, kind, timeout_sec, status_message, additional_context_limit, } = normalized; let key = crate::hook_key(&source.key_source, event_name, group_index, handler_index); let state = source.hook_states.get(&key); let builtin = source.plugin_id.as_deref().is_some_and(|plugin_id| { is_allowlisted_bundled_cleanup_hook( plugin_id, event_name, group.matcher.as_deref(), &config, /*app_connector_id*/ None, ) }); let plugin_content_hash = match source.plugin_content_hash { Some(Ok(hash)) => Some(hash.as_str()), Some(Err(err)) if !builtin && !source.bypass_hook_trust => { source.record_load_failure( format!( "skipping plugin hook in {}: failed to fingerprint plugin contents: {err}", source.path.display() ), warnings, ); continue; } Some(Err(_)) | None => None, }; let declaration_hash = hook_hash(event_name, matcher, &group, &config); // The config writer persists these two approval hashes separately. let current_hash = plugin_content_hash.map_or_else( || declaration_hash.clone(), |hash| format!("{declaration_hash}/{hash}"), ); let enabled = hook_enabled(source.is_managed, builtin, state); let trusted_hash = hook_trusted_hash(source.is_managed, state); let plugin_trust = if !builtin && (!source.bypass_hook_trust || source.plugin_content_hash.is_some()) && let Some(root) = source.plugin_root && let Some(plugin_id) = source.plugin_id.as_deref() && let Some(trusted_hash) = trusted_hash { Some(PluginHookTrust { root: root.clone(), legacy_path: source .config_layer_stack .and_then(|stack| super::plugin_trust::legacy_trust_path(stack, plugin_id)), declaration_hash, trusted_hash: trusted_hash.to_owned(), trusted_content_hash: state .and_then(|state| state.trusted_content_hash.clone()), }) } else { None }; let trust_status = if let Some(content_hash) = plugin_content_hash && let Some(trust) = plugin_trust.as_ref() { match trust.matches_hash(content_hash) { Ok(true) => HookTrustStatus::Trusted, Ok(false) => HookTrustStatus::Modified, Err(err) => { source.record_load_failure( format!( "skipping plugin hook in {}: failed to migrate hook trust: {err}", source.path.display() ), warnings, ); continue; } } } else { hook_trust_status(source.is_managed, builtin, ¤t_hash, trusted_hash) }; let handler = match &kind { ConfiguredHandlerKind::Command { command, r#async, .. } => HookListEntryHandler::Command { command: command.clone(), r#async: *r#async, }, ConfiguredHandlerKind::McpTool { server, tool, .. } => { HookListEntryHandler::McpTool { server: server.clone(), tool: tool.clone(), } } }; hook_entries.push(HookListEntry { builtin, key, event_name, handler, matcher: matcher.map(ToOwned::to_owned), timeout_sec, status_message: status_message.clone(), additional_context_limit, source_path: source.path.clone(), source: source.source, plugin_id: source.plugin_id.clone(), environment_id: None, display_order: *display_order, enabled, is_managed: source.is_managed, current_hash, trust_status, }); if enabled && (source.bypass_hook_trust || (source.plugin_content_hash.is_none() && plugin_trust.is_some()) || matches!( trust_status, HookTrustStatus::Managed | HookTrustStatus::Trusted )) { handlers.push(ConfiguredHandler { builtin, plugin_trust, event_name, matcher: compiled_matcher.clone(), timeout_sec, status_message, additional_context_limit: AdditionalContextLimit::from_config( additional_context_limit, ), source_path: source.path.clone().into(), source: source.source, display_order: *display_order, kind, }); } *display_order += 1; } } } /// Normalizes hook timeouts. SessionEnd and Interrupt default to one second and are capped at three /// seconds; all other hooks keep the standard ten-minute default. fn normalize_command_hook( event_name: HookEventName, timeout_sec: Option, source_path: &Path, warnings: &mut Vec, ) -> u64 { match event_name { HookEventName::SessionEnd | HookEventName::Interrupt => { let max_timeout_sec = SESSION_END_MAX_TIMEOUT_SEC; if timeout_sec.is_some_and(|timeout_sec| timeout_sec > max_timeout_sec) { warnings.push(format!( "clamping {} hook timeout to {max_timeout_sec}s in {}", hook_event_name_label(event_name), source_path.display() )); } timeout_sec .unwrap_or(SESSION_END_DEFAULT_TIMEOUT_SEC) .clamp(1, max_timeout_sec) } _ => timeout_sec.unwrap_or(600).max(1), } } /// Hash a normalized, config-derived identity instead of source text so equivalent /// hooks from config TOML and hooks.json converge on the same trust identity. #[derive(Serialize)] struct NormalizedHookIdentity { event_name: &'static str, #[serde(flatten)] group: MatcherGroup, } fn hook_hash( event_name: codex_protocol::protocol::HookEventName, matcher: Option<&str>, group: &MatcherGroup, normalized_handler: &HookHandlerConfig, ) -> String { let mut group = group.clone(); group.matcher = matcher.map(ToOwned::to_owned); group.hooks = vec![normalized_handler.clone()]; let identity = NormalizedHookIdentity { event_name: crate::hook_event_key_label(event_name), group, }; let Ok(value) = TomlValue::try_from(identity) else { unreachable!("normalized hook identity should serialize to TOML"); }; version_for_toml(&value) } fn hook_trust_status( is_managed: bool, is_builtin: bool, current_hash: &str, trusted_hash: Option<&str>, ) -> HookTrustStatus { if is_builtin { HookTrustStatus::Trusted } else if is_managed { HookTrustStatus::Managed } else { match trusted_hash { Some(trusted_hash) if trusted_hash == current_hash => HookTrustStatus::Trusted, Some(_) => HookTrustStatus::Modified, None => HookTrustStatus::Untrusted, } } } fn hook_enabled(is_managed: bool, is_builtin: bool, state: Option<&HookStateToml>) -> bool { is_builtin || is_managed || state.and_then(|state| state.enabled) != Some(false) } fn hook_trusted_hash(is_managed: bool, state: Option<&HookStateToml>) -> Option<&str> { (!is_managed) .then(|| state.and_then(|state| state.trusted_hash.as_deref())) .flatten() } fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSource, bool) { match source { ConfigLayerSource::PackagedDefaults { .. } => (HookSource::Unknown, false), ConfigLayerSource::System { .. } => (HookSource::System, true), ConfigLayerSource::User { .. } => (HookSource::User, false), ConfigLayerSource::Project { .. } => (HookSource::Project, false), ConfigLayerSource::Mdm { .. } => (HookSource::Mdm, true), ConfigLayerSource::EnterpriseManaged { .. } => (HookSource::CloudManagedConfig, true), ConfigLayerSource::SessionFlags => (HookSource::SessionFlags, false), ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => { (HookSource::LegacyManagedConfigFile, true) } ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { (HookSource::LegacyManagedConfigMdm, true) } } } fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> HookSource { match source { Some(RequirementSource::MdmManagedPreferences { .. }) => HookSource::Mdm, Some(RequirementSource::SystemRequirementsToml { .. }) => HookSource::System, Some(RequirementSource::LegacyManagedConfigTomlFromFile { .. }) => { HookSource::LegacyManagedConfigFile } Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { HookSource::LegacyManagedConfigMdm } Some(RequirementSource::Composite { sources }) => { // Requirements hook composition preserves contributing sources in // priority order, but discovery only carries one source for the // whole merged hooks field. Use the primary contributor as the best // available coarse attribution. hook_source_for_requirement_source(sources.first()) } Some(RequirementSource::EnterpriseManaged { .. }) => HookSource::CloudRequirements, Some(RequirementSource::Unknown) | None => HookSource::Unknown, } } #[cfg(test)] mod tests { use super::HookMatcher; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; use codex_config::HookEventsToml; use codex_config::RequirementSource; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookExecutionMode; use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use super::ConfiguredHandler; use super::ConfiguredHandlerKind; use super::HookListEntry; use super::HookListEntryHandler; use super::append_matcher_groups; use super::normalize_command_hook; use crate::output_spill::AdditionalContextLimit; use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT; use codex_config::HookHandlerConfig; use codex_config::HookStateToml; use codex_config::MatcherGroup; use codex_config::TomlValue; use codex_protocol::protocol::HookTrustStatus; fn source_path() -> AbsolutePathBuf { test_path_buf("/tmp/hooks.json").abs() } fn hook_source() -> HookSource { HookSource::System } fn hook_handler_source<'a>( path: &'a AbsolutePathBuf, hook_states: &'a std::collections::HashMap, ) -> super::HookHandlerSource<'a> { super::HookHandlerSource { path, key_source: path.display().to_string(), source: hook_source(), is_managed: true, requirement: super::HookRequirement::Optional, bypass_hook_trust: false, hook_states, env: std::collections::HashMap::new(), plugin_id: None, plugin_content_hash: None, config_layer_stack: None, plugin_root: None, } } fn unmanaged_hook_handler_source<'a>( path: &'a AbsolutePathBuf, hook_states: &'a std::collections::HashMap, bypass_hook_trust: bool, ) -> super::HookHandlerSource<'a> { super::HookHandlerSource { path, key_source: path.display().to_string(), source: HookSource::User, is_managed: false, requirement: super::HookRequirement::Optional, bypass_hook_trust, hook_states, env: std::collections::HashMap::new(), plugin_id: None, plugin_content_hash: None, config_layer_stack: None, plugin_root: None, } } #[test] fn composite_requirement_hook_source_uses_primary_source() { let source = RequirementSource::Composite { sources: vec![ RequirementSource::SystemRequirementsToml { file: test_path_buf("/etc/codex/requirements.toml").abs(), }, RequirementSource::EnterpriseManaged { id: "layer-1".to_string(), name: "Engineering".to_string(), }, ], }; assert_eq!( super::hook_source_for_requirement_source(Some(&source)), HookSource::System ); } #[test] fn enterprise_managed_synthetic_path_escapes_display_fields() { let source = RequirementSource::EnterpriseManaged { id: "id<&>".to_string(), name: "Name & \"Ops\"".to_string(), }; let source_path = super::fallback_managed_hooks_source_path(Some(&source)); let source_path = source_path.display().to_string(); assert!(source_path.contains("Name <Admin> & "Ops"")); assert!(source_path.contains("id<&>")); assert!(!source_path.contains("Name ")); } fn command_group(matcher: Option<&str>) -> MatcherGroup { MatcherGroup { matcher: matcher.map(str::to_string), hooks: vec![HookHandlerConfig::Command { command: "echo hello".to_string(), command_windows: None, timeout_sec: None, r#async: false, status_message: None, additional_context_limit: None, }], } } fn command_group_with_additional_context_limit( additional_context_limit: usize, ) -> MatcherGroup { MatcherGroup { matcher: None, hooks: vec![HookHandlerConfig::Command { command: "echo hello".to_string(), command_windows: None, timeout_sec: None, r#async: false, status_message: None, additional_context_limit: Some(additional_context_limit), }], } } #[test] fn mcp_tool_hooks_preserve_argument_templates_and_list_their_target() { let source_path = source_path(); let hook_states = std::collections::HashMap::new(); let input = serde_json::from_value(serde_json::json!({ "file_path": "${tool_input.file_path}", "optional": "${tool_input.optional}", })) .expect("MCP hook input should be an object"); let mut handlers = Vec::new(); let mut entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; append_matcher_groups( &mut handlers, &mut entries, &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::PostToolUse, vec![MatcherGroup { matcher: Some("Write|Edit".to_string()), hooks: vec![HookHandlerConfig::McpTool { server: "security".to_string(), tool: "scan".to_string(), input, timeout_sec: Some(30), status_message: Some("Scanning file".to_string()), }], }], ); assert!(warnings.is_empty()); assert_eq!(handlers.len(), 1); assert_eq!(entries.len(), 1); assert_eq!( entries[0].handler, HookListEntryHandler::McpTool { server: "security".to_string(), tool: "scan".to_string(), } ); assert!(entries[0].current_hash.starts_with("sha256:")); } #[test] fn session_end_mcp_tool_hooks_are_warned_and_skipped() { let source_path = source_path(); let hook_states = std::collections::HashMap::new(); let mut handlers = Vec::new(); let mut entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; append_matcher_groups( &mut handlers, &mut entries, &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::SessionEnd, vec![MatcherGroup { matcher: None, hooks: vec![HookHandlerConfig::McpTool { server: "security".to_string(), tool: "scan".to_string(), input: serde_json::Map::new(), timeout_sec: None, status_message: None, }], }], ); assert!(handlers.is_empty()); assert!(entries.is_empty()); assert_eq!( warnings, vec![format!( "skipping MCP tool hook in {}: SessionEnd MCP hooks are not supported", source_path.display() )] ); } #[test] fn interrupt_mcp_tool_hooks_are_supported_and_timeout_is_clamped() { let source_path = source_path(); let hook_states = std::collections::HashMap::new(); let mut handlers = Vec::new(); let mut entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; append_matcher_groups( &mut handlers, &mut entries, &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::Interrupt, vec![MatcherGroup { matcher: None, hooks: vec![ HookHandlerConfig::McpTool { server: "security".to_string(), tool: "scan".to_string(), input: serde_json::Map::new(), timeout_sec: None, status_message: None, }, HookHandlerConfig::McpTool { server: "security".to_string(), tool: "report".to_string(), input: serde_json::Map::new(), timeout_sec: Some(600), status_message: None, }, ], }], ); assert_eq!( handlers .iter() .map(|handler| handler.timeout_sec) .collect::>(), vec![1, 3] ); assert_eq!( entries .iter() .map(|entry| entry.timeout_sec) .collect::>(), vec![1, 3] ); assert!( entries .iter() .all(|entry| matches!(entry.handler, HookListEntryHandler::McpTool { .. })) ); assert_eq!( warnings, vec![format!( "clamping Interrupt hook timeout to 3s in {}", source_path.display() )] ); } fn discover_command( event_name: HookEventName, additional_context_limit: Option, ) -> (ConfiguredHandler, HookListEntry, Vec) { let source_path = source_path(); let hook_states = std::collections::HashMap::new(); let mut handlers = Vec::new(); let mut entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; append_matcher_groups( &mut handlers, &mut entries, &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), event_name, vec![match additional_context_limit { Some(limit) => command_group_with_additional_context_limit(limit), None => command_group(/*matcher*/ None), }], ); (handlers.remove(0), entries.remove(0), warnings) } #[test] fn supported_events_retain_per_handler_additional_context_limit_and_hash_it() { for event_name in [ HookEventName::PreToolUse, HookEventName::PostToolUse, HookEventName::SessionStart, HookEventName::UserPromptSubmit, HookEventName::SubagentStart, ] { let (_, default_entry, _) = discover_command(event_name, /*additional_context_limit*/ None); let (explicit_default_handler, explicit_default_entry, _) = discover_command( event_name, /*additional_context_limit*/ Some(DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT), ); let (custom_handler, custom_entry, _) = discover_command(event_name, /*additional_context_limit*/ Some(20_000)); let (unlimited_handler, unlimited_entry, _) = discover_command(event_name, /*additional_context_limit*/ Some(0)); assert_eq!( custom_handler.additional_context_limit, AdditionalContextLimit::from_config(Some(20_000)) ); assert_eq!(custom_entry.additional_context_limit, Some(20_000)); assert_ne!(default_entry.current_hash, custom_entry.current_hash); assert_eq!( explicit_default_handler.additional_context_limit, AdditionalContextLimit::from_config(Some(DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT)) ); assert_eq!( explicit_default_entry.additional_context_limit, Some(DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT) ); assert_eq!( default_entry.current_hash, explicit_default_entry.current_hash ); assert_eq!( unlimited_handler.additional_context_limit, AdditionalContextLimit::from_config(Some(0)) ); assert_eq!(unlimited_entry.additional_context_limit, Some(0)); assert_ne!(default_entry.current_hash, unlimited_entry.current_hash); } } #[test] fn unsupported_event_warns_and_ignores_additional_context_limit() { let source_path = source_path(); let (handler, _, warnings) = discover_command( HookEventName::Stop, /*additional_context_limit*/ Some(4_096), ); assert_eq!(handler.additional_context_limit, Default::default()); assert_eq!(warnings.len(), 1); assert!(warnings[0].contains("ignoring additionalContextLimit for Stop hook")); assert!(warnings[0].contains(&source_path.display().to_string())); } #[test] fn user_prompt_submit_ignores_invalid_matcher_during_discovery() { let mut handlers = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::UserPromptSubmit, vec![command_group(Some("["))], ); assert_eq!(warnings, Vec::::new()); assert_eq!( handlers, vec![ConfiguredHandler { builtin: false, plugin_trust: None, event_name: HookEventName::UserPromptSubmit, matcher: None, timeout_sec: 600, status_message: None, additional_context_limit: Default::default(), source_path: source_path.clone().into(), source: hook_source(), display_order: 0, kind: ConfiguredHandlerKind::Command { command: "echo hello".to_string(), r#async: false, env: std::collections::HashMap::new(), }, }] ); } #[test] fn pre_tool_use_keeps_valid_matcher_during_discovery() { let mut handlers = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::PreToolUse, vec![command_group(Some("^Bash$"))], ); assert_eq!(warnings, Vec::::new()); assert_eq!( handlers, vec![ConfiguredHandler { builtin: false, plugin_trust: None, event_name: HookEventName::PreToolUse, matcher: Some(HookMatcher::new("^Bash$").expect("valid matcher")), timeout_sec: 600, status_message: None, additional_context_limit: Default::default(), source_path: source_path.clone().into(), source: hook_source(), display_order: 0, kind: ConfiguredHandlerKind::Command { command: "echo hello".to_string(), r#async: false, env: std::collections::HashMap::new(), }, }] ); } #[test] fn session_end_normalizes_timeout() { let mut handlers = Vec::new(); let mut hook_entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut hook_entries, &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::SessionEnd, vec![MatcherGroup { matcher: Some("other".to_string()), hooks: vec![ HookHandlerConfig::Command { command: "echo default".to_string(), command_windows: None, timeout_sec: None, r#async: false, status_message: None, additional_context_limit: None, }, HookHandlerConfig::Command { command: "echo clamped".to_string(), command_windows: None, timeout_sec: Some(600), r#async: true, status_message: None, additional_context_limit: None, }, ], }], ); assert_eq!( handlers .iter() .map(|handler| handler.timeout_sec) .collect::>(), vec![1, 3] ); assert!( handlers .iter() .all(ConfiguredHandler::can_apply_control_effects) ); assert_eq!( handlers .iter() .map(|handler| handler.matcher.as_ref().map(HookMatcher::as_str)) .collect::>(), vec![Some("other"), Some("other")] ); assert_eq!( hook_entries .iter() .map(|entry| entry.timeout_sec) .collect::>(), vec![1, 3] ); assert!(hook_entries.iter().all(|entry| matches!( entry.handler, HookListEntryHandler::Command { r#async: false, .. } ))); assert_eq!( hook_entries .iter() .map(|entry| entry.matcher.as_deref()) .collect::>(), vec![Some("other"), Some("other")] ); assert_eq!( warnings, vec![ format!( "clamping SessionEnd hook timeout to 3s in {}", source_path.display() ), format!( "running async SessionEnd hook synchronously in {}", source_path.display() ), ] ); } #[test] fn interrupt_normalizes_timeout_and_supports_async_execution() { let mut handlers = Vec::new(); let mut hook_entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut hook_entries, &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::Interrupt, vec![MatcherGroup { matcher: Some("ignored".to_string()), hooks: vec![HookHandlerConfig::Command { command: "echo interrupt".to_string(), command_windows: None, timeout_sec: Some(600), r#async: true, status_message: None, additional_context_limit: None, }], }], ); assert_eq!( normalize_command_hook( HookEventName::Interrupt, /*timeout_sec*/ None, source_path.as_path(), &mut Vec::new(), ), 1 ); assert_eq!( handlers .iter() .map(|handler| ( handler.timeout_sec, handler.matcher.as_ref().map(HookMatcher::as_str), handler.execution_mode() )) .collect::>(), vec![(3, None, HookExecutionMode::Async)] ); assert_eq!( hook_entries .iter() .map(|entry| (entry.timeout_sec, entry.matcher.as_deref())) .collect::>(), vec![(3, None)] ); assert!(hook_entries.iter().all(|entry| matches!( entry.handler, HookListEntryHandler::Command { r#async: true, .. } ))); assert_eq!( warnings, vec![format!( "clamping Interrupt hook timeout to 3s in {}", source_path.display() )] ); } #[test] fn bypass_hook_trust_allows_enabled_untrusted_handlers() { let mut handlers = Vec::new(); let mut hook_entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut hook_entries, &mut warnings, &mut display_order, &mut unmanaged_hook_handler_source( &source_path, &hook_states, /*bypass_hook_trust*/ true, ), HookEventName::PreToolUse, vec![command_group(Some("Bash"))], ); assert_eq!(warnings, Vec::::new()); assert_eq!(handlers.len(), 1); assert_eq!(hook_entries.len(), 1); assert_eq!(hook_entries[0].trust_status, HookTrustStatus::Untrusted); assert_eq!(hook_entries[0].enabled, true); } #[test] fn bypass_hook_trust_respects_disabled_handlers() { let mut handlers = Vec::new(); let mut hook_entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); let hook_states = std::collections::HashMap::from([( format!("{}:pre_tool_use:0:0", source_path.display()), HookStateToml { enabled: Some(false), trusted_hash: None, trusted_content_hash: None, }, )]); append_matcher_groups( &mut handlers, &mut hook_entries, &mut warnings, &mut display_order, &mut unmanaged_hook_handler_source( &source_path, &hook_states, /*bypass_hook_trust*/ true, ), HookEventName::PreToolUse, vec![command_group(Some("Bash"))], ); assert_eq!(warnings, Vec::::new()); assert_eq!(handlers, Vec::::new()); assert_eq!(hook_entries.len(), 1); assert_eq!(hook_entries[0].trust_status, HookTrustStatus::Untrusted); assert_eq!(hook_entries[0].enabled, false); } #[test] fn pre_tool_use_treats_star_matcher_as_match_all() { let mut handlers = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::PreToolUse, vec![command_group(Some("*"))], ); assert_eq!(warnings, Vec::::new()); assert_eq!(handlers.len(), 1); assert_eq!( handlers[0].matcher.as_ref().map(HookMatcher::as_str), Some("*") ); } #[test] fn post_tool_use_keeps_valid_matcher_during_discovery() { let mut handlers = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::PostToolUse, vec![command_group(Some("Edit|Write"))], ); assert_eq!(warnings, Vec::::new()); assert_eq!(handlers.len(), 1); assert_eq!(handlers[0].event_name, HookEventName::PostToolUse); assert_eq!( handlers[0].matcher.as_ref().map(HookMatcher::as_str), Some("Edit|Write") ); } #[test] fn toml_hook_discovery_ignores_malformed_state_entries() { let layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: test_path_buf("/tmp/config.toml").abs(), profile: None, }, config_with_malformed_state_and_session_start_hook(), ); let mut warnings = Vec::new(); let (_, hooks) = super::load_toml_hooks_from_layer(&layer, &mut warnings) .expect("valid hook events should still load"); assert_eq!(warnings, Vec::::new()); assert_eq!( hooks, HookEventsToml { session_start: vec![MatcherGroup { matcher: None, hooks: vec![HookHandlerConfig::Command { command: "echo hello".to_string(), command_windows: None, timeout_sec: None, r#async: false, status_message: None, additional_context_limit: None, }], }], ..Default::default() } ); } #[test] fn pre_tool_use_resolves_windows_command_override_during_discovery() { let mut handlers = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, &mut hook_handler_source(&source_path, &hook_states), HookEventName::PreToolUse, vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "echo unix".to_string(), command_windows: Some("echo windows".to_string()), timeout_sec: None, r#async: false, status_message: None, additional_context_limit: None, }], }], ); assert_eq!(warnings, Vec::::new()); assert_eq!(handlers.len(), 1); assert_eq!( handlers[0].kind, ConfiguredHandlerKind::Command { command: if cfg!(windows) { "echo windows" } else { "echo unix" } .to_string(), env: std::collections::HashMap::new(), r#async: false, } ); } fn config_with_malformed_state_and_session_start_hook() -> TomlValue { serde_json::from_value(serde_json::json!({ "hooks": { "state": { "some_key": { "enabled": "not a bool", }, }, "SessionStart": [{ "hooks": [{ "type": "command", "command": "echo hello", }], }], }, })) .expect("config TOML should deserialize") } #[test] fn hook_metadata_for_config_layer_source_discards_source_details() { let config_file = test_path_buf("/tmp/.codex/config.toml").abs(); let dot_codex_folder = test_path_buf("/tmp/worktree/.codex").abs(); assert_eq!( super::hook_metadata_for_config_layer_source(&ConfigLayerSource::System { file: config_file.clone(), }), (HookSource::System, true), ); assert_eq!( super::hook_metadata_for_config_layer_source(&ConfigLayerSource::User { file: config_file.clone(), profile: None, }), (HookSource::User, false), ); assert_eq!( super::hook_metadata_for_config_layer_source(&ConfigLayerSource::Project { dot_codex_folder }), (HookSource::Project, false), ); assert_eq!( super::hook_metadata_for_config_layer_source(&ConfigLayerSource::Mdm { domain: "com.openai.codex".to_string(), key: "config".to_string(), }), (HookSource::Mdm, true), ); assert_eq!( super::hook_metadata_for_config_layer_source(&ConfigLayerSource::EnterpriseManaged { id: "cfg_123".to_string(), name: "Base policy".to_string(), }), (HookSource::CloudManagedConfig, true), ); assert_eq!( super::hook_metadata_for_config_layer_source(&ConfigLayerSource::SessionFlags), (HookSource::SessionFlags, false), ); assert_eq!( super::hook_metadata_for_config_layer_source( &ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config_file }, ), (HookSource::LegacyManagedConfigFile, true), ); assert_eq!( super::hook_metadata_for_config_layer_source( &ConfigLayerSource::LegacyManagedConfigTomlFromMdm, ), (HookSource::LegacyManagedConfigMdm, true), ); } }