Codex Handbook
hooks/src/config_rules.rs 263 lines
use std::collections::HashMap;use codex_config::ConfigLayerSource;use codex_config::ConfigLayerStack;use codex_config::ConfigLayerStackOrdering;use codex_config::HookStateToml;use codex_config::TomlValue;/// Build effective hook state from config layers that are allowed to override/// user preferences.////// This intentionally reads only user and session flag layers, including/// disabled layers, to match the skills config behavior. Project, managed, and/// plugin layers can discover hooks, but they do not get to write user hook/// state.pub fn hook_states_from_stack(    config_layer_stack: Option<&ConfigLayerStack>,) -> HashMap<String, HookStateToml> {    let Some(config_layer_stack) = config_layer_stack else {        return HashMap::new();    };    let mut states: HashMap<String, HookStateToml> = HashMap::new();    for layer in config_layer_stack.get_layers(        ConfigLayerStackOrdering::LowestPrecedenceFirst,        /*include_disabled*/ true,    ) {        if !matches!(            layer.name,            ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags        ) {            continue;        }        let Some(state_value) = layer            .config            .get("hooks")            .and_then(|hooks| hooks.get("state"))        else {            continue;        };        let TomlValue::Table(state_by_key) = state_value else {            continue;        };        for (key, state_value) in state_by_key {            let state: HookStateToml = match state_value.clone().try_into() {                Ok(state) => state,                Err(_) => {                    continue;                }            };            let key = key.trim();            if key.is_empty() {                continue;            }            // Later layers win field-by-field so a future per-hook state write            // does not accidentally erase an existing enablement override.            let effective_state = states.entry(key.to_string()).or_default();            if let Some(enabled) = state.enabled {                effective_state.enabled = Some(enabled);            }            if let Some(trusted_hash) = state.trusted_hash {                effective_state.trusted_hash = Some(trusted_hash);            }        }    }    states}#[cfg(test)]mod tests {    use codex_config::ConfigLayerEntry;    use codex_config::TomlValue;    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::*;    #[test]    fn hook_states_from_stack_respects_layer_precedence() {        let key = "file:/tmp/hooks.json:pre_tool_use:0:0";        let stack = ConfigLayerStack::new(            vec![                ConfigLayerEntry::new(                    ConfigLayerSource::User {                        file: test_path_buf("/tmp/config.toml").abs(),                        profile: None,                    },                    config_with_hook_override(key, Some(/*enabled*/ false)),                ),                ConfigLayerEntry::new(                    ConfigLayerSource::SessionFlags,                    config_with_hook_override(key, Some(/*enabled*/ true)),                ),            ],            Default::default(),            Default::default(),        )        .expect("config layer stack");        assert_eq!(            hook_states_from_stack(Some(&stack)),            HashMap::from([(                key.to_string(),                HookStateToml {                    enabled: Some(true),                    trusted_hash: None,                },            )])        );    }    #[test]    fn hook_states_from_stack_merges_fields_across_layers() {        let key = "file:/tmp/hooks.json:pre_tool_use:0:0";        let stack = ConfigLayerStack::new(            vec![                ConfigLayerEntry::new(                    ConfigLayerSource::User {                        file: test_path_buf("/tmp/config.toml").abs(),                        profile: None,                    },                    config_with_hook_state(                        key,                        HookStateToml {                            enabled: Some(/*enabled*/ false),                            trusted_hash: None,                        },                    ),                ),                ConfigLayerEntry::new(                    ConfigLayerSource::SessionFlags,                    config_with_hook_state(                        key,                        HookStateToml {                            enabled: None,                            trusted_hash: Some("sha256:trusted".to_string()),                        },                    ),                ),            ],            Default::default(),            Default::default(),        )        .expect("config layer stack");        assert_eq!(            hook_states_from_stack(Some(&stack)),            HashMap::from([(                key.to_string(),                HookStateToml {                    enabled: Some(false),                    trusted_hash: Some("sha256:trusted".to_string()),                },            )])        );    }    #[test]    fn hook_states_from_stack_ignores_malformed_hook_events() {        let key = "file:/tmp/hooks.json:pre_tool_use:0:0";        let config: TomlValue = serde_json::from_value(serde_json::json!({            "hooks": {                "state": {                    (key): {                        "enabled": false,                    },                },                "SessionStart": "not a matcher list",            },        }))        .expect("config TOML should deserialize");        let stack = ConfigLayerStack::new(            vec![ConfigLayerEntry::new(                ConfigLayerSource::User {                    file: test_path_buf("/tmp/config.toml").abs(),                    profile: None,                },                config,            )],            Default::default(),            Default::default(),        )        .expect("config layer stack");        assert_eq!(            hook_states_from_stack(Some(&stack)),            HashMap::from([(                key.to_string(),                HookStateToml {                    enabled: Some(false),                    trusted_hash: None,                },            )])        );    }    #[test]    fn hook_states_from_stack_ignores_malformed_state_entries() {        let key = "file:/tmp/hooks.json:pre_tool_use:0:0";        let config: TomlValue = serde_json::from_value(serde_json::json!({            "hooks": {                "state": {                    (key): {                        "enabled": false,                    },                    "malformed": {                        "enabled": "not a bool",                    },                },            },        }))        .expect("config TOML should deserialize");        let stack = ConfigLayerStack::new(            vec![ConfigLayerEntry::new(                ConfigLayerSource::User {                    file: test_path_buf("/tmp/config.toml").abs(),                    profile: None,                },                config,            )],            Default::default(),            Default::default(),        )        .expect("config layer stack");        assert_eq!(            hook_states_from_stack(Some(&stack)),            HashMap::from([(                key.to_string(),                HookStateToml {                    enabled: Some(false),                    trusted_hash: None,                },            )])        );    }    fn config_with_hook_override(key: &str, enabled: Option<bool>) -> TomlValue {        config_with_hook_state(            key,            HookStateToml {                enabled,                trusted_hash: None,            },        )    }    fn config_with_hook_state(key: &str, state: HookStateToml) -> TomlValue {        let hook_state = serde_json::to_value(state).expect("hook state should serialize");        serde_json::from_value(serde_json::json!({            "hooks": {                "state": {                    (key): hook_state,                },            },        }))        .expect("config TOML should deserialize")    }}