diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index cda2bbe61d..5e1100dcc0 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -32,10 +32,11 @@ use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; use codex_chatgpt::connectors; use codex_config::ConfigRequirementsToml; -use codex_config::HookEventsToml; use codex_config::HookHandlerConfig as CoreHookHandlerConfig; +use codex_config::ManagedHookEventsToml; +use codex_config::ManagedHookHandlerConfig as CoreManagedHookHandlerConfig; use codex_config::ManagedHooksRequirementsToml; -use codex_config::MatcherGroup as CoreMatcherGroup; +use codex_config::ManagedMatcherGroup as CoreManagedMatcherGroup; use codex_config::ResidencyRequirement as CoreResidencyRequirement; use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_core::ThreadManager; @@ -465,7 +466,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed windows_managed_dir, hooks, } = hooks; - let HookEventsToml { + let ManagedHookEventsToml { pre_tool_use, permission_request, post_tool_use, @@ -479,39 +480,41 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed ManagedHooksRequirements { managed_dir, windows_managed_dir, - pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use), - permission_request: map_hook_matcher_groups_to_api(permission_request), - post_tool_use: map_hook_matcher_groups_to_api(post_tool_use), - pre_compact: map_hook_matcher_groups_to_api(pre_compact), - post_compact: map_hook_matcher_groups_to_api(post_compact), - session_start: map_hook_matcher_groups_to_api(session_start), - user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), - stop: map_hook_matcher_groups_to_api(stop), + pre_tool_use: map_managed_hook_matcher_groups_to_api(pre_tool_use), + permission_request: map_managed_hook_matcher_groups_to_api(permission_request), + post_tool_use: map_managed_hook_matcher_groups_to_api(post_tool_use), + pre_compact: map_managed_hook_matcher_groups_to_api(pre_compact), + post_compact: map_managed_hook_matcher_groups_to_api(post_compact), + session_start: map_managed_hook_matcher_groups_to_api(session_start), + user_prompt_submit: map_managed_hook_matcher_groups_to_api(user_prompt_submit), + stop: map_managed_hook_matcher_groups_to_api(stop), } } -fn map_hook_matcher_groups_to_api( - groups: Vec, +fn map_managed_hook_matcher_groups_to_api( + groups: Vec, ) -> Vec { groups .into_iter() - .map(map_hook_matcher_group_to_api) + .map(map_managed_hook_matcher_group_to_api) .collect() } -fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup { +fn map_managed_hook_matcher_group_to_api( + group: CoreManagedMatcherGroup, +) -> ConfiguredHookMatcherGroup { ConfiguredHookMatcherGroup { matcher: group.matcher, hooks: group .hooks .into_iter() - .map(map_hook_handler_to_api) + .map(map_managed_hook_handler_to_api) .collect(), } } -fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler { - match handler { +fn map_managed_hook_handler_to_api(handler: CoreManagedHookHandlerConfig) -> ConfiguredHookHandler { + match handler.handler { CoreHookHandlerConfig::Command { command, timeout_sec, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 59d1cde9ea..c7a704ffe5 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1193,7 +1193,6 @@ pub fn sandbox_mode_requirement_for_permission_profile( #[cfg(test)] mod tests { use super::*; - use crate::HookEventsToml; use anyhow::Result; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; @@ -2443,7 +2442,7 @@ command = "python3 /enterprise/hooks/pre.py" .set(ManagedHooksRequirementsToml { managed_dir: Some(std::path::PathBuf::from("/other/hooks")), windows_managed_dir: None, - hooks: HookEventsToml::default(), + hooks: crate::ManagedHookEventsToml::default(), }) .expect_err("managed hooks should reject drift"); diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 630d18c569..0305539158 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -144,7 +144,7 @@ pub struct ManagedHooksRequirementsToml { pub managed_dir: Option, pub windows_managed_dir: Option, #[serde(flatten)] - pub hooks: HookEventsToml, + pub hooks: ManagedHookEventsToml, } impl ManagedHooksRequirementsToml { @@ -174,6 +174,105 @@ impl ManagedHooksRequirementsToml { } } +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedHookEventsToml { + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest", default)] + pub permission_request: Vec, + #[serde(rename = "PostToolUse", default)] + pub post_tool_use: Vec, + #[serde(rename = "PreCompact", default)] + pub pre_compact: Vec, + #[serde(rename = "PostCompact", default)] + pub post_compact: Vec, + #[serde(rename = "SessionStart", default)] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit", default)] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop", default)] + pub stop: Vec, +} + +impl ManagedHookEventsToml { + pub fn is_empty(&self) -> bool { + let Self { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + stop, + } = self; + pre_tool_use.is_empty() + && permission_request.is_empty() + && post_tool_use.is_empty() + && pre_compact.is_empty() + && post_compact.is_empty() + && session_start.is_empty() + && user_prompt_submit.is_empty() + && stop.is_empty() + } + + pub fn handler_count(&self) -> usize { + let Self { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + stop, + } = self; + [ + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + stop, + ] + .into_iter() + .flatten() + .map(|group| group.hooks.len()) + .sum() + } + + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 8] { + [ + (HookEventName::PreToolUse, self.pre_tool_use), + (HookEventName::PermissionRequest, self.permission_request), + (HookEventName::PostToolUse, self.post_tool_use), + (HookEventName::PreCompact, self.pre_compact), + (HookEventName::PostCompact, self.post_compact), + (HookEventName::SessionStart, self.session_start), + (HookEventName::UserPromptSubmit, self.user_prompt_submit), + (HookEventName::Stop, self.stop), + ] + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedMatcherGroup { + #[serde(default)] + pub matcher: Option, + #[serde(default)] + pub hooks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedHookHandlerConfig { + #[serde(flatten)] + pub handler: HookHandlerConfig, + #[serde(default)] + pub suppress: bool, +} + #[cfg(test)] #[path = "hooks_tests.rs"] mod tests; diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 69fcd3fe95..0554f19fec 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -6,7 +6,10 @@ use super::HookEventsToml; use super::HookHandlerConfig; use super::HooksFile; use super::HooksToml; +use super::ManagedHookEventsToml; +use super::ManagedHookHandlerConfig; use super::ManagedHooksRequirementsToml; +use super::ManagedMatcherGroup; use super::MatcherGroup; #[test] @@ -149,14 +152,57 @@ command = "python3 /enterprise/place/pre.py" ManagedHooksRequirementsToml { managed_dir: Some(std::path::PathBuf::from("/enterprise/place")), windows_managed_dir: None, - hooks: HookEventsToml { - pre_tool_use: vec![MatcherGroup { + hooks: ManagedHookEventsToml { + pre_tool_use: vec![ManagedMatcherGroup { matcher: Some("^Bash$".to_string()), - hooks: vec![HookHandlerConfig::Command { - command: "python3 /enterprise/place/pre.py".to_string(), - timeout_sec: None, - r#async: false, - status_message: None, + hooks: vec![ManagedHookHandlerConfig { + handler: HookHandlerConfig::Command { + command: "python3 /enterprise/place/pre.py".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }, + suppress: false, + }], + }], + ..Default::default() + }, + } + ); +} + +#[test] +fn managed_hooks_requirements_support_suppress() { + let parsed: ManagedHooksRequirementsToml = toml::from_str( + r#" +managed_dir = "/enterprise/place" + +[[UserPromptSubmit]] + +[[UserPromptSubmit.hooks]] +type = "command" +command = "python3 /enterprise/place/prompt.py" +suppress = true +"#, + ) + .expect("requirements hooks TOML should deserialize"); + + assert_eq!( + parsed, + ManagedHooksRequirementsToml { + managed_dir: Some(std::path::PathBuf::from("/enterprise/place")), + windows_managed_dir: None, + hooks: ManagedHookEventsToml { + user_prompt_submit: vec![ManagedMatcherGroup { + matcher: None, + hooks: vec![ManagedHookHandlerConfig { + handler: HookHandlerConfig::Command { + command: "python3 /enterprise/place/prompt.py".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }, + suppress: true, }], }], ..Default::default() diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e88c736db0..b2883b03a3 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -77,7 +77,10 @@ pub use hook_config::HookHandlerConfig; pub use hook_config::HookStateToml; pub use hook_config::HooksFile; pub use hook_config::HooksToml; +pub use hook_config::ManagedHookEventsToml; +pub use hook_config::ManagedHookHandlerConfig; pub use hook_config::ManagedHooksRequirementsToml; +pub use hook_config::ManagedMatcherGroup; pub use hook_config::MatcherGroup; pub use host_name::host_name; pub use marketplace_edit::MarketplaceConfigUpdate; diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 6fcd5f872d..05bacc8626 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1152,14 +1152,17 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result hooks: Some(codex_config::ManagedHooksRequirementsToml { managed_dir: Some(managed_dir.clone()), windows_managed_dir: None, - hooks: codex_config::HookEventsToml { - pre_tool_use: vec![codex_config::MatcherGroup { + hooks: codex_config::ManagedHookEventsToml { + pre_tool_use: vec![codex_config::ManagedMatcherGroup { matcher: Some("^Bash$".to_string()), - hooks: vec![codex_config::HookHandlerConfig::Command { - command: format!("python3 {}/pre.py", managed_dir.display()), - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), + hooks: vec![codex_config::ManagedHookHandlerConfig { + handler: codex_config::HookHandlerConfig::Command { + command: format!("python3 {}/pre.py", managed_dir.display()), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }, + suppress: false, }], }], ..Default::default() diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 2bea729339..60e6b35007 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -453,6 +453,9 @@ async fn emit_hook_started_events( preview_runs: Vec, ) { for run in preview_runs { + if !should_emit_hook_notification(&run) { + continue; + } sess.send_event( turn_context, EventMsg::HookStarted(HookStartedEvent { @@ -472,11 +475,17 @@ pub(crate) async fn emit_hook_completed_events( for completed in completed_events { emit_hook_completed_metrics(turn_context, &completed); track_hook_completed_analytics(sess, turn_context, &completed); - sess.send_event(turn_context, EventMsg::HookCompleted(completed)) - .await; + if should_emit_hook_notification(&completed.run) { + sess.send_event(turn_context, EventMsg::HookCompleted(completed)) + .await; + } } } +fn should_emit_hook_notification(run: &HookRunSummary) -> bool { + !run.suppress_notifications +} + fn emit_hook_completed_metrics(turn_context: &TurnContext, completed: &HookCompletedEvent) { let tags = hook_run_metric_tags(&completed.run); turn_context @@ -597,6 +606,7 @@ mod tests { use super::additional_context_messages; use super::hook_run_analytics_payload; use super::hook_run_metric_tags; + use super::should_emit_hook_notification; use crate::session::tests::make_session_and_context; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookRunSummary; @@ -712,6 +722,17 @@ mod tests { ); } + #[test] + fn suppressed_hook_runs_do_not_emit_notifications() { + let mut run = sample_hook_run(HookRunStatus::Completed, HookSource::CloudRequirements); + + assert!(should_emit_hook_notification(&run)); + + run.suppress_notifications = true; + + assert!(!should_emit_hook_notification(&run)); + } + fn sample_hook_run(status: HookRunStatus, source: HookSource) -> HookRunSummary { HookRunSummary { id: "stop:0:/tmp/hooks.json".to_string(), @@ -728,6 +749,7 @@ mod tests { completed_at: Some(37), duration_ms: Some(27), entries: Vec::new(), + suppress_notifications: false, } } } diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index cc180325b6..cf980a2e54 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -10,7 +10,9 @@ use codex_config::HookEventsToml; use codex_config::HookHandlerConfig; use codex_config::HookStateToml; use codex_config::HooksFile; +use codex_config::ManagedHookEventsToml; use codex_config::ManagedHooksRequirementsToml; +use codex_config::ManagedMatcherGroup; use codex_config::MatcherGroup; use codex_config::RequirementSource; use codex_config::TomlValue; @@ -36,12 +38,15 @@ pub(crate) struct DiscoveryResult { pub warnings: Vec, } +type HookHandlerPosition = (codex_protocol::protocol::HookEventName, usize, usize); + struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, key_source: String, source: HookSource, is_managed: bool, hook_states: &'a HashMap, + suppressed_handler_positions: Vec, env: HashMap, plugin_id: Option, } @@ -99,6 +104,7 @@ pub(crate) fn discover_handlers( source: hook_source, is_managed, hook_states: &hook_states, + suppressed_handler_positions: Vec::new(), env: HashMap::new(), plugin_id: None, }, @@ -140,6 +146,8 @@ fn append_managed_requirement_handlers( else { return; }; + let (hook_events, suppressed_handler_positions) = + normalize_managed_hook_events(managed_hooks.get().hooks.clone()); append_hook_events( handlers, hook_entries, @@ -151,10 +159,11 @@ fn append_managed_requirement_handlers( source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), is_managed: true, hook_states, + suppressed_handler_positions, env: HashMap::new(), plugin_id: None, }, - managed_hooks.get().hooks.clone(), + hook_events, ); } @@ -199,6 +208,7 @@ fn append_plugin_hook_sources( source: HookSource::Plugin, is_managed: false, hook_states, + suppressed_handler_positions: Vec::new(), env, plugin_id: Some(plugin_id), }, @@ -272,7 +282,23 @@ fn load_hooks_json( } }; - let parsed: HooksFile = match serde_json::from_str(&contents) { + let raw_json: serde_json::Value = 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; + } + }; + if raw_json + .get("hooks") + .is_some_and(json_value_contains_suppress) + { + warnings.push(unsupported_suppress_warning(source_path.as_path())); + } + let parsed: HooksFile = match serde_json::from_value(raw_json) { Ok(parsed) => parsed, Err(err) => { warnings.push(format!( @@ -301,6 +327,9 @@ fn load_toml_hooks_from_layer( ) -> Option<(AbsolutePathBuf, HookEventsToml)> { let source_path = config_toml_source_path(layer); let hook_value = layer.config.get("hooks")?.clone(); + if toml_value_contains_suppress(&hook_value) { + warnings.push(unsupported_suppress_warning(source_path.as_path())); + } let parsed = match HookEventsToml::deserialize(hook_value) { Ok(parsed) => parsed, Err(err) => { @@ -315,6 +344,33 @@ fn load_toml_hooks_from_layer( (!parsed.is_empty()).then_some((source_path, parsed)) } +fn unsupported_suppress_warning(source_path: &Path) -> String { + format!( + "ignoring unsupported `suppress` in hook config {}; `suppress` is only supported for managed hooks in requirements", + source_path.display() + ) +} + +fn json_value_contains_suppress(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Array(values) => values.iter().any(json_value_contains_suppress), + serde_json::Value::Object(values) => { + values.contains_key("suppress") || values.values().any(json_value_contains_suppress) + } + _ => false, + } +} + +fn toml_value_contains_suppress(value: &TomlValue) -> bool { + match value { + TomlValue::Array(values) => values.iter().any(toml_value_contains_suppress), + TomlValue::Table(values) => { + values.contains_key("suppress") || values.values().any(toml_value_contains_suppress) + } + _ => false, + } +} + fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf { match &layer.name { ConfigLayerSource::System { file } @@ -364,6 +420,65 @@ fn append_hook_events( } } +fn normalize_managed_hook_events( + hook_events: ManagedHookEventsToml, +) -> (HookEventsToml, Vec) { + let mut normalized = HookEventsToml::default(); + let mut suppressed_handler_positions = Vec::new(); + + for (event_name, groups) in hook_events.into_matcher_groups() { + let groups = + normalize_managed_matcher_groups(event_name, groups, &mut suppressed_handler_positions); + match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => normalized.pre_tool_use = groups, + codex_protocol::protocol::HookEventName::PermissionRequest => { + normalized.permission_request = groups + } + codex_protocol::protocol::HookEventName::PostToolUse => { + normalized.post_tool_use = groups + } + codex_protocol::protocol::HookEventName::PreCompact => normalized.pre_compact = groups, + codex_protocol::protocol::HookEventName::PostCompact => { + normalized.post_compact = groups + } + codex_protocol::protocol::HookEventName::SessionStart => { + normalized.session_start = groups + } + codex_protocol::protocol::HookEventName::UserPromptSubmit => { + normalized.user_prompt_submit = groups + } + codex_protocol::protocol::HookEventName::Stop => normalized.stop = groups, + } + } + + (normalized, suppressed_handler_positions) +} + +fn normalize_managed_matcher_groups( + event_name: codex_protocol::protocol::HookEventName, + groups: Vec, + suppressed_handler_positions: &mut Vec, +) -> Vec { + groups + .into_iter() + .enumerate() + .map(|(group_index, group)| MatcherGroup { + matcher: group.matcher, + hooks: group + .hooks + .into_iter() + .enumerate() + .map(|(handler_index, handler)| { + if handler.suppress { + suppressed_handler_positions.push((event_name, group_index, handler_index)); + } + handler.handler + }) + .collect(), + }) + .collect() +} + fn append_matcher_groups( handlers: &mut Vec, hook_entries: &mut Vec, @@ -421,6 +536,11 @@ fn append_matcher_groups( // 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 suppress_notifications = source.suppressed_handler_positions.contains(&( + 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); @@ -459,6 +579,7 @@ fn append_matcher_groups( source: source.source, display_order: *display_order, env: source.env.clone(), + suppress_notifications, }); } *display_order += 1; @@ -572,6 +693,8 @@ mod tests { use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; + use std::fs; + use tempfile::tempdir; use super::ConfiguredHandler; use super::append_matcher_groups; @@ -598,6 +721,7 @@ mod tests { source: hook_source(), is_managed: true, hook_states, + suppressed_handler_positions: Vec::new(), env: std::collections::HashMap::new(), plugin_id: None, } @@ -646,6 +770,7 @@ mod tests { source: hook_source(), display_order: 0, env: std::collections::HashMap::new(), + suppress_notifications: false, }] ); } @@ -681,6 +806,7 @@ mod tests { source: hook_source(), display_order: 0, env: std::collections::HashMap::new(), + suppress_notifications: false, }] ); } @@ -763,6 +889,102 @@ mod tests { ); } + #[test] + fn toml_hook_discovery_warns_and_ignores_suppress_outside_requirements() { + let layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_path_buf("/tmp/config.toml").abs(), + }, + serde_json::from_value(serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": "echo hello", + "suppress": true, + }], + }], + }, + })) + .expect("config TOML should deserialize"), + ); + 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![ + "ignoring unsupported `suppress` in hook config /tmp/config.toml; `suppress` is only supported for managed hooks in requirements".to_string() + ] + ); + 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() + } + ); + } + + #[test] + fn json_hook_discovery_warns_and_ignores_suppress_outside_requirements() { + let temp = tempdir().expect("create temp dir"); + let hooks_path = temp.path().join("hooks.json"); + fs::write( + &hooks_path, + serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": "echo hello", + "suppress": true, + }], + }], + }, + }) + .to_string(), + ) + .expect("write hooks.json"); + let mut warnings = Vec::new(); + + let (_, hooks) = super::load_hooks_json(Some(temp.path()), &mut warnings) + .expect("valid hook events should still load"); + + assert_eq!( + warnings, + vec![format!( + "ignoring unsupported `suppress` in hook config {}; `suppress` is only supported for managed hooks in requirements", + hooks_path.display() + )] + ); + 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": { diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 9c71bf49b5..8257da0c90 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -79,6 +79,7 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary { completed_at: None, duration_ms: None, entries: Vec::new(), + suppress_notifications: handler.suppress_notifications, } } @@ -125,6 +126,7 @@ pub(crate) fn completed_summary( completed_at: Some(run_result.completed_at), duration_ms: Some(run_result.duration_ms), entries, + suppress_notifications: handler.suppress_notifications, } } @@ -168,6 +170,7 @@ mod tests { source: HookSource::User, display_order, env: std::collections::HashMap::new(), + suppress_notifications: false, } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 579f7594d4..486eb597fd 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -50,6 +50,7 @@ pub(crate) struct ConfiguredHandler { pub source: HookSource, pub display_order: i64, pub env: HashMap, + pub suppress_notifications: bool, } impl ConfiguredHandler { diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 32739165f1..0a186f0d97 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -12,7 +12,10 @@ use codex_config::Constrained; use codex_config::ConstrainedWithSource; use codex_config::HookEventsToml; use codex_config::HookHandlerConfig; +use codex_config::ManagedHookEventsToml; +use codex_config::ManagedHookHandlerConfig; use codex_config::ManagedHooksRequirementsToml; +use codex_config::ManagedMatcherGroup; use codex_config::MatcherGroup; use codex_config::RequirementSource; use codex_config::TomlValue; @@ -36,7 +39,7 @@ fn cwd() -> AbsolutePathBuf { fn managed_hooks_for_current_platform( managed_dir: impl AsRef, - hooks: HookEventsToml, + hooks: ManagedHookEventsToml, ) -> ManagedHooksRequirementsToml { let managed_dir = managed_dir.as_ref().to_path_buf(); ManagedHooksRequirementsToml { @@ -80,14 +83,17 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: let managed_hooks = managed_hooks_for_current_platform( managed_dir.clone(), - HookEventsToml { - pre_tool_use: vec![MatcherGroup { + ManagedHookEventsToml { + pre_tool_use: vec![ManagedMatcherGroup { matcher: Some("^Bash$".to_string()), - hooks: vec![HookHandlerConfig::Command { - command: format!("python3 {}", script_path.display()), - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), + hooks: vec![ManagedHookHandlerConfig { + handler: HookHandlerConfig::Command { + command: format!("python3 {}", script_path.display()), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }, + suppress: false, }], }], ..Default::default() @@ -123,6 +129,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_eq!(engine.handlers[0].source, HookSource::CloudRequirements); + assert!(!engine.handlers[0].suppress_notifications); let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, @@ -169,6 +176,74 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\"")); } +#[test] +fn requirements_managed_hooks_can_suppress_notifications() { + 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, + ManagedHookEventsToml { + user_prompt_submit: vec![ManagedMatcherGroup { + matcher: None, + hooks: vec![ManagedHookHandlerConfig { + handler: HookHandlerConfig::Command { + command: "python3 /tmp/managed.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }, + suppress: true, + }], + }], + ..Default::default() + }, + ); + let config_layer_stack = ConfigLayerStack::new( + Vec::new(), + 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].suppress_notifications); + let preview = engine.preview_user_prompt_submit( + &crate::events::user_prompt_submit::UserPromptSubmitRequest { + session_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + cwd: cwd(), + transcript_path: None, + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + prompt: "hello".to_string(), + }, + ); + assert_eq!(preview.len(), 1); + assert!(preview[0].suppress_notifications); +} + #[test] fn unknown_requirement_source_hooks_stay_managed() { let temp = tempdir().expect("create temp dir"); @@ -177,14 +252,17 @@ fn unknown_requirement_source_hooks_stay_managed() { fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); let managed_hooks = managed_hooks_for_current_platform( managed_dir, - HookEventsToml { - pre_tool_use: vec![MatcherGroup { + ManagedHookEventsToml { + pre_tool_use: vec![ManagedMatcherGroup { 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()), + hooks: vec![ManagedHookHandlerConfig { + handler: HookHandlerConfig::Command { + command: "python3 /tmp/managed.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }, + suppress: false, }], }], ..Default::default() @@ -239,14 +317,17 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { 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 { + ManagedHookEventsToml { + pre_tool_use: vec![ManagedMatcherGroup { 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()), + hooks: vec![ManagedHookHandlerConfig { + handler: HookHandlerConfig::Command { + command: "python3 /tmp/managed.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }, + suppress: false, }], }], ..Default::default() @@ -458,14 +539,17 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { let missing_dir = temp.path().join("missing-managed-hooks"); let managed_hooks = managed_hooks_for_current_platform( missing_dir.clone(), - HookEventsToml { - pre_tool_use: vec![MatcherGroup { + ManagedHookEventsToml { + pre_tool_use: vec![ManagedMatcherGroup { matcher: Some("^Bash$".to_string()), - hooks: vec![HookHandlerConfig::Command { - command: format!("python3 {}", missing_dir.join("pre.py").display()), - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), + hooks: vec![ManagedHookHandlerConfig { + handler: HookHandlerConfig::Command { + command: format!("python3 {}", missing_dir.join("pre.py").display()), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }, + suppress: false, }], }], ..Default::default() diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index 67c13c34eb..2d38e4b02c 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -591,6 +591,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + suppress_notifications: false, } } diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 223efa7260..2e61915c29 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -551,6 +551,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + suppress_notifications: false, } } diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 77e6d3f3fa..e2ef25629a 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -613,6 +613,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + suppress_notifications: false, } } diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 195bb11257..5b668c4b5a 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -364,6 +364,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + suppress_notifications: false, } } diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 8fc176a474..8b016a202b 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -531,6 +531,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + suppress_notifications: false, } } diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index a10798ea62..5cf15c3beb 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -422,6 +422,7 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + suppress_notifications: false, } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 30e33abe43..fa6a68a86f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1559,6 +1559,10 @@ pub struct HookRunSummary { #[ts(type = "number | null")] pub duration_ms: Option, pub entries: Vec, + #[serde(skip)] + #[schemars(skip)] + #[ts(skip)] + pub suppress_notifications: bool, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 1d4bf2b858..6220400e51 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -514,10 +514,11 @@ mod tests { use codex_config::ConstrainedWithSource; use codex_config::FeatureRequirementsToml; use codex_config::FilesystemConstraints; - use codex_config::HookEventsToml; use codex_config::HookHandlerConfig; + use codex_config::ManagedHookEventsToml; + use codex_config::ManagedHookHandlerConfig; use codex_config::ManagedHooksRequirementsToml; - use codex_config::MatcherGroup; + use codex_config::ManagedMatcherGroup; use codex_config::McpServerIdentity; use codex_config::McpServerRequirement; use codex_config::NetworkConstraints; @@ -925,14 +926,17 @@ approval_policy = "never" std::path::PathBuf::from("/enterprise/hooks") }), windows_managed_dir: Some(std::path::PathBuf::from(r"C:\enterprise\hooks")), - hooks: HookEventsToml { - pre_tool_use: vec![MatcherGroup { + hooks: ManagedHookEventsToml { + pre_tool_use: vec![ManagedMatcherGroup { matcher: Some("^Bash$".to_string()), - hooks: vec![HookHandlerConfig::Command { - command: "python3 /enterprise/hooks/pre.py".to_string(), - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), + hooks: vec![ManagedHookHandlerConfig { + handler: HookHandlerConfig::Command { + command: "python3 /enterprise/hooks/pre.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }, + suppress: false, }], }], ..Default::default()