Skip to main content

codex_hooks/
config_rules.rs

1use std::collections::HashMap;
2
3use codex_config::ConfigLayerSource;
4use codex_config::ConfigLayerStack;
5use codex_config::ConfigLayerStackOrdering;
6use codex_config::HookStateToml;
7use codex_config::TomlValue;
8
9/// Build effective hook state from config layers that are allowed to override
10/// user preferences.
11///
12/// This intentionally reads only user and session flag layers, including
13/// disabled layers, to match the skills config behavior. Project, managed, and
14/// plugin layers can discover hooks, but they do not get to write user hook
15/// state.
16pub fn hook_states_from_stack(
17    config_layer_stack: Option<&ConfigLayerStack>,
18) -> HashMap<String, HookStateToml> {
19    let Some(config_layer_stack) = config_layer_stack else {
20        return HashMap::new();
21    };
22
23    let mut states: HashMap<String, HookStateToml> = HashMap::new();
24    for layer in config_layer_stack.get_layers(
25        ConfigLayerStackOrdering::LowestPrecedenceFirst,
26        /*include_disabled*/ true,
27    ) {
28        if !matches!(
29            layer.name,
30            ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
31        ) {
32            continue;
33        }
34
35        let Some(state_value) = layer
36            .config
37            .get("hooks")
38            .and_then(|hooks| hooks.get("state"))
39        else {
40            continue;
41        };
42        let TomlValue::Table(state_by_key) = state_value else {
43            continue;
44        };
45
46        for (key, state_value) in state_by_key {
47            let state: HookStateToml = match state_value.clone().try_into() {
48                Ok(state) => state,
49                Err(_) => {
50                    continue;
51                }
52            };
53            let key = key.trim();
54            if key.is_empty() {
55                continue;
56            }
57            // Later layers win field-by-field so a future per-hook state write
58            // does not accidentally erase an existing enablement override.
59            let effective_state = states.entry(key.to_string()).or_default();
60            if let Some(enabled) = state.enabled {
61                effective_state.enabled = Some(enabled);
62            }
63            if let Some(trusted_hash) = state.trusted_hash {
64                effective_state.trusted_hash = Some(trusted_hash);
65            }
66        }
67    }
68
69    states
70}
71
72#[cfg(test)]
73mod tests {
74    use codex_config::ConfigLayerEntry;
75    use codex_config::TomlValue;
76    use codex_utils_absolute_path::test_support::PathBufExt;
77    use codex_utils_absolute_path::test_support::test_path_buf;
78    use pretty_assertions::assert_eq;
79
80    use super::*;
81
82    #[test]
83    fn hook_states_from_stack_respects_layer_precedence() {
84        let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
85        let stack = ConfigLayerStack::new(
86            vec![
87                ConfigLayerEntry::new(
88                    ConfigLayerSource::User {
89                        file: test_path_buf("/tmp/config.toml").abs(),
90                        profile: None,
91                    },
92                    config_with_hook_override(key, Some(/*enabled*/ false)),
93                ),
94                ConfigLayerEntry::new(
95                    ConfigLayerSource::SessionFlags,
96                    config_with_hook_override(key, Some(/*enabled*/ true)),
97                ),
98            ],
99            Default::default(),
100            Default::default(),
101        )
102        .expect("config layer stack");
103
104        assert_eq!(
105            hook_states_from_stack(Some(&stack)),
106            HashMap::from([(
107                key.to_string(),
108                HookStateToml {
109                    enabled: Some(true),
110                    trusted_hash: None,
111                },
112            )])
113        );
114    }
115
116    #[test]
117    fn hook_states_from_stack_merges_fields_across_layers() {
118        let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
119        let stack = ConfigLayerStack::new(
120            vec![
121                ConfigLayerEntry::new(
122                    ConfigLayerSource::User {
123                        file: test_path_buf("/tmp/config.toml").abs(),
124                        profile: None,
125                    },
126                    config_with_hook_state(
127                        key,
128                        HookStateToml {
129                            enabled: Some(/*enabled*/ false),
130                            trusted_hash: None,
131                        },
132                    ),
133                ),
134                ConfigLayerEntry::new(
135                    ConfigLayerSource::SessionFlags,
136                    config_with_hook_state(
137                        key,
138                        HookStateToml {
139                            enabled: None,
140                            trusted_hash: Some("sha256:trusted".to_string()),
141                        },
142                    ),
143                ),
144            ],
145            Default::default(),
146            Default::default(),
147        )
148        .expect("config layer stack");
149
150        assert_eq!(
151            hook_states_from_stack(Some(&stack)),
152            HashMap::from([(
153                key.to_string(),
154                HookStateToml {
155                    enabled: Some(false),
156                    trusted_hash: Some("sha256:trusted".to_string()),
157                },
158            )])
159        );
160    }
161
162    #[test]
163    fn hook_states_from_stack_ignores_malformed_hook_events() {
164        let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
165        let config: TomlValue = serde_json::from_value(serde_json::json!({
166            "hooks": {
167                "state": {
168                    (key): {
169                        "enabled": false,
170                    },
171                },
172                "SessionStart": "not a matcher list",
173            },
174        }))
175        .expect("config TOML should deserialize");
176        let stack = ConfigLayerStack::new(
177            vec![ConfigLayerEntry::new(
178                ConfigLayerSource::User {
179                    file: test_path_buf("/tmp/config.toml").abs(),
180                    profile: None,
181                },
182                config,
183            )],
184            Default::default(),
185            Default::default(),
186        )
187        .expect("config layer stack");
188
189        assert_eq!(
190            hook_states_from_stack(Some(&stack)),
191            HashMap::from([(
192                key.to_string(),
193                HookStateToml {
194                    enabled: Some(false),
195                    trusted_hash: None,
196                },
197            )])
198        );
199    }
200
201    #[test]
202    fn hook_states_from_stack_ignores_malformed_state_entries() {
203        let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
204        let config: TomlValue = serde_json::from_value(serde_json::json!({
205            "hooks": {
206                "state": {
207                    (key): {
208                        "enabled": false,
209                    },
210                    "malformed": {
211                        "enabled": "not a bool",
212                    },
213                },
214            },
215        }))
216        .expect("config TOML should deserialize");
217        let stack = ConfigLayerStack::new(
218            vec![ConfigLayerEntry::new(
219                ConfigLayerSource::User {
220                    file: test_path_buf("/tmp/config.toml").abs(),
221                    profile: None,
222                },
223                config,
224            )],
225            Default::default(),
226            Default::default(),
227        )
228        .expect("config layer stack");
229
230        assert_eq!(
231            hook_states_from_stack(Some(&stack)),
232            HashMap::from([(
233                key.to_string(),
234                HookStateToml {
235                    enabled: Some(false),
236                    trusted_hash: None,
237                },
238            )])
239        );
240    }
241
242    fn config_with_hook_override(key: &str, enabled: Option<bool>) -> TomlValue {
243        config_with_hook_state(
244            key,
245            HookStateToml {
246                enabled,
247                trusted_hash: None,
248            },
249        )
250    }
251
252    fn config_with_hook_state(key: &str, state: HookStateToml) -> TomlValue {
253        let hook_state = serde_json::to_value(state).expect("hook state should serialize");
254        serde_json::from_value(serde_json::json!({
255            "hooks": {
256                "state": {
257                    (key): hook_state,
258                },
259            },
260        }))
261        .expect("config TOML should deserialize")
262    }
263}