Add persisted hook enablement state (#19840)

## Why

After `hooks/list` exposes the hook inventory, clients need a way to
persist user hook preferences, make those changes effective in
already-open sessions, and distinguish user-controllable hooks from
managed requirements without adding another bespoke app-server write
API.

## What

- Extends `hooks/list` entries with effective `enabled` state.
- Persists user-level hook state under `hooks.state.<hook-id>` so the
model can grow beyond a single boolean over time.
- Uses the existing `config/batchWrite` path for hook state updates
instead of introducing a dedicated hook write RPC.
- Refreshes live session hook engines after config writes so
already-open threads observe updated enablement without a restart.

## Stack

1. openai/codex#19705
2. openai/codex#19778
3. This PR - openai/codex#19840
4. openai/codex#19882

## Reviewer Notes

The generated schema files account for much of the raw diff. The core
behavior is in:

- `hooks/src/config_rules.rs`, which resolves per-hook user state from
the config layer stack.
- `hooks/src/engine/discovery.rs`, which projects effective enablement
into `hooks/list` from source-derived managedness.
- `config/src/hook_config.rs`, which defines the new `hooks.state`
representation.
- `core/src/session/mod.rs`, which rebuilds live hook state after user
config reloads.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-30 04:46:32 +00:00
committed by GitHub
co-authored by Codex
parent ac4332c05b
commit 8f3c06cc97
39 changed files with 1212 additions and 181 deletions
+126 -34
View File
@@ -16,9 +16,11 @@ use codex_plugin::PluginHookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use std::collections::HashMap;
use std::collections::HashSet;
use super::ConfiguredHandler;
use super::HookListEntry;
use crate::config_rules::disabled_hook_keys_from_stack;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
use codex_protocol::protocol::HookHandlerType;
@@ -30,11 +32,11 @@ pub(crate) struct DiscoveryResult {
pub warnings: Vec<String>,
}
#[derive(Clone)]
struct HookHandlerSource<'a> {
path: &'a AbsolutePathBuf,
is_managed: bool,
key_source: String,
source: HookSource,
disabled_hook_keys: &'a HashSet<String>,
env: HashMap<String, String>,
plugin_id: Option<String>,
}
@@ -48,6 +50,7 @@ pub(crate) fn discover_handlers(
let mut hook_entries = Vec::new();
let mut warnings = plugin_hook_load_warnings;
let mut display_order = 0_i64;
let disabled_hook_keys = disabled_hook_keys_from_stack(config_layer_stack);
if let Some(config_layer_stack) = config_layer_stack {
append_managed_requirement_handlers(
@@ -56,6 +59,7 @@ pub(crate) fn discover_handlers(
&mut warnings,
&mut display_order,
config_layer_stack,
&disabled_hook_keys,
);
for layer in config_layer_stack.get_layers(
@@ -86,8 +90,9 @@ pub(crate) fn discover_handlers(
&mut display_order,
HookHandlerSource {
path: &source_path,
is_managed: false,
key_source: source_path.display().to_string(),
source: hook_source,
disabled_hook_keys: &disabled_hook_keys,
env: HashMap::new(),
plugin_id: None,
},
@@ -103,6 +108,7 @@ pub(crate) fn discover_handlers(
&mut warnings,
&mut display_order,
plugin_hook_sources,
&disabled_hook_keys,
);
DiscoveryResult {
@@ -118,6 +124,7 @@ fn append_managed_requirement_handlers(
warnings: &mut Vec<String>,
display_order: &mut i64,
config_layer_stack: &ConfigLayerStack,
disabled_hook_keys: &HashSet<String>,
) {
let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else {
return;
@@ -134,8 +141,9 @@ fn append_managed_requirement_handlers(
display_order,
HookHandlerSource {
path: &source_path,
is_managed: true,
key_source: source_path.display().to_string(),
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
disabled_hook_keys,
env: HashMap::new(),
plugin_id: None,
},
@@ -149,6 +157,7 @@ fn append_plugin_hook_sources(
warnings: &mut Vec<String>,
display_order: &mut i64,
plugin_hook_sources: Vec<PluginHookSource>,
disabled_hook_keys: &HashSet<String>,
) {
// TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable.
for source in plugin_hook_sources {
@@ -157,8 +166,8 @@ fn append_plugin_hook_sources(
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();
@@ -177,8 +186,9 @@ fn append_plugin_hook_sources(
display_order,
HookHandlerSource {
path: &source_path,
is_managed: false,
key_source: format!("{plugin_id}:{source_relative_path}"),
source: HookSource::Plugin,
disabled_hook_keys,
env,
plugin_id: Some(plugin_id),
},
@@ -337,7 +347,7 @@ fn append_hook_events(
hook_entries,
warnings,
display_order,
source.clone(),
&source,
event_name,
groups,
);
@@ -349,11 +359,11 @@ fn append_matcher_groups(
hook_entries: &mut Vec<HookListEntry>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source: HookHandlerSource<'_>,
source: &HookHandlerSource<'_>,
event_name: codex_protocol::protocol::HookEventName,
groups: Vec<MatcherGroup>,
) {
for group in groups {
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)
@@ -364,8 +374,7 @@ fn append_matcher_groups(
));
continue;
}
for handler in group.hooks {
for (handler_index, handler) in group.hooks.into_iter().enumerate() {
match handler {
HookHandlerConfig::Command {
command,
@@ -391,7 +400,18 @@ fn append_matcher_groups(
command.replace(&format!("${{{key}}}"), value)
});
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
// TODO(abhinav): replace this positional suffix with a durable hook id.
let key = format!(
"{}:{}:{}:{}",
source.key_source,
hook_event_key_label(event_name),
group_index,
handler_index
);
let enabled =
source.source.is_managed() || !source.disabled_hook_keys.contains(&key);
hook_entries.push(HookListEntry {
key,
event_name,
handler_type: HookHandlerType::Command,
matcher: matcher.map(ToOwned::to_owned),
@@ -402,19 +422,22 @@ fn append_matcher_groups(
source: source.source,
plugin_id: source.plugin_id.clone(),
display_order: *display_order,
enabled,
is_managed: source.source.is_managed(),
});
handlers.push(ConfiguredHandler {
event_name,
is_managed: source.is_managed,
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(),
});
if enabled {
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!(
@@ -430,6 +453,17 @@ fn append_matcher_groups(
}
}
fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use",
codex_protocol::protocol::HookEventName::PermissionRequest => "permission_request",
codex_protocol::protocol::HookEventName::PostToolUse => "post_tool_use",
codex_protocol::protocol::HookEventName::SessionStart => "session_start",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user_prompt_submit",
codex_protocol::protocol::HookEventName::Stop => "stop",
}
}
fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource {
match source {
ConfigLayerSource::System { .. } => HookSource::System,
@@ -454,15 +488,16 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
HookSource::LegacyManagedConfigMdm
}
Some(RequirementSource::CloudRequirements | RequirementSource::Unknown) | None => {
HookSource::Unknown
}
Some(RequirementSource::CloudRequirements) => 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_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -474,6 +509,7 @@ mod tests {
use super::append_matcher_groups;
use codex_config::HookHandlerConfig;
use codex_config::MatcherGroup;
use codex_config::TomlValue;
fn source_path() -> AbsolutePathBuf {
test_path_buf("/tmp/hooks.json").abs()
@@ -483,11 +519,15 @@ mod tests {
HookSource::User
}
fn hook_handler_source(path: &AbsolutePathBuf) -> super::HookHandlerSource<'_> {
fn hook_handler_source<'a>(
path: &'a AbsolutePathBuf,
disabled_hook_keys: &'a std::collections::HashSet<String>,
) -> super::HookHandlerSource<'a> {
super::HookHandlerSource {
path,
is_managed: false,
key_source: path.display().to_string(),
source: hook_source(),
disabled_hook_keys,
env: std::collections::HashMap::new(),
plugin_id: None,
}
@@ -511,13 +551,14 @@ mod tests {
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
let disabled_hook_keys = std::collections::HashSet::new();
append_matcher_groups(
&mut handlers,
&mut Vec::new(),
&mut warnings,
&mut display_order,
hook_handler_source(&source_path),
&hook_handler_source(&source_path, &disabled_hook_keys),
HookEventName::UserPromptSubmit,
vec![command_group(Some("["))],
);
@@ -527,7 +568,6 @@ mod tests {
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
is_managed: false,
matcher: None,
command: "echo hello".to_string(),
timeout_sec: 600,
@@ -546,13 +586,14 @@ mod tests {
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
let disabled_hook_keys = std::collections::HashSet::new();
append_matcher_groups(
&mut handlers,
&mut Vec::new(),
&mut warnings,
&mut display_order,
hook_handler_source(&source_path),
&hook_handler_source(&source_path, &disabled_hook_keys),
HookEventName::PreToolUse,
vec![command_group(Some("^Bash$"))],
);
@@ -562,7 +603,6 @@ mod tests {
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::PreToolUse,
is_managed: false,
matcher: Some("^Bash$".to_string()),
command: "echo hello".to_string(),
timeout_sec: 600,
@@ -581,13 +621,14 @@ mod tests {
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
let disabled_hook_keys = std::collections::HashSet::new();
append_matcher_groups(
&mut handlers,
&mut Vec::new(),
&mut warnings,
&mut display_order,
hook_handler_source(&source_path),
&hook_handler_source(&source_path, &disabled_hook_keys),
HookEventName::PreToolUse,
vec![command_group(Some("*"))],
);
@@ -603,13 +644,14 @@ mod tests {
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
let disabled_hook_keys = std::collections::HashSet::new();
append_matcher_groups(
&mut handlers,
&mut Vec::new(),
&mut warnings,
&mut display_order,
hook_handler_source(&source_path),
&hook_handler_source(&source_path, &disabled_hook_keys),
HookEventName::PostToolUse,
vec![command_group(Some("Edit|Write"))],
);
@@ -620,6 +662,56 @@ mod tests {
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(),
},
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(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
}
);
}
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_source_for_config_layer_source_discards_source_details() {
let config_file = test_path_buf("/tmp/.codex/config.toml").abs();
-1
View File
@@ -156,7 +156,6 @@ mod tests {
) -> ConfiguredHandler {
ConfiguredHandler {
event_name,
is_managed: false,
matcher: matcher.map(str::to_owned),
command: command.to_string(),
timeout_sec: 5,
+3 -1
View File
@@ -36,7 +36,6 @@ pub(crate) struct CommandShell {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConfiguredHandler {
pub event_name: codex_protocol::protocol::HookEventName,
pub is_managed: bool,
pub matcher: Option<String>,
pub command: String,
pub timeout_sec: u64,
@@ -71,6 +70,7 @@ impl ConfiguredHandler {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookListEntry {
pub key: String,
pub event_name: HookEventName,
pub handler_type: HookHandlerType,
pub matcher: Option<String>,
@@ -81,6 +81,8 @@ pub struct HookListEntry {
pub source: HookSource,
pub plugin_id: Option<String>,
pub display_order: i64,
pub enabled: bool,
pub is_managed: bool,
}
#[derive(Clone)]
+178 -2
View File
@@ -121,7 +121,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
assert!(engine.warnings().is_empty());
assert_eq!(engine.handlers.len(), 1);
assert!(engine.handlers[0].is_managed);
assert!(engine.handlers[0].source.is_managed());
let cwd = cwd();
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
@@ -158,6 +158,177 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));
}
#[test]
fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
let temp = tempdir().expect("create temp dir");
let managed_dir =
AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path");
fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir");
let managed_hooks = managed_hooks_for_current_platform(
managed_dir.clone(),
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/managed.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
);
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path");
let managed_disabled_key = format!("{}:pre_tool_use:0:0", managed_dir.display());
let user_disabled_key = format!("{}:pre_tool_use:0:0", config_path.display());
let user_config = config_with_pre_tool_use_hook_and_states(
"python3 /tmp/user.py",
[&managed_disabled_key, &user_disabled_key],
);
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: config_path },
user_config,
)],
ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(managed_hooks.clone()),
Some(RequirementSource::CloudRequirements),
)),
..ConfigRequirements::default()
},
ConfigRequirementsToml {
hooks: Some(managed_hooks),
..ConfigRequirementsToml::default()
},
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert_eq!(engine.handlers.len(), 1);
assert!(engine.handlers[0].source.is_managed());
let discovered =
super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new());
assert_eq!(discovered.hook_entries.len(), 2);
assert_eq!(discovered.hook_entries[0].key, managed_disabled_key);
assert_eq!(discovered.hook_entries[0].enabled, true);
assert!(discovered.hook_entries[0].is_managed);
assert_eq!(discovered.hook_entries[1].key, user_disabled_key);
assert_eq!(discovered.hook_entries[1].enabled, false);
assert!(!discovered.hook_entries[1].is_managed);
}
#[test]
fn user_disablement_does_not_filter_managed_layer_hooks() {
let temp = tempdir().expect("create temp dir");
let managed_config_path =
AbsolutePathBuf::try_from(temp.path().join("managed_config.toml")).expect("absolute path");
let user_config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path");
let managed_key = format!("{}:pre_tool_use:0:0", managed_config_path.display());
let config_layer_stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_config_path,
},
config_with_hook_state(&managed_key, /*enabled*/ false),
),
ConfigLayerEntry::new(
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_config_path,
},
config_with_pre_tool_use_hook("python3 /tmp/managed-layer.py"),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert_eq!(engine.handlers.len(), 1);
assert!(engine.handlers[0].source.is_managed());
let discovered =
super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new());
assert_eq!(discovered.hook_entries.len(), 1);
assert_eq!(discovered.hook_entries[0].key, managed_key);
assert_eq!(discovered.hook_entries[0].enabled, true);
assert!(discovered.hook_entries[0].is_managed);
}
fn config_with_hook_state(key: &str, enabled: bool) -> TomlValue {
serde_json::from_value(serde_json::json!({
"hooks": {
"state": {
(key): {
"enabled": enabled,
},
},
},
}))
.expect("config TOML should deserialize")
}
fn config_with_pre_tool_use_hook_and_states<const N: usize>(
command: &str,
disabled_keys: [&str; N],
) -> TomlValue {
let state = disabled_keys
.into_iter()
.map(|key| (key.to_string(), serde_json::json!({ "enabled": false })))
.collect::<serde_json::Map<_, _>>();
serde_json::from_value(serde_json::json!({
"hooks": {
"state": state,
"PreToolUse": [{
"hooks": [{
"type": "command",
"command": command,
}],
}],
},
}))
.expect("config TOML should deserialize")
}
fn config_with_pre_tool_use_hook(command: &str) -> TomlValue {
serde_json::from_value(serde_json::json!({
"hooks": {
"PreToolUse": [{
"hooks": [{
"type": "command",
"command": command,
}],
}],
},
}))
.expect("config TOML should deserialize")
}
#[test]
fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
let temp = tempdir().expect("create temp dir");
@@ -333,7 +504,12 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
tool_input: serde_json::json!({ "command": "echo hello" }),
});
assert_eq!(preview.len(), 2);
assert!(engine.handlers.iter().all(|handler| !handler.is_managed));
assert!(
engine
.handlers
.iter()
.all(|handler| !handler.source.is_managed())
);
assert_eq!(preview[0].source_path, hooks_json_path);
assert_eq!(preview[1].source_path, config_path);
}