codex: support hooks in config.toml and requirements.toml (#18893)

## Summary

Support the existing hooks schema in inline TOML so hooks can be
configured from both `config.toml` and enterprise-managed
`requirements.toml` without requiring a separate `hooks.json` payload.

This gives enterprise admins a way to ship managed hook policy through
the existing requirements channel while still leaving script delivery to
MDM or other device-management tooling, and it keeps `hooks.json`
working unchanged for existing users.

This also lays the groundwork for follow-on managed filtering work such
as #15937, while continuing to respect project trust gating from #14718.
It does **not** implement `allow_managed_hooks_only` itself.

NOTE: yes, it's a bit unfortunate that the toml isn't formatted as
closely as normal to our default styling. This is because we're trying
to stay compatible with the spec for plugins/hooks that we'll need to
support & the main usecase here is embedding into requirements.toml

## What changed

- moved the shared hook serde model out of `codex-rs/hooks` into
`codex-rs/config` so the same schema can power `hooks.json`, inline
`config.toml` hooks, and managed `requirements.toml` hooks
- added `hooks` support to both `ConfigToml` and
`ConfigRequirementsToml`, including requirements-side `managed_dir` /
`windows_managed_dir`
- treated requirements-managed hooks as one constrained value via
`Constrained`, so managed hook policy is merged atomically and cannot
drift across requirement sources
- updated hook discovery to load requirements-managed hooks first, then
per-layer `hooks.json`, then per-layer inline TOML hooks, with a warning
when a single layer defines both representations
- threaded managed hook metadata through discovered handlers and exposed
requirements hooks in app-server responses, generated schemas, and
`/debug-config`
- added hook/config coverage in `codex-rs/config`, `codex-rs/hooks`,
`codex-rs/core/src/config_loader/tests.rs`, and
`codex-rs/core/tests/suite/hooks.rs`

## Testing

- `cargo test -p codex-config`
- `cargo test -p codex-hooks`
- `cargo test -p codex-app-server config_api`

## Documentation

Companion updates are needed in the developers website repo for:

- the hooks guide
- the config reference, sample, basic, and advanced pages
- the enterprise managed configuration guide

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
Andrei Eternal
2026-04-22 21:20:09 -07:00
committed by GitHub
Unverified
parent 9955eacd22
commit 2b2de3f38b
35 changed files with 2464 additions and 270 deletions
+2
View File
@@ -5732,6 +5732,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
crate::config_loader::WebSearchModeRequirement::Cached,
]),
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -6407,6 +6408,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
remote_sandbox_config: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
+1
View File
@@ -1577,6 +1577,7 @@ impl Config {
sandbox_policy: mut constrained_sandbox_policy,
web_search_mode: mut constrained_web_search_mode,
feature_requirements,
managed_hooks: _,
mcp_servers,
exec_policy: _,
enforce_residency,
+4
View File
@@ -45,7 +45,11 @@ pub use codex_config::ConstrainedWithSource;
pub use codex_config::FeatureRequirementsToml;
pub use codex_config::FilesystemConstraints;
pub use codex_config::FilesystemDenyReadPattern;
pub use codex_config::HookEventsToml;
pub use codex_config::HookHandlerConfig;
pub use codex_config::LoaderOverrides;
pub use codex_config::ManagedHooksRequirementsToml;
pub use codex_config::MatcherGroup;
pub use codex_config::McpServerIdentity;
pub use codex_config::McpServerRequirement;
pub use codex_config::NetworkConstraints;
+59
View File
@@ -777,6 +777,7 @@ allowed_approval_policies = ["on-request"]
remote_sandbox_config: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -833,6 +834,7 @@ allowed_approval_policies = ["on-request"]
remote_sandbox_config: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1041,6 +1043,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
remote_sandbox_config: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1084,6 +1087,62 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
Ok(())
}
#[tokio::test]
async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result<()> {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
let managed_dir = tmp.path().join("managed-hooks");
tokio::fs::create_dir_all(&managed_dir).await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let requirements = ConfigRequirementsToml {
hooks: Some(codex_config::ManagedHooksRequirementsToml {
managed_dir: Some(managed_dir.clone()),
windows_managed_dir: None,
hooks: codex_config::HookEventsToml {
pre_tool_use: vec![codex_config::MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![codex_config::HookHandlerConfig::Command {
command: format!("python3 {}/pre.py", managed_dir.display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}),
..ConfigRequirementsToml::default()
};
let expected = requirements.clone();
let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) });
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
cloud_requirements,
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
assert_eq!(layers.requirements_toml().hooks, expected.hooks);
assert_eq!(
layers
.requirements()
.managed_hooks
.as_ref()
.map(|hooks| hooks.source.clone()),
Some(Some(RequirementSource::CloudRequirements))
);
Ok(())
}
#[tokio::test]
async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> {
let tmp = tempdir()?;