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

@@ -17,6 +17,7 @@ use super::requirements_exec_policy::RequirementsExecPolicy;
use super::requirements_exec_policy::RequirementsExecPolicyToml;
use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedHooksRequirementsToml;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequirementSource {
@@ -86,6 +87,7 @@ pub struct ConfigRequirements {
pub sandbox_policy: ConstrainedWithSource<SandboxPolicy>,
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
@@ -117,6 +119,7 @@ impl Default for ConfigRequirements {
/*source*/ None,
),
feature_requirements: None,
managed_hooks: None,
mcp_servers: None,
exec_policy: None,
enforce_residency: ConstrainedWithSource::new(
@@ -628,6 +631,7 @@ pub struct ConfigRequirementsToml {
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
#[serde(rename = "features", alias = "feature_requirements")]
pub feature_requirements: Option<FeatureRequirementsToml>,
pub hooks: Option<ManagedHooksRequirementsToml>,
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
pub apps: Option<AppsRequirementsToml>,
pub rules: Option<RequirementsExecPolicyToml>,
@@ -673,6 +677,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub apps: Option<Sourced<AppsRequirementsToml>>,
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
@@ -707,6 +712,7 @@ impl ConfigRequirementsWithSources {
remote_sandbox_config: _,
allowed_web_search_modes: _,
feature_requirements: _,
hooks: _,
mcp_servers: _,
apps: _,
rules: _,
@@ -734,6 +740,7 @@ impl ConfigRequirementsWithSources {
allowed_sandbox_modes,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
rules,
enforce_residency,
@@ -759,6 +766,7 @@ impl ConfigRequirementsWithSources {
allowed_sandbox_modes,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
apps,
rules,
@@ -774,6 +782,7 @@ impl ConfigRequirementsWithSources {
remote_sandbox_config: None,
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
feature_requirements: feature_requirements.map(|sourced| sourced.value),
hooks: hooks.map(|sourced| sourced.value),
mcp_servers: mcp_servers.map(|sourced| sourced.value),
apps: apps.map(|sourced| sourced.value),
rules: rules.map(|sourced| sourced.value),
@@ -858,6 +867,10 @@ impl ConfigRequirementsToml {
.feature_requirements
.as_ref()
.is_none_or(FeatureRequirementsToml::is_empty)
&& self
.hooks
.as_ref()
.is_none_or(ManagedHooksRequirementsToml::is_empty)
&& self.mcp_servers.is_none()
&& self
.apps
@@ -884,6 +897,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
allowed_sandbox_modes,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
apps: _apps,
rules,
@@ -1064,6 +1078,34 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
};
let feature_requirements =
feature_requirements.filter(|requirements| !requirements.value.is_empty());
let managed_hooks = hooks
.filter(|managed_hooks| managed_hooks.value.handler_count() > 0)
.map(|sourced_hooks| {
let Sourced {
value,
source: requirement_source,
} = sourced_hooks;
let allowed = value;
let allowed_for_error = format!("{allowed:?}");
let requirement_source_for_error = requirement_source.clone();
let constrained = Constrained::new(allowed.clone(), move |candidate| {
if candidate == &allowed {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "hooks",
candidate: format!("{candidate:?}"),
allowed: allowed_for_error.clone(),
requirement_source: requirement_source_for_error.clone(),
})
}
})?;
Ok(ConstrainedWithSource::new(
constrained,
Some(requirement_source),
))
})
.transpose()?;
let enforce_residency = match enforce_residency {
Some(Sourced {
@@ -1106,6 +1148,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
sandbox_policy,
web_search_mode,
feature_requirements,
managed_hooks,
mcp_servers,
exec_policy,
enforce_residency,
@@ -1119,6 +1162,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
#[cfg(test)]
mod tests {
use super::*;
use crate::HookEventsToml;
use anyhow::Result;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
@@ -1147,6 +1191,7 @@ mod tests {
remote_sandbox_config: _,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
apps,
rules,
@@ -1166,6 +1211,7 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
feature_requirements: feature_requirements
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)),
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)),
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
@@ -1210,6 +1256,7 @@ mod tests {
remote_sandbox_config: None,
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
feature_requirements: Some(feature_requirements.clone()),
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1241,6 +1288,7 @@ mod tests {
feature_requirements,
enforce_source.clone(),
)),
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1278,6 +1326,7 @@ mod tests {
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1323,6 +1372,7 @@ mod tests {
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -2232,6 +2282,124 @@ allowed_approvals_reviewers = ["user"]
Ok(())
}
#[test]
fn deserialize_managed_hooks_requirements() -> Result<()> {
let toml_str = r#"
managed_dir = "/enterprise/hooks"
windows_managed_dir = 'C:\enterprise\hooks'
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "python3 /enterprise/hooks/pre.py"
timeout = 10
statusMessage = "checking"
"#;
let hooks: ManagedHooksRequirementsToml = from_str(toml_str)?;
assert_eq!(
hooks.managed_dir.as_deref(),
Some(std::path::Path::new("/enterprise/hooks"))
);
assert_eq!(hooks.handler_count(), 1);
assert_eq!(hooks.hooks.pre_tool_use.len(), 1);
Ok(())
}
#[test]
fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> {
let mut target = ConfigRequirementsWithSources::default();
target.merge_unset_fields(
RequirementSource::CloudRequirements,
from_str::<ConfigRequirementsToml>(
r#"
[hooks]
managed_dir = "/cloud/hooks"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /cloud/hooks/pre.py"
"#,
)?,
);
target.merge_unset_fields(
RequirementSource::SystemRequirementsToml {
file: system_requirements_toml_file_for_test()?,
},
from_str::<ConfigRequirementsToml>(
r#"
[hooks]
managed_dir = "/system/hooks"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /system/hooks/pre.py"
"#,
)?,
);
assert_eq!(
target
.hooks
.as_ref()
.and_then(|hooks| hooks.value.managed_dir.as_ref())
.map(std::path::PathBuf::as_path),
Some(std::path::Path::new("/cloud/hooks"))
);
assert_eq!(
target.hooks.as_ref().map(|hooks| hooks.source.clone()),
Some(RequirementSource::CloudRequirements)
);
Ok(())
}
#[test]
fn managed_hooks_constraint_rejects_drift() -> Result<()> {
let config: ConfigRequirementsToml = from_str(
r#"
[hooks]
managed_dir = "/enterprise/hooks"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /enterprise/hooks/pre.py"
"#,
)?;
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
let mut managed_hooks = requirements
.managed_hooks
.expect("expected managed hooks requirements");
let err = managed_hooks
.set(ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/other/hooks")),
windows_managed_dir: None,
hooks: HookEventsToml::default(),
})
.expect_err("managed hooks should reject drift");
assert!(matches!(
err,
ConstraintError::InvalidValue {
field_name: "hooks",
requirement_source: RequirementSource::Unknown,
..
}
));
Ok(())
}
#[test]
fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> {
let toml_str = r#"

View File

@@ -4,6 +4,7 @@ use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::Path;
use crate::HookEventsToml;
use crate::permissions_toml::PermissionsToml;
use crate::profile_toml::ConfigProfile;
use crate::types::AnalyticsConfigToml;
@@ -332,6 +333,9 @@ pub struct ConfigToml {
/// User-level skill config entries keyed by SKILL.md path.
pub skills: Option<SkillsConfig>,
/// Lifecycle hooks configured inline in TOML.
pub hooks: Option<HookEventsToml>,
/// User-level plugin config entries keyed by plugin name.
#[serde(default)]
pub plugins: HashMap<String, PluginConfig>,

View File

@@ -0,0 +1,148 @@
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct HooksFile {
#[serde(default)]
pub hooks: HookEventsToml,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct HookEventsToml {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "PermissionRequest", default)]
pub permission_request: Vec<MatcherGroup>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
impl HookEventsToml {
pub fn is_empty(&self) -> bool {
let Self {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = self;
pre_tool_use.is_empty()
&& permission_request.is_empty()
&& post_tool_use.is_empty()
&& session_start.is_empty()
&& user_prompt_submit.is_empty()
&& stop.is_empty()
}
pub fn handler_count(&self) -> usize {
let Self {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = self;
[
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
]
.into_iter()
.flatten()
.map(|group| group.hooks.len())
.sum()
}
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 6] {
[
(HookEventName::PreToolUse, self.pre_tool_use),
(HookEventName::PermissionRequest, self.permission_request),
(HookEventName::PostToolUse, self.post_tool_use),
(HookEventName::SessionStart, self.session_start),
(HookEventName::UserPromptSubmit, self.user_prompt_submit),
(HookEventName::Stop, self.stop),
]
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MatcherGroup {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub hooks: Vec<HookHandlerConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
pub enum HookHandlerConfig {
#[serde(rename = "command")]
Command {
command: String,
#[serde(default, rename = "timeout")]
timeout_sec: Option<u64>,
#[serde(default)]
r#async: bool,
#[serde(default, rename = "statusMessage")]
status_message: Option<String>,
},
#[serde(rename = "prompt")]
Prompt {},
#[serde(rename = "agent")]
Agent {},
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedHooksRequirementsToml {
pub managed_dir: Option<PathBuf>,
pub windows_managed_dir: Option<PathBuf>,
#[serde(flatten)]
pub hooks: HookEventsToml,
}
impl ManagedHooksRequirementsToml {
pub fn is_empty(&self) -> bool {
let Self {
managed_dir,
windows_managed_dir,
hooks,
} = self;
managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty()
}
pub fn handler_count(&self) -> usize {
self.hooks.handler_count()
}
pub fn managed_dir_for_current_platform(&self) -> Option<&Path> {
#[cfg(windows)]
{
self.windows_managed_dir.as_deref()
}
#[cfg(not(windows))]
{
self.managed_dir.as_deref()
}
}
}
#[cfg(test)]
#[path = "hooks_tests.rs"]
mod tests;

View File

@@ -0,0 +1,119 @@
use pretty_assertions::assert_eq;
use super::HookEventsToml;
use super::HookHandlerConfig;
use super::HooksFile;
use super::ManagedHooksRequirementsToml;
use super::MatcherGroup;
#[test]
fn hooks_file_deserializes_existing_json_shape() {
let parsed: HooksFile = serde_json::from_str(
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 /tmp/pre.py",
"timeout": 10,
"statusMessage": "checking"
}
]
}
]
}
}"#,
)
.expect("hooks.json should deserialize");
assert_eq!(
parsed,
HooksFile {
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}
);
}
#[test]
fn hook_events_deserialize_from_toml_arrays_of_tables() {
let parsed: HookEventsToml = toml::from_str(
r#"
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "python3 /tmp/pre.py"
timeout = 10
statusMessage = "checking"
"#,
)
.expect("hook events TOML should deserialize");
assert_eq!(
parsed,
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
}
);
}
#[test]
fn managed_hooks_requirements_flatten_hook_events() {
let parsed: ManagedHooksRequirementsToml = toml::from_str(
r#"
managed_dir = "/enterprise/place"
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "python3 /enterprise/place/pre.py"
"#,
)
.expect("requirements hooks TOML should deserialize");
assert_eq!(
parsed,
ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
windows_managed_dir: None,
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /enterprise/place/pre.py".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
},
}
);
}

View File

@@ -4,6 +4,7 @@ pub mod config_toml;
mod constraint;
mod diagnostics;
mod fingerprint;
mod hook_config;
mod host_name;
mod key_aliases;
mod marketplace_edit;
@@ -27,6 +28,8 @@ pub const CONFIG_TOML_FILE: &str = "config.toml";
pub use cloud_requirements::CloudRequirementsLoadError;
pub use cloud_requirements::CloudRequirementsLoadErrorCode;
pub use cloud_requirements::CloudRequirementsLoader;
pub use codex_app_server_protocol::ConfigLayerSource;
pub use codex_utils_absolute_path::AbsolutePathBuf;
pub use config_requirements::AppRequirementToml;
pub use config_requirements::AppsRequirementsToml;
pub use config_requirements::ConfigRequirements;
@@ -65,6 +68,11 @@ pub use diagnostics::format_config_error;
pub use diagnostics::format_config_error_with_source;
pub use diagnostics::io_error_from_config_error;
pub use fingerprint::version_for_toml;
pub use hook_config::HookEventsToml;
pub use hook_config::HookHandlerConfig;
pub use hook_config::HooksFile;
pub use hook_config::ManagedHooksRequirementsToml;
pub use hook_config::MatcherGroup;
pub use host_name::host_name;
pub use marketplace_edit::MarketplaceConfigUpdate;
pub use marketplace_edit::RemoveMarketplaceConfigOutcome;
@@ -106,5 +114,4 @@ pub use thread_config::ThreadConfigLoadErrorCode;
pub use thread_config::ThreadConfigLoader;
pub use thread_config::ThreadConfigSource;
pub use thread_config::UserThreadConfig;
pub use codex_app_server_protocol::ConfigLayerSource;
pub use toml::Value as TomlValue;