Add allow_managed_hooks_only hook requirement (#20319)

## Why

Enterprise-managed hook policy needs a narrow way to require Codex to
ignore user-controlled lifecycle hooks without adopting the broader
trust-precedence model from earlier hook work. This keeps the policy
anchored in `requirements.toml`, so admins can opt into managed hooks
only while normal `config.toml` files cannot enable the restriction
themselves.

## What changed

- Added `allow_managed_hooks_only` to the requirements data flow and
preserved explicit `false` values.
- Also adds it to /debug-config
- Marked MDM, system, and legacy managed config layers as managed for
hook discovery.
- Updated hook discovery so `allow_managed_hooks_only = true`:
  - keeps managed requirements hooks and managed config-layer hooks,
- skips user/project/session `hooks.json` and `[hooks]` entries with
concise startup warnings,
  - skips current unmanaged plugin hooks,
- ignores any `allow_managed_hooks_only` key placed in ordinary
`config.toml` layers.
This commit is contained in:
Andrei Eternal
2026-05-12 19:05:25 -07:00
committed by GitHub
parent fbfbfe5fc5
commit 913aad4d3c
17 changed files with 650 additions and 68 deletions

View File

@@ -228,6 +228,76 @@ async fn returns_config_error_for_schema_error_in_user_config() {
assert_eq!(config_error, &expected_config_error);
}
#[tokio::test]
async fn top_level_allow_managed_hooks_only_in_user_config_does_not_enable_requirements_policy()
-> std::io::Result<()> {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
"allow_managed_hooks_only = true",
)
.expect("write config");
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
assert_eq!(layers.requirements_toml().allow_managed_hooks_only, None);
assert!(layers.requirements().allow_managed_hooks_only.is_none());
Ok(())
}
#[tokio::test]
async fn hooks_allow_managed_hooks_only_in_user_config_does_not_enable_requirements_policy()
-> std::io::Result<()> {
let tmp = tempdir().expect("tempdir");
let contents = r#"
[hooks]
allow_managed_hooks_only = true
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /tmp/user-hook.py"
"#;
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), contents).expect("write config");
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
assert!(
layers
.get_user_layer()
.and_then(|layer| layer.config.get("hooks"))
.is_some(),
"hooks should still deserialize from config.toml"
);
assert_eq!(layers.requirements_toml().allow_managed_hooks_only, None);
assert!(layers.requirements().allow_managed_hooks_only.is_none());
Ok(())
}
#[test]
fn schema_error_points_to_feature_value() {
let tmp = tempdir().expect("tempdir");
@@ -777,6 +847,7 @@ allowed_approval_policies = ["on-request"]
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -834,6 +905,7 @@ allowed_approval_policies = ["on-request"]
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -1042,6 +1114,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,

View File

@@ -8167,6 +8167,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -8879,6 +8880,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,

View File

@@ -2125,6 +2125,7 @@ impl Config {
approvals_reviewer: mut constrained_approvals_reviewer,
permission_profile: mut constrained_permission_profile,
web_search_mode: mut constrained_web_search_mode,
allow_managed_hooks_only: _,
feature_requirements,
managed_hooks: _,
mcp_servers,