Codex Handbook
hooks/src/engine/discovery.rs 1093 lines
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::ConfigLayerStackOrdering;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_utils_absolute_path::AbsolutePathBuf;use serde::Deserialize;use serde::Serialize;use super::ConfiguredHandler;use super::HookListEntry;use crate::config_rules::hook_states_from_stack;use crate::events::common::matcher_pattern_for_event;use crate::events::common::validate_matcher_pattern;use codex_protocol::protocol::HookHandlerType;use codex_protocol::protocol::HookSource;use codex_protocol::protocol::HookTrustStatus;pub(crate) struct DiscoveryResult {    pub handlers: Vec<ConfiguredHandler>,    pub hook_entries: Vec<HookListEntry>,    pub warnings: Vec<String>,}struct HookHandlerSource<'a> {    path: &'a AbsolutePathBuf,    key_source: String,    source: HookSource,    is_managed: bool,    bypass_hook_trust: bool,    hook_states: &'a HashMap<String, HookStateToml>,    env: HashMap<String, String>,    plugin_id: Option<String>,}#[derive(Clone, Copy)]struct HookDiscoveryPolicy {    allow_managed_hooks_only: bool,    bypass_hook_trust: bool,}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<PluginHookSource>,    plugin_hook_load_warnings: Vec<String>,    bypass_hook_trust: bool,) -> DiscoveryResult {    let mut handlers = Vec::new();    let mut hook_entries = Vec::new();    let mut warnings = plugin_hook_load_warnings;    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 {        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,    };    if let Some(config_layer_stack) = config_layer_stack {        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.get_layers(            ConfigLayerStackOrdering::LowestPrecedenceFirst,            /*include_disabled*/ false,        ) {            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,                bypass_hook_trust: false,                hook_states: &hook_states,                env: HashMap::new(),                plugin_id: 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,                        bypass_hook_trust: policy.bypass_hook_trust,                        hook_states: &hook_states,                        env: HashMap::new(),                        plugin_id: 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,    }}fn append_managed_requirement_handlers(    handlers: &mut Vec<ConfiguredHandler>,    hook_entries: &mut Vec<HookListEntry>,    warnings: &mut Vec<String>,    display_order: &mut i64,    config_layer_stack: &ConfigLayerStack,    hook_states: &HashMap<String, HookStateToml>,    policy: HookDiscoveryPolicy,) {    let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else {        return;    };    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,            bypass_hook_trust: false,            hook_states,            env: HashMap::new(),            plugin_id: None,        },        managed_hooks.get().hooks.clone(),        policy,    );}fn append_plugin_hook_sources(    handlers: &mut Vec<ConfiguredHandler>,    hook_entries: &mut Vec<HookListEntry>,    warnings: &mut Vec<String>,    display_order: &mut i64,    plugin_hook_sources: Vec<PluginHookSource>,    hook_states: &HashMap<String, HookStateToml>,    policy: HookDiscoveryPolicy,) {    for source in plugin_hook_sources {        let PluginHookSource {            plugin_root,            plugin_id,            plugin_data_root,            source_path,            source_relative_path,            hooks,        } = source;        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);        let plugin_id = plugin_id.as_key();        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,                bypass_hook_trust: policy.bypass_hook_trust,                hook_states,                env,                plugin_id: Some(plugin_id),            },            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!("<mdm:{domain}:{key}>/requirements.toml"))        }        Some(RequirementSource::Composite { .. }) => {            synthetic_layer_path("<requirements-composition>/requirements.toml")        }        Some(RequirementSource::EnterpriseManaged { id, name }) => {            let name = escape_xml_text(name);            let id = escape_xml_text(id);            synthetic_layer_path(&format!(                "<enterprise-managed:{name}:{id}>/requirements.toml"            ))        }        Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {            synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")        }        Some(RequirementSource::Unknown) | None => {            synthetic_layer_path("<managed-requirements>/requirements.toml")        }    }}fn load_hooks_json(    config_folder: Option<&Path>,    warnings: &mut Vec<String>,) -> 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<String>,) -> 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::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!("<mdm:{domain}:{key}>/{CONFIG_TOML_FILE}"))        }        ConfigLayerSource::EnterpriseManaged { id, name } => synthetic_layer_path(&format!(            "<enterprise-managed:{name}:{id}>/{CONFIG_TOML_FILE}"        )),        ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {            synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")        }        ConfigLayerSource::SessionFlags => synthetic_layer_path("<session-flags>/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("&amp;"),            '<' => escaped.push_str("&lt;"),            '>' => escaped.push_str("&gt;"),            '"' => escaped.push_str("&quot;"),            '\'' => escaped.push_str("&apos;"),            _ => escaped.push(ch),        }    }    escaped}fn append_hook_events(    handlers: &mut Vec<ConfiguredHandler>,    hook_entries: &mut Vec<HookListEntry>,    warnings: &mut Vec<String>,    display_order: &mut i64,    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,            &source,            event_name,            groups,        );    }}fn append_matcher_groups(    handlers: &mut Vec<ConfiguredHandler>,    hook_entries: &mut Vec<HookListEntry>,    warnings: &mut Vec<String>,    display_order: &mut i64,    source: &HookHandlerSource<'_>,    event_name: codex_protocol::protocol::HookEventName,    groups: Vec<MatcherGroup>,) {    for (group_index, group) in groups.into_iter().enumerate() {        let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref());        if let Some(matcher) = matcher            && let Err(err) = validate_matcher_pattern(matcher)        {            warnings.push(format!(                "invalid matcher {matcher:?} in {}: {err}",                source.path.display()            ));            continue;        }        for (handler_index, handler) in group.hooks.iter().cloned().enumerate() {            match handler {                HookHandlerConfig::Command {                    command,                    command_windows,                    timeout_sec,                    r#async,                    status_message,                } => {                    let command = if cfg!(windows) {                        command_windows.unwrap_or(command)                    } else {                        command                    };                    if r#async {                        warnings.push(format!(                            "skipping async hook in {}: async hooks are not supported yet",                            source.path.display()                        ));                        continue;                    }                    if command.trim().is_empty() {                        warnings.push(format!(                            "skipping empty hook command in {}",                            source.path.display()                        ));                        continue;                    }                    let timeout_sec = timeout_sec.unwrap_or(600).max(1);                    let normalized_handler = HookHandlerConfig::Command {                        command: command.clone(),                        command_windows: None,                        timeout_sec: Some(timeout_sec),                        r#async,                        status_message: status_message.clone(),                    };                    let current_hash =                        command_hook_hash(event_name, matcher, &group, normalized_handler);                    let command = source.env.iter().fold(command, |command, (key, value)| {                        command.replace(&format!("${{{key}}}"), value)                    });                    // TODO(abhinav): replace this positional suffix with a durable hook id.                    let key =                        crate::hook_key(&source.key_source, event_name, group_index, handler_index);                    let state = source.hook_states.get(&key);                    let enabled = hook_enabled(source.is_managed, state);                    let trusted_hash = hook_trusted_hash(source.is_managed, state);                    let trust_status =                        hook_trust_status(source.is_managed, &current_hash, trusted_hash);                    hook_entries.push(HookListEntry {                        key,                        event_name,                        handler_type: HookHandlerType::Command,                        matcher: matcher.map(ToOwned::to_owned),                        command: Some(command.clone()),                        timeout_sec,                        status_message: status_message.clone(),                        source_path: source.path.clone(),                        source: source.source,                        plugin_id: source.plugin_id.clone(),                        display_order: *display_order,                        enabled,                        is_managed: source.is_managed,                        current_hash,                        trust_status,                    });                    if enabled                        && (source.bypass_hook_trust                            || matches!(                                trust_status,                                HookTrustStatus::Managed | HookTrustStatus::Trusted                            ))                    {                        handlers.push(ConfiguredHandler {                            event_name,                            matcher: matcher.map(ToOwned::to_owned),                            command,                            timeout_sec,                            status_message,                            source_path: source.path.clone(),                            source: source.source,                            display_order: *display_order,                            env: source.env.clone(),                        });                    }                    *display_order += 1;                }                HookHandlerConfig::Prompt {} => warnings.push(format!(                    "skipping prompt hook in {}: prompt hooks are not supported yet",                    source.path.display()                )),                HookHandlerConfig::Agent {} => warnings.push(format!(                    "skipping agent hook in {}: agent hooks are not supported yet",                    source.path.display()                )),            }        }    }}/// 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 command_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];    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,    current_hash: &str,    trusted_hash: Option<&str>,) -> HookTrustStatus {    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, state: Option<&HookStateToml>) -> bool {    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::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 codex_config::ConfigLayerEntry;    use codex_config::ConfigLayerSource;    use codex_config::HookEventsToml;    use codex_config::RequirementSource;    use codex_protocol::protocol::HookEventName;    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::append_matcher_groups;    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<String, HookStateToml>,    ) -> super::HookHandlerSource<'a> {        super::HookHandlerSource {            path,            key_source: path.display().to_string(),            source: hook_source(),            is_managed: true,            bypass_hook_trust: false,            hook_states,            env: std::collections::HashMap::new(),            plugin_id: None,        }    }    fn unmanaged_hook_handler_source<'a>(        path: &'a AbsolutePathBuf,        hook_states: &'a std::collections::HashMap<String, HookStateToml>,        bypass_hook_trust: bool,    ) -> super::HookHandlerSource<'a> {        super::HookHandlerSource {            path,            key_source: path.display().to_string(),            source: HookSource::User,            is_managed: false,            bypass_hook_trust,            hook_states,            env: std::collections::HashMap::new(),            plugin_id: 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 <Admin> & \"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 &lt;Admin&gt; &amp; &quot;Ops&quot;"));        assert!(source_path.contains("id&lt;&amp;&gt;"));        assert!(!source_path.contains("Name <Admin>"));    }    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,            }],        }    }    #[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,            &hook_handler_source(&source_path, &hook_states),            HookEventName::UserPromptSubmit,            vec![command_group(Some("["))],        );        assert_eq!(warnings, Vec::<String>::new());        assert_eq!(            handlers,            vec![ConfiguredHandler {                event_name: HookEventName::UserPromptSubmit,                matcher: None,                command: "echo hello".to_string(),                timeout_sec: 600,                status_message: None,                source_path: source_path.clone(),                source: hook_source(),                display_order: 0,                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,            &hook_handler_source(&source_path, &hook_states),            HookEventName::PreToolUse,            vec![command_group(Some("^Bash$"))],        );        assert_eq!(warnings, Vec::<String>::new());        assert_eq!(            handlers,            vec![ConfiguredHandler {                event_name: HookEventName::PreToolUse,                matcher: Some("^Bash$".to_string()),                command: "echo hello".to_string(),                timeout_sec: 600,                status_message: None,                source_path: source_path.clone(),                source: hook_source(),                display_order: 0,                env: std::collections::HashMap::new(),            }]        );    }    #[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,            &unmanaged_hook_handler_source(                &source_path,                &hook_states,                /*bypass_hook_trust*/ true,            ),            HookEventName::PreToolUse,            vec![command_group(Some("Bash"))],        );        assert_eq!(warnings, Vec::<String>::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,            },        )]);        append_matcher_groups(            &mut handlers,            &mut hook_entries,            &mut warnings,            &mut display_order,            &unmanaged_hook_handler_source(                &source_path,                &hook_states,                /*bypass_hook_trust*/ true,            ),            HookEventName::PreToolUse,            vec![command_group(Some("Bash"))],        );        assert_eq!(warnings, Vec::<String>::new());        assert_eq!(handlers, Vec::<ConfiguredHandler>::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,            &hook_handler_source(&source_path, &hook_states),            HookEventName::PreToolUse,            vec![command_group(Some("*"))],        );        assert_eq!(warnings, Vec::<String>::new());        assert_eq!(handlers.len(), 1);        assert_eq!(handlers[0].matcher.as_deref(), 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,            &hook_handler_source(&source_path, &hook_states),            HookEventName::PostToolUse,            vec![command_group(Some("Edit|Write"))],        );        assert_eq!(warnings, Vec::<String>::new());        assert_eq!(handlers.len(), 1);        assert_eq!(handlers[0].event_name, HookEventName::PostToolUse);        assert_eq!(handlers[0].matcher.as_deref(), 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::<String>::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,                    }],                }],                ..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,            &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,                }],            }],        );        assert_eq!(warnings, Vec::<String>::new());        assert_eq!(handlers.len(), 1);        assert_eq!(            handlers[0].command,            if cfg!(windows) {                "echo windows"            } else {                "echo unix"            }        );    }    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),        );    }}