feat: add managed hooks lockdown

This commit is contained in:
viyatb-oai
2026-04-07 14:48:50 -07:00
parent fa5270abef
commit b04edb1685
18 changed files with 260 additions and 4 deletions
@@ -6742,6 +6742,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/v2/AskForApproval"
@@ -3410,6 +3410,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/AskForApproval"
@@ -61,6 +61,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/AskForApproval"
@@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
@@ -855,6 +855,7 @@ pub struct ConfigRequirements {
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub feature_requirements: Option<BTreeMap<String, bool>>,
pub enforce_residency: Option<ResidencyRequirement>,
#[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,
+1 -1
View File
@@ -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
+4
View File
@@ -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,
@@ -82,6 +82,7 @@ pub struct ConfigRequirements {
pub approvals_reviewer: ConstrainedWithSource<ApprovalsReviewer>,
pub sandbox_policy: ConstrainedWithSource<SandboxPolicy>,
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
@@ -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<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub allow_managed_hooks_only: Option<bool>,
#[serde(rename = "features", alias = "feature_requirements")]
pub feature_requirements: Option<FeatureRequirementsToml>,
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
@@ -545,6 +548,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_approvals_reviewers: Option<Sourced<Vec<ApprovalsReviewer>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub apps: Option<Sourced<AppsRequirementsToml>>,
@@ -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<ConfigRequirementsWithSources> 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<ConfigRequirementsWithSources> 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,
+11
View File
@@ -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()
}
+2
View File
@@ -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,
+1
View File
@@ -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: _,
+3
View File
@@ -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,
+144
View File
@@ -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::<Vec<_>>();
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<String> {
let body: Value = match serde_json::from_slice(body) {
Ok(body) => body,
+26
View File
@@ -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::<Vec<_>>();
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 }
}
+1 -1
View File
@@ -49,7 +49,7 @@ pub struct SessionStartOutcome {
pub additional_contexts: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Default, PartialEq, Eq)]
struct SessionStartHandlerData {
should_stop: bool,
stop_reason: Option<String>,
@@ -36,7 +36,7 @@ pub struct UserPromptSubmitOutcome {
pub additional_contexts: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Default, PartialEq, Eq)]
struct UserPromptSubmitHandlerData {
should_stop: bool,
stop_reason: Option<String>,
+18
View File
@@ -130,6 +130,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
));
}
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::<Vec<_>>());
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,