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
parent 9955eacd22
commit 2b2de3f38b
35 changed files with 2464 additions and 270 deletions

View File

@@ -4,6 +4,7 @@ use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::NetworkConstraints;
use codex_config::NetworkDomainPermissionToml;
use codex_config::NetworkUnixSocketPermissionToml;
@@ -168,6 +169,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
));
}
if let Some(hooks) = requirements_toml.hooks.as_ref() {
requirement_lines.push(requirement_line(
"hooks",
format_managed_hooks_requirements(hooks),
requirements
.managed_hooks
.as_ref()
.and_then(|managed_hooks| managed_hooks.source.as_ref()),
));
}
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(
@@ -257,6 +269,23 @@ fn render_session_flag_details(config: &TomlValue) -> Vec<Line<'static>> {
.collect()
}
fn format_managed_hooks_requirements(hooks: &ManagedHooksRequirementsToml) -> String {
let mut parts = Vec::new();
if let Some(managed_dir) = hooks.managed_dir.as_ref() {
parts.push(format!("managed_dir={}", managed_dir.display()));
}
if let Some(windows_managed_dir) = hooks.windows_managed_dir.as_ref() {
parts.push(format!(
"windows_managed_dir={}",
windows_managed_dir.display()
));
}
parts.push(format!("handlers={}", hooks.handler_count()));
join_or_empty(parts)
}
fn render_mdm_layer_details(layer: &ConfigLayerEntry) -> Vec<Line<'static>> {
let value = layer
.raw_toml()
@@ -484,6 +513,10 @@ mod tests {
use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::FilesystemConstraints;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::NetworkConstraints;
@@ -655,6 +688,7 @@ mod tests {
feature_requirements: Some(FeatureRequirementsToml {
entries: BTreeMap::from([("guardian_approval".to_string(), true)]),
}),
hooks: None,
mcp_servers: Some(BTreeMap::from([(
"docs".to_string(),
McpServerRequirement {
@@ -860,6 +894,7 @@ approval_policy = "never"
allowed_web_search_modes: Some(Vec::new()),
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -877,6 +912,50 @@ approval_policy = "never"
);
}
#[test]
fn debug_config_output_lists_managed_hooks_requirement() {
let requirements = ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(ManagedHooksRequirementsToml {
managed_dir: Some(if cfg!(windows) {
std::path::PathBuf::from(r"C:\enterprise\hooks")
} else {
std::path::PathBuf::from("/enterprise/hooks")
}),
windows_managed_dir: Some(std::path::PathBuf::from(r"C:\enterprise\hooks")),
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}),
Some(RequirementSource::CloudRequirements),
)),
..ConfigRequirements::default()
};
let requirements_toml = ConfigRequirementsToml {
hooks: requirements
.managed_hooks
.as_ref()
.map(|hooks| hooks.get().clone()),
..ConfigRequirementsToml::default()
};
let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
.expect("config layer stack");
let rendered = render_to_text(&render_debug_config_lines(&stack));
assert!(rendered.contains("hooks:"));
assert!(rendered.contains("handlers=1"));
assert!(rendered.contains("(source: cloud requirements)"));
}
#[test]
fn session_all_proxy_url_uses_socks_when_enabled() {
assert_eq!(