From b04edb16855d448a704c9f022100bce05b72ffdf Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Thu, 26 Mar 2026 18:32:51 -0700 Subject: [PATCH 1/4] feat: add managed hooks lockdown --- .../codex_app_server_protocol.schemas.json | 6 + .../codex_app_server_protocol.v2.schemas.json | 6 + .../v2/ConfigRequirementsReadResponse.json | 6 + .../typescript/v2/ConfigRequirements.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 2 + codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/config_api.rs | 4 + codex-rs/config/src/config_requirements.rs | 18 +++ codex-rs/config/src/state.rs | 11 ++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/mod.rs | 1 + codex-rs/core/src/config_loader/tests.rs | 3 + codex-rs/core/tests/suite/hooks.rs | 144 ++++++++++++++++++ codex-rs/hooks/src/engine/discovery.rs | 26 ++++ codex-rs/hooks/src/events/session_start.rs | 2 +- .../hooks/src/events/user_prompt_submit.rs | 2 +- codex-rs/tui/src/debug_config.rs | 18 +++ docs/config.md | 9 ++ 18 files changed, 260 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index da2169987565..e0b17ea6626b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6742,6 +6742,12 @@ }, "ConfigRequirements": { "properties": { + "allowManagedHooksOnly": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/v2/AskForApproval" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a66c34e14d1e..b20a333c81b4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3410,6 +3410,12 @@ }, "ConfigRequirements": { "properties": { + "allowManagedHooksOnly": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/AskForApproval" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index ae6eb1dc7d3f..59cf17975df2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -61,6 +61,12 @@ }, "ConfigRequirements": { "properties": { + "allowManagedHooksOnly": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/AskForApproval" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 47a99453fe36..1b0625e5e16e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e0d22bfa2405..f7f49e19e5c4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -855,6 +855,7 @@ pub struct ConfigRequirements { pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, + pub allow_managed_hooks_only: Option, pub feature_requirements: Option>, pub enforce_residency: Option, #[experimental("configRequirements/read.network")] @@ -7398,6 +7399,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, enforce_residency: None, network: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3aff64776624..ada50d26bb5d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -196,7 +196,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), `allowManagedHooksOnly`, pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 4f9a800243a6..111f0c768576 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -388,6 +388,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR } normalized }), + allow_managed_hooks_only: requirements.allow_managed_hooks_only, feature_requirements: requirements .feature_requirements .map(|requirements| requirements.entries), @@ -564,6 +565,7 @@ mod tests { allowed_web_search_modes: Some(vec![ codex_core::config_loader::WebSearchModeRequirement::Cached, ]), + allow_managed_hooks_only: Some(true), guardian_developer_instructions: None, feature_requirements: Some(codex_core::config_loader::FeatureRequirementsToml { entries: std::collections::BTreeMap::from([ @@ -630,6 +632,7 @@ mod tests { mapped.allowed_web_search_modes, Some(vec![WebSearchMode::Cached, WebSearchMode::Disabled]), ); + assert_eq!(mapped.allow_managed_hooks_only, Some(true)); assert_eq!( mapped.feature_requirements, Some(std::collections::BTreeMap::from([ @@ -734,6 +737,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: Some(Vec::new()), + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index eb1c1033a5fa..423657e8b3d1 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -82,6 +82,7 @@ pub struct ConfigRequirements { pub approvals_reviewer: ConstrainedWithSource, pub sandbox_policy: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, + pub allow_managed_hooks_only: Option>, pub feature_requirements: Option>, pub mcp_servers: Option>>, pub exec_policy: Option>, @@ -109,6 +110,7 @@ impl Default for ConfigRequirements { Constrained::allow_any(WebSearchMode::Cached), /*source*/ None, ), + allow_managed_hooks_only: None, feature_requirements: None, mcp_servers: None, exec_policy: None, @@ -506,6 +508,7 @@ pub struct ConfigRequirementsToml { pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, + pub allow_managed_hooks_only: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, pub mcp_servers: Option>, @@ -545,6 +548,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, pub allowed_web_search_modes: Option>>, + pub allow_managed_hooks_only: Option>, pub feature_requirements: Option>, pub mcp_servers: Option>>, pub apps: Option>, @@ -577,6 +581,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers: _, allowed_sandbox_modes: _, allowed_web_search_modes: _, + allow_managed_hooks_only: _, feature_requirements: _, mcp_servers: _, apps: _, @@ -603,6 +608,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, mcp_servers, rules, @@ -627,6 +633,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, mcp_servers, apps, @@ -640,6 +647,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), + allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), apps: apps.map(|sourced| sourced.value), @@ -691,6 +699,7 @@ impl ConfigRequirementsToml { && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() && self.allowed_web_search_modes.is_none() + && self.allow_managed_hooks_only.is_none() && self .feature_requirements .as_ref() @@ -719,6 +728,7 @@ impl TryFrom for ConfigRequirements { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, mcp_servers, apps: _apps, @@ -935,6 +945,7 @@ impl TryFrom for ConfigRequirements { approvals_reviewer, sandbox_policy, web_search_mode, + allow_managed_hooks_only, feature_requirements, mcp_servers, exec_policy, @@ -972,6 +983,7 @@ mod tests { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, mcp_servers, apps, @@ -989,6 +1001,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_managed_hooks_only: allow_managed_hooks_only + .map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1033,6 +1047,7 @@ mod tests { allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), allowed_web_search_modes: Some(allowed_web_search_modes.clone()), + allow_managed_hooks_only: Some(true), feature_requirements: Some(feature_requirements.clone()), mcp_servers: None, apps: None, @@ -1060,6 +1075,7 @@ mod tests { allowed_web_search_modes, enforce_source.clone(), )), + allow_managed_hooks_only: Some(Sourced::new(true, enforce_source.clone())), feature_requirements: Some(Sourced::new( feature_requirements, enforce_source.clone(), @@ -1102,6 +1118,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1146,6 +1163,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, mcp_servers: None, apps: None, diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index a34ccc6cdb25..cd8d1d0102f5 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -99,6 +99,17 @@ impl ConfigLayerEntry { self.disabled_reason.is_some() } + /// Returns true for config layers controlled by managed policy sources. + pub fn is_managed(&self) -> bool { + matches!( + self.name, + ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::System { .. } + | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } + | ConfigLayerSource::LegacyManagedConfigTomlFromMdm + ) + } + pub fn raw_toml(&self) -> Option<&str> { self.raw_toml.as_deref() } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b7a6a1845780..71381f2a475c 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4981,6 +4981,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any allowed_web_search_modes: Some(vec![ crate::config_loader::WebSearchModeRequirement::Cached, ]), + allow_managed_hooks_only: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -5605,6 +5606,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s allowed_approvals_reviewers: None, allowed_sandbox_modes: Some(vec![crate::config_loader::SandboxModeRequirement::ReadOnly]), allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, mcp_servers: None, apps: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3e9626866b8a..52f2de953bf8 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1346,6 +1346,7 @@ impl Config { approvals_reviewer: mut constrained_approvals_reviewer, sandbox_policy: mut constrained_sandbox_policy, web_search_mode: mut constrained_web_search_mode, + allow_managed_hooks_only: _, feature_requirements, mcp_servers, exec_policy: _, diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 060efeb599c7..6fc71e97e225 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -630,6 +630,7 @@ allowed_approval_policies = ["on-request"] allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -682,6 +683,7 @@ allowed_approval_policies = ["on-request"] allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -723,6 +725,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, mcp_servers: None, apps: None, diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c6e8a48d27a7..eb864f97538b 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -3,6 +3,12 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; +use codex_app_server_protocol::ConfigLayerSource; +use codex_core::config::Config; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_core::config_loader::RequirementSource; +use codex_core::config_loader::Sourced; use codex_features::Feature; use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; @@ -11,7 +17,9 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_message_item_added; @@ -440,6 +448,142 @@ fn sse_event(event: Value) -> String { sse(vec![event]) } +fn enable_managed_hooks_only(config: &mut Config) { + let system_dir = config.codex_home.join("managed"); + let system_config_file = + AbsolutePathBuf::from_absolute_path(system_dir.join(codex_config::CONFIG_TOML_FILE)) + .expect("absolute managed config path"); + let mut layers = config + .config_layer_stack + .get_layers( + codex_config::ConfigLayerStackOrdering::LowestPrecedenceFirst, + true, + ) + .into_iter() + .cloned() + .collect::>(); + layers.insert( + 0, + ConfigLayerEntry::new( + ConfigLayerSource::System { + file: system_config_file, + }, + toml::Value::Table(Default::default()), + ), + ); + + let mut requirements = config.config_layer_stack.requirements().clone(); + requirements.allow_managed_hooks_only = + Some(Sourced::new(true, RequirementSource::CloudRequirements)); + let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); + requirements_toml.allow_managed_hooks_only = Some(true); + config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml) + .expect("rebuild config layer stack with managed hooks requirement"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn configured_hooks_emit_startup_warning() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_session_start_hook_recording_transcript(home) { + panic!("failed to write session start hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let warning = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::Warning(WarningEvent { message }) + if message.contains("Hooks run arbitrary shell commands outside the sandbox") + ) + }) + .await; + let EventMsg::Warning(WarningEvent { message }) = warning else { + panic!("expected warning event"); + }; + assert!(message.contains("hooks.json")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn managed_hooks_only_skips_user_hooks() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello from managed hook test"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + let system_dir = home.join("managed"); + fs::create_dir_all(&system_dir).expect("create managed hooks dir"); + write_session_start_hook_recording_transcript(&system_dir) + .expect("write managed session start hook"); + write_session_start_hook_recording_transcript(home) + .expect("write user session start hook"); + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + enable_managed_hooks_only(config); + }); + let test = builder.build(&server).await?; + + let warning = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::Warning(WarningEvent { message }) + if message.contains("skipping hooks config") + && message.contains("allow_managed_hooks_only") + ) + }) + .await; + let EventMsg::Warning(WarningEvent { message }) = warning else { + panic!("expected warning event"); + }; + assert!( + message.contains( + test.codex_home_path() + .join("hooks.json") + .to_string_lossy() + .as_ref() + ) + ); + + test.submit_turn("hello").await?; + + let managed_hook_inputs = + read_session_start_hook_inputs(&test.codex_home_path().join("managed"))?; + assert_eq!(managed_hook_inputs.len(), 1); + let user_hook_log_path = test.codex_home_path().join("session_start_hook_log.jsonl"); + assert!( + !user_hook_log_path.exists(), + "user hook should be skipped when managed hooks only is enabled" + ); + + Ok(()) +} + fn request_message_input_texts(body: &[u8], role: &str) -> Vec { let body: Value = match serde_json::from_slice(body) { Ok(body) => body, diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 2d8d266689a6..b1b98986a543 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -28,6 +28,11 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - let mut handlers = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0_i64; + let allow_managed_hooks_only = config_layer_stack + .requirements() + .allow_managed_hooks_only + .as_ref() + .is_some_and(|requirement| requirement.value); for layer in config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, @@ -40,6 +45,13 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - if !source_path.as_path().is_file() { continue; } + if allow_managed_hooks_only && !layer.is_managed() { + warnings.push(format!( + "skipping hooks config {} because `allow_managed_hooks_only` is enabled", + source_path.display() + )); + continue; + } let contents = match fs::read_to_string(source_path.as_path()) { Ok(contents) => contents, @@ -102,6 +114,20 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - } } + if !handlers.is_empty() { + let mut source_paths = handlers + .iter() + .map(|handler| handler.source_path.display().to_string()) + .collect::>(); + source_paths.sort(); + source_paths.dedup(); + warnings.push(format!( + "Loaded {} lifecycle hook(s) from {}. Hooks run arbitrary shell commands outside the sandbox; review hooks.json changes before continuing.", + handlers.len(), + source_paths.join(", ") + )); + } + DiscoveryResult { handlers, warnings } } diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 3f115742c504..995b24b4d76d 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -49,7 +49,7 @@ pub struct SessionStartOutcome { pub additional_contexts: Vec, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Default, PartialEq, Eq)] struct SessionStartHandlerData { should_stop: bool, stop_reason: Option, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 2f148475ff02..b48c254aa8fd 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -36,7 +36,7 @@ pub struct UserPromptSubmitOutcome { pub additional_contexts: Vec, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Default, PartialEq, Eq)] struct UserPromptSubmitHandlerData { should_stop: bool, stop_reason: Option, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index bc723049746d..7df9c039f3db 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -130,6 +130,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { )); } + if let Some(allow_managed_hooks_only) = requirements_toml.allow_managed_hooks_only { + requirement_lines.push(requirement_line( + "allow_managed_hooks_only", + allow_managed_hooks_only.to_string(), + requirements + .allow_managed_hooks_only + .as_ref() + .map(|sourced| &sourced.source), + )); + } + if let Some(servers) = requirements_toml.mcp_servers.as_ref() { let value = join_or_empty(servers.keys().cloned().collect::>()); requirement_lines.push(requirement_line( @@ -554,6 +565,10 @@ mod tests { Constrained::allow_any(WebSearchMode::Cached), Some(RequirementSource::CloudRequirements), ), + allow_managed_hooks_only: Some(Sourced::new( + true, + RequirementSource::CloudRequirements, + )), network: Some(Sourced::new( NetworkConstraints { enabled: Some(true), @@ -576,6 +591,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), + allow_managed_hooks_only: Some(true), guardian_developer_instructions: None, feature_requirements: None, mcp_servers: Some(BTreeMap::from([( @@ -625,6 +641,7 @@ mod tests { "allowed_web_search_modes: cached, disabled (source: cloud requirements)" ) ); + assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)")); assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); assert!(rendered.contains( @@ -740,6 +757,7 @@ approval_policy = "never" allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: Some(Vec::new()), + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, diff --git a/docs/config.md b/docs/config.md index 71f3548debb2..7b656ecb0c2e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -36,6 +36,15 @@ Codex can run a notification hook when the agent finishes a turn. See the config When Codex knows which client started the turn, the legacy notify JSON payload also includes a top-level `client` field. The TUI reports `codex-tui`, and the app server reports the `clientInfo.name` value from `initialize`. +## Lifecycle hooks + +Lifecycle hooks run arbitrary shell commands outside the sandbox. When hooks are configured, +Codex emits a startup warning that lists the loaded `hooks.json` files so you can review +changes before continuing. + +Admins can set `allow_managed_hooks_only = true` in `requirements.toml` to ignore user and +project hook files while still allowing managed hooks. + ## JSON Schema The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`. From de886c57c48a4edd0ba9b1853ea0fbf993ca6784 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 6 Apr 2026 20:42:32 -0700 Subject: [PATCH 2/4] style: format hook safety imports Co-authored-by: Codex --- codex-rs/core/tests/suite/hooks.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index eb864f97538b..8dbf4bc7f801 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -4,9 +4,9 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; use codex_app_server_protocol::ConfigLayerSource; -use codex_core::config::Config; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerStack; +use codex_core::config::Config; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::Sourced; use codex_features::Feature; From c5decba321683f9b45a077c9bc3ae2dfff8080a6 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 6 Apr 2026 20:47:06 -0700 Subject: [PATCH 3/4] fix: annotate managed hook test literal Co-authored-by: Codex --- codex-rs/config/src/config_requirements.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 423657e8b3d1..2ed5275a20d1 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1075,7 +1075,10 @@ mod tests { allowed_web_search_modes, enforce_source.clone(), )), - allow_managed_hooks_only: Some(Sourced::new(true, enforce_source.clone())), + allow_managed_hooks_only: Some(Sourced::new( + /*value*/ true, + enforce_source.clone(), + )), feature_requirements: Some(Sourced::new( feature_requirements, enforce_source.clone(), From 5ab034b859ae18739c78d785616ceb5bf127c1ba Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 6 Apr 2026 21:09:19 -0700 Subject: [PATCH 4/4] fix: complete managed hooks requirements tests Co-authored-by: Codex --- codex-rs/app-server/src/config_api.rs | 1 + codex-rs/cloud-requirements/src/lib.rs | 16 ++++++++++++++++ codex-rs/core/tests/suite/hooks.rs | 19 ++++++++++++------- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 111f0c768576..425dd0999ed1 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -678,6 +678,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index ad4ebed12fb8..0f11fcdc863a 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1157,6 +1157,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1186,6 +1187,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1215,6 +1217,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1261,6 +1264,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1343,6 +1347,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1415,6 +1420,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1485,6 +1491,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1649,6 +1656,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1684,6 +1692,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1739,6 +1748,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1789,6 +1799,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1843,6 +1854,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1898,6 +1910,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -1953,6 +1966,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -2041,6 +2055,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, @@ -2068,6 +2083,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_developer_instructions: None, feature_requirements: None, mcp_servers: None, diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 8dbf4bc7f801..51a35353c8d7 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -448,16 +448,16 @@ fn sse_event(event: Value) -> String { sse(vec![event]) } -fn enable_managed_hooks_only(config: &mut Config) { +fn enable_managed_hooks_only(config: &mut Config) -> Result<()> { let system_dir = config.codex_home.join("managed"); let system_config_file = AbsolutePathBuf::from_absolute_path(system_dir.join(codex_config::CONFIG_TOML_FILE)) - .expect("absolute managed config path"); + .context("absolute managed config path")?; let mut layers = config .config_layer_stack .get_layers( codex_config::ConfigLayerStackOrdering::LowestPrecedenceFirst, - true, + /*include_disabled*/ true, ) .into_iter() .cloned() @@ -473,12 +473,15 @@ fn enable_managed_hooks_only(config: &mut Config) { ); let mut requirements = config.config_layer_stack.requirements().clone(); - requirements.allow_managed_hooks_only = - Some(Sourced::new(true, RequirementSource::CloudRequirements)); + requirements.allow_managed_hooks_only = Some(Sourced::new( + /*value*/ true, + RequirementSource::CloudRequirements, + )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.allow_managed_hooks_only = Some(true); config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml) - .expect("rebuild config layer stack with managed hooks requirement"); + .context("rebuild config layer stack with managed hooks requirement")?; + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -545,7 +548,9 @@ async fn managed_hooks_only_skips_user_hooks() -> Result<()> { .features .enable(Feature::CodexHooks) .expect("test config should allow feature update"); - enable_managed_hooks_only(config); + if let Err(error) = enable_managed_hooks_only(config) { + panic!("failed to enable managed-hooks-only test config: {error:#}"); + } }); let test = builder.build(&server).await?;