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
+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,