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 9fb9c3e3e67f..dfcfd817250b 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 @@ -6917,6 +6917,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 da0381d8b129..a7ba95582025 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 @@ -3514,6 +3514,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 614575a95553..1451c12ff555 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 340a80a618ab..ac37eda795ff 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -890,6 +890,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")] @@ -7845,6 +7846,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 fcc19ce42d20..0a4cc7350fb4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -205,7 +205,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect. When a request includes plugin imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background remote imports finish). - `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`. +- `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 feb1cc7884d5..11a86c2ba12a 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -391,6 +391,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), @@ -566,6 +567,7 @@ mod tests { allowed_web_search_modes: Some(vec![ codex_core::config_loader::WebSearchModeRequirement::Cached, ]), + allow_managed_hooks_only: Some(true), guardian_policy_config: None, feature_requirements: Some(codex_core::config_loader::FeatureRequirementsToml { entries: std::collections::BTreeMap::from([ @@ -632,6 +634,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([ @@ -676,6 +679,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -734,6 +738,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: Some(Vec::new()), + allow_managed_hooks_only: None, guardian_policy_config: 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 676d3201da0f..c2136c5b81d8 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1165,6 +1165,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1195,6 +1196,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1225,6 +1227,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1272,6 +1275,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1355,6 +1359,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1428,6 +1433,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1499,6 +1505,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1697,6 +1704,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1733,6 +1741,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1789,6 +1798,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1840,6 +1850,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1895,6 +1906,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -1951,6 +1963,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -2007,6 +2020,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -2096,6 +2110,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, mcp_servers: None, @@ -2124,6 +2139,7 @@ enabled = false allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: 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 7cf22f69ebbb..37097f08aaa7 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -84,6 +84,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>, @@ -113,6 +114,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, @@ -621,6 +623,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>, @@ -661,6 +664,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>, @@ -694,6 +698,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers: _, allowed_sandbox_modes: _, allowed_web_search_modes: _, + allow_managed_hooks_only: _, feature_requirements: _, mcp_servers: _, apps: _, @@ -721,6 +726,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, mcp_servers, rules, @@ -746,6 +752,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, mcp_servers, apps, @@ -760,6 +767,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), @@ -811,6 +819,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() @@ -840,6 +849,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, @@ -1061,6 +1071,7 @@ impl TryFrom for ConfigRequirements { approvals_reviewer, sandbox_policy, web_search_mode, + allow_managed_hooks_only, feature_requirements, mcp_servers, exec_policy, @@ -1100,6 +1111,7 @@ mod tests { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, mcp_servers, apps, @@ -1118,6 +1130,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)), @@ -1162,6 +1176,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, @@ -1190,6 +1205,10 @@ mod tests { allowed_web_search_modes, 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(), @@ -1230,6 +1249,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, @@ -1275,6 +1295,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 f4f23880e467..2482687a113d 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -100,6 +100,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 bc65aac61c7c..c63cb02ab9a6 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5313,6 +5313,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_web_search_modes: Some(vec![ crate::config_loader::WebSearchModeRequirement::Cached, ]), + allow_managed_hooks_only: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -5987,6 +5988,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 6320bff2e399..faa663831234 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1487,6 +1487,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 a8f4eda8ec04..1bd06faf00ff 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -646,6 +646,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, @@ -699,6 +700,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, @@ -855,6 +857,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 ee63ca77b808..80713ea15387 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -3,7 +3,10 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; +use codex_app_server_protocol::ConfigLayerSource; +use codex_core::config::Config; use codex_core::config::Constrained; +use codex_core::config_loader::ConfigLayerEntry; use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::NetworkConstraints; @@ -20,7 +23,9 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SandboxPolicy; +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_function_call; @@ -577,6 +582,147 @@ fn sse_event(event: Value) -> String { sse(vec![event]) } +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)) + .context("absolute managed config path")?; + let mut layers = config + .config_layer_stack + .get_layers( + codex_config::ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ 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( + /*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) + .context("rebuild config layer stack with managed hooks requirement")?; + Ok(()) +} + +#[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"); + 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?; + + 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 c363fa0ef732..5c7a91c5fd6c 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, @@ -107,6 +119,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 46201d0e8819..41dcab125392 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -52,7 +52,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 13bd562b08c7..bd353f7a0811 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -37,7 +37,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 008ff1a5fafb..9a68ebd6a629 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -144,6 +144,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(feature_requirements) = requirements.feature_requirements.as_ref() { let value = join_or_empty( feature_requirements @@ -606,6 +617,10 @@ mod tests { Constrained::allow_any(WebSearchMode::Cached), Some(RequirementSource::CloudRequirements), ), + allow_managed_hooks_only: Some(Sourced::new( + /*value*/ true, + RequirementSource::CloudRequirements, + )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -641,6 +656,7 @@ mod tests { allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), + allow_managed_hooks_only: Some(true), guardian_policy_config: None, feature_requirements: Some(FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -696,6 +712,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("features: guardian_approval=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)")); @@ -844,6 +861,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_policy_config: None, feature_requirements: None, mcp_servers: None, diff --git a/docs/config.md b/docs/config.md index d583122e0106..4df2f4b96364 100644 --- a/docs/config.md +++ b/docs/config.md @@ -56,6 +56,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`.