Compare commits

...

8 Commits

Author SHA1 Message Date
Andrei Eternal
9782226c48 codex: restack managed hooks PR on main 2026-05-05 21:34:05 -07:00
Andrei Eternal
4c71499139 codex: clean up managed hooks rebase onto hook trust 2026-05-05 21:16:35 -07:00
Andrei Eternal
ca1fe83337 codex: fix argument comment lint on PR #20319 2026-05-05 21:16:35 -07:00
Andrei Eternal
653bc5e8e0 codex: move allow_managed_hooks_only under [hooks] 2026-05-05 21:16:35 -07:00
Andrei Eternal
53b6cafbae Fix managed hooks discovery edge cases 2026-05-05 21:16:34 -07:00
Andrei Eternal
d20d41edcc Fix hooks argument lint 2026-05-05 21:16:34 -07:00
Andrei Eternal
534fa89a1b Fix managed hooks CI fixture 2026-05-05 21:16:34 -07:00
Andrei Eternal
f781d91ee5 Add managed-hooks-only requirement 2026-05-05 21:16:30 -07:00
18 changed files with 967 additions and 75 deletions

View File

@@ -7549,6 +7549,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/v2/AskForApproval"

View File

@@ -4005,6 +4005,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/AskForApproval"

View File

@@ -62,6 +62,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/AskForApproval"

View File

@@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};

View File

@@ -358,6 +358,7 @@ pub struct ConfigRequirements {
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub feature_requirements: Option<BTreeMap<String, bool>>,
#[experimental("configRequirements/read.hooks")]
pub hooks: Option<ManagedHooksRequirements>,

View File

@@ -1807,6 +1807,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
enforce_residency: None,

View File

@@ -232,7 +232,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread

View File

@@ -412,6 +412,10 @@ impl ConfigRequestProcessor {
}
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
let allow_managed_hooks_only = requirements
.hooks
.as_ref()
.and_then(|hooks| hooks.allow_managed_hooks_only);
ConfigRequirements {
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
policies
@@ -441,6 +445,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
}
normalized
}),
allow_managed_hooks_only,
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
@@ -454,6 +459,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
let ManagedHooksRequirementsToml {
allow_managed_hooks_only: _,
managed_dir,
windows_managed_dir,
hooks,
@@ -617,3 +623,61 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) ->
}));
error
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_config::ConfigRequirementsToml;
use codex_config::HookEventsToml;
use codex_config::ManagedHooksRequirementsToml;
use pretty_assertions::assert_eq;
use super::map_requirements_toml_to_api;
#[test]
fn map_requirements_toml_to_api_reads_allow_managed_hooks_only_from_hooks() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
hooks: Some(ManagedHooksRequirementsToml {
allow_managed_hooks_only: Some(true),
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
hooks: HookEventsToml::default(),
}),
..ConfigRequirementsToml::default()
});
assert_eq!(mapped.allow_managed_hooks_only, Some(true));
assert_eq!(
mapped.hooks.map(|hooks| {
(
hooks.managed_dir,
hooks.windows_managed_dir,
hooks.pre_tool_use,
hooks.permission_request,
hooks.post_tool_use,
hooks.session_start,
hooks.user_prompt_submit,
hooks.stop,
)
}),
Some((
Some(PathBuf::from("/enterprise/hooks")),
Some(PathBuf::from(r"C:\enterprise\hooks")),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
))
);
}
#[test]
fn map_requirements_toml_to_api_leaves_allow_managed_hooks_only_unset_without_hooks() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml::default());
assert_eq!(mapped.allow_managed_hooks_only, None);
}
}

View File

@@ -5,6 +5,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as _;
use serde::de::value::Error as ValueDeserializerError;
@@ -86,6 +87,7 @@ pub struct ConfigRequirements {
pub approvals_reviewer: ConstrainedWithSource<ApprovalsReviewer>,
pub permission_profile: ConstrainedWithSource<PermissionProfile>,
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
@@ -119,6 +121,7 @@ impl Default for ConfigRequirements {
Constrained::allow_any(WebSearchMode::Cached),
/*source*/ None,
),
allow_managed_hooks_only: None,
feature_requirements: None,
managed_hooks: None,
mcp_servers: None,
@@ -635,13 +638,33 @@ pub(crate) fn merge_enablement_settings_descending(
}
/// Base config deserialized from system `requirements.toml` or MDM.
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConfigRequirementsToml {
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub remote_sandbox_config: Option<Vec<RemoteSandboxConfigToml>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub feature_requirements: Option<FeatureRequirementsToml>,
pub hooks: Option<ManagedHooksRequirementsToml>,
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
pub plugins: Option<BTreeMap<String, PluginRequirementsToml>>,
pub apps: Option<AppsRequirementsToml>,
pub rules: Option<RequirementsExecPolicyToml>,
pub enforce_residency: Option<ResidencyRequirement>,
pub network: Option<NetworkRequirementsToml>,
pub permissions: Option<PermissionsRequirementsToml>,
pub guardian_policy_config: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
struct ConfigRequirementsTomlHelper {
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub remote_sandbox_config: Option<Vec<RemoteSandboxConfigToml>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub allow_managed_hooks_only: Option<bool>,
#[serde(rename = "features", alias = "feature_requirements")]
pub feature_requirements: Option<FeatureRequirementsToml>,
pub hooks: Option<ManagedHooksRequirementsToml>,
@@ -656,6 +679,56 @@ pub struct ConfigRequirementsToml {
pub guardian_policy_config: Option<String>,
}
impl<'de> Deserialize<'de> for ConfigRequirementsToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let ConfigRequirementsTomlHelper {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
remote_sandbox_config,
allowed_web_search_modes,
allow_managed_hooks_only,
feature_requirements,
hooks,
mcp_servers,
plugins,
apps,
rules,
enforce_residency,
network,
permissions,
guardian_policy_config,
} = ConfigRequirementsTomlHelper::deserialize(deserializer)?;
if allow_managed_hooks_only.is_some() {
return Err(D::Error::custom(
"`allow_managed_hooks_only` must be set under `[hooks]` in requirements.toml",
));
}
Ok(Self {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
remote_sandbox_config,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
plugins,
apps,
rules,
enforce_residency,
network,
permissions,
guardian_policy_config,
})
}
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct RemoteSandboxConfigToml {
pub hostname_patterns: Vec<String>,
@@ -684,12 +757,42 @@ impl<T> std::ops::Deref for Sourced<T> {
}
}
fn split_managed_hooks_requirements(
hooks: Option<ManagedHooksRequirementsToml>,
) -> (Option<bool>, Option<ManagedHooksRequirementsToml>) {
let Some(mut hooks) = hooks else {
return (None, None);
};
let allow_managed_hooks_only = hooks.allow_managed_hooks_only.take();
let hooks = (!hooks.is_empty()).then_some(hooks);
(allow_managed_hooks_only, hooks)
}
fn combine_managed_hooks_requirements(
allow_managed_hooks_only: Option<bool>,
hooks: Option<ManagedHooksRequirementsToml>,
) -> Option<ManagedHooksRequirementsToml> {
match hooks {
Some(mut hooks) => {
hooks.allow_managed_hooks_only = allow_managed_hooks_only;
Some(hooks)
}
None => {
allow_managed_hooks_only.map(|allow_managed_hooks_only| ManagedHooksRequirementsToml {
allow_managed_hooks_only: Some(allow_managed_hooks_only),
..ManagedHooksRequirementsToml::default()
})
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConfigRequirementsWithSources {
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
pub allowed_approvals_reviewers: Option<Sourced<Vec<ApprovalsReviewer>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
@@ -746,6 +849,8 @@ impl ConfigRequirementsWithSources {
{
other.guardian_policy_config = None;
}
let (incoming_allow_managed_hooks_only, incoming_hooks) =
split_managed_hooks_requirements(other.hooks.take());
fill_missing_take!(
self,
other,
@@ -756,7 +861,6 @@ impl ConfigRequirementsWithSources {
allowed_sandbox_modes,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
plugins,
rules,
@@ -766,6 +870,16 @@ impl ConfigRequirementsWithSources {
guardian_policy_config,
}
);
if self.allow_managed_hooks_only.is_none()
&& let Some(value) = incoming_allow_managed_hooks_only
{
self.allow_managed_hooks_only = Some(Sourced::new(value, source.clone()));
}
if self.hooks.is_none()
&& let Some(value) = incoming_hooks
{
self.hooks = Some(Sourced::new(value, source.clone()));
}
if let Some(incoming_apps) = other.apps.take() {
if let Some(existing_apps) = self.apps.as_mut() {
@@ -782,6 +896,7 @@ impl ConfigRequirementsWithSources {
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_web_search_modes,
allow_managed_hooks_only,
feature_requirements,
hooks,
mcp_servers,
@@ -800,7 +915,10 @@ 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),
hooks: combine_managed_hooks_requirements(
allow_managed_hooks_only.map(|sourced| sourced.value),
hooks.map(|sourced| sourced.value),
),
mcp_servers: mcp_servers.map(|sourced| sourced.value),
plugins: plugins.map(|sourced| sourced.value),
apps: apps.map(|sourced| sourced.value),
@@ -919,6 +1037,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_web_search_modes,
allow_managed_hooks_only,
feature_requirements,
hooks,
mcp_servers,
@@ -1089,9 +1208,10 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
.filter(|managed_hooks| managed_hooks.value.handler_count() > 0)
.map(|sourced_hooks| {
let Sourced {
value,
mut value,
source: requirement_source,
} = sourced_hooks;
value.allow_managed_hooks_only = None;
let allowed = value;
let allowed_for_error = format!("{allowed:?}");
let requirement_source_for_error = requirement_source.clone();
@@ -1154,6 +1274,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
approvals_reviewer,
permission_profile,
web_search_mode,
allow_managed_hooks_only,
feature_requirements,
managed_hooks,
mcp_servers,
@@ -1237,6 +1358,7 @@ mod tests {
permissions,
guardian_policy_config,
} = toml;
let (allow_managed_hooks_only, hooks) = split_managed_hooks_requirements(hooks);
ConfigRequirementsWithSources {
allowed_approval_policies: allowed_approval_policies
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
@@ -1246,6 +1368,8 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_web_search_modes: allowed_web_search_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allow_managed_hooks_only: allow_managed_hooks_only
.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)),
@@ -1262,6 +1386,63 @@ mod tests {
}
}
#[test]
fn deserialize_allow_managed_hooks_only_under_hooks() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
[hooks]
allow_managed_hooks_only = true
"#,
)?;
assert_eq!(
requirements
.hooks
.as_ref()
.and_then(|hooks| hooks.allow_managed_hooks_only),
Some(true)
);
assert!(!requirements.is_empty());
Ok(())
}
#[test]
fn allow_managed_hooks_only_false_is_still_configured_under_hooks() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
[hooks]
allow_managed_hooks_only = false
"#,
)?;
assert_eq!(
requirements
.hooks
.as_ref()
.and_then(|hooks| hooks.allow_managed_hooks_only),
Some(false)
);
assert!(!requirements.is_empty());
Ok(())
}
#[test]
fn top_level_allow_managed_hooks_only_is_rejected() {
let err = from_str::<ConfigRequirementsToml>(
r#"
allow_managed_hooks_only = true
"#,
)
.expect_err("top-level allow_managed_hooks_only should be rejected");
assert!(
err.to_string().contains(
"`allow_managed_hooks_only` must be set under `[hooks]` in requirements.toml",
),
"unexpected error: {err}"
);
}
#[test]
fn merge_unset_fields_copies_every_field_and_sets_sources() {
let mut target = ConfigRequirementsWithSources::default();
@@ -1294,7 +1475,10 @@ mod tests {
remote_sandbox_config: None,
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
feature_requirements: Some(feature_requirements.clone()),
hooks: None,
hooks: Some(ManagedHooksRequirementsToml {
allow_managed_hooks_only: Some(true),
..ManagedHooksRequirementsToml::default()
}),
mcp_servers: None,
plugins: None,
apps: None,
@@ -1323,6 +1507,10 @@ mod tests {
allowed_web_search_modes,
enforce_source.clone(),
)),
allow_managed_hooks_only: Some(Sourced::new(
/*value*/ true,
enforce_source.clone(),
)),
feature_requirements: Some(Sourced::new(
feature_requirements,
enforce_source.clone(),
@@ -1365,6 +1553,7 @@ mod tests {
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -1412,6 +1601,7 @@ mod tests {
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -2343,6 +2533,7 @@ allowed_approvals_reviewers = ["user"]
#[test]
fn deserialize_managed_hooks_requirements() -> Result<()> {
let toml_str = r#"
allow_managed_hooks_only = true
managed_dir = "/enterprise/hooks"
windows_managed_dir = 'C:\enterprise\hooks'
@@ -2361,6 +2552,7 @@ statusMessage = "checking"
hooks.managed_dir.as_deref(),
Some(std::path::Path::new("/enterprise/hooks"))
);
assert_eq!(hooks.allow_managed_hooks_only, Some(true));
assert_eq!(hooks.handler_count(), 1);
assert_eq!(hooks.hooks.pre_tool_use.len(), 1);
Ok(())
@@ -2419,6 +2611,57 @@ command = "python3 /system/hooks/pre.py"
Ok(())
}
#[test]
fn merge_unset_fields_preserves_existing_hooks_and_fills_hook_policy() -> 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]
allow_managed_hooks_only = true
"#,
)?,
);
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.allow_managed_hooks_only,
Some(Sourced::new(
/*value*/ true,
RequirementSource::SystemRequirementsToml {
file: system_requirements_toml_file_for_test()?,
},
))
);
Ok(())
}
#[test]
fn managed_hooks_constraint_rejects_drift() -> Result<()> {
let config: ConfigRequirementsToml = from_str(
@@ -2441,6 +2684,7 @@ command = "python3 /enterprise/hooks/pre.py"
let err = managed_hooks
.set(ManagedHooksRequirementsToml {
allow_managed_hooks_only: None,
managed_dir: Some(std::path::PathBuf::from("/other/hooks")),
windows_managed_dir: None,
hooks: HookEventsToml::default(),

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -13,7 +14,8 @@ pub struct HooksFile {
pub hooks: HookEventsToml,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct HooksToml {
#[serde(flatten)]
pub events: HookEventsToml,
@@ -127,6 +129,7 @@ pub enum HookHandlerConfig {
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedHooksRequirementsToml {
pub allow_managed_hooks_only: Option<bool>,
pub managed_dir: Option<PathBuf>,
pub windows_managed_dir: Option<PathBuf>,
#[serde(flatten)]
@@ -136,11 +139,15 @@ pub struct ManagedHooksRequirementsToml {
impl ManagedHooksRequirementsToml {
pub fn is_empty(&self) -> bool {
let Self {
allow_managed_hooks_only,
managed_dir,
windows_managed_dir,
hooks,
} = self;
managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty()
allow_managed_hooks_only.is_none()
&& managed_dir.is_none()
&& windows_managed_dir.is_none()
&& hooks.is_empty()
}
pub fn handler_count(&self) -> usize {
@@ -160,6 +167,59 @@ impl ManagedHooksRequirementsToml {
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
struct HooksTomlHelper {
#[serde(rename = "PreToolUse", default)]
pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "PermissionRequest", default)]
permission_request: Vec<MatcherGroup>,
#[serde(rename = "PostToolUse", default)]
post_tool_use: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
stop: Vec<MatcherGroup>,
#[serde(default)]
state: BTreeMap<String, HookStateToml>,
}
impl From<HooksTomlHelper> for HooksToml {
fn from(value: HooksTomlHelper) -> Self {
let HooksTomlHelper {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
state,
} = value;
Self {
events: HookEventsToml {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
},
state,
}
}
}
impl<'de> Deserialize<'de> for HooksToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
HooksTomlHelper::deserialize(deserializer).map(Into::into)
}
}
#[cfg(test)]
#[path = "hooks_tests.rs"]
mod tests;

View File

@@ -128,10 +128,27 @@ command = "python3 /tmp/pre.py"
);
}
#[test]
fn hooks_toml_rejects_requirements_only_keys() {
let err = toml::from_str::<HooksToml>(
r#"
allow_managed_hooks_only = true
managed_dir = "/enterprise/place"
"#,
)
.expect_err("config hooks TOML should reject managed requirements keys");
assert!(
err.to_string().contains("unknown field"),
"unexpected error: {err}"
);
}
#[test]
fn managed_hooks_requirements_flatten_hook_events() {
let parsed: ManagedHooksRequirementsToml = toml::from_str(
r#"
allow_managed_hooks_only = true
managed_dir = "/enterprise/place"
[[PreToolUse]]
@@ -147,6 +164,7 @@ command = "python3 /enterprise/place/pre.py"
assert_eq!(
parsed,
ManagedHooksRequirementsToml {
allow_managed_hooks_only: Some(true),
managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
windows_managed_dir: None,
hooks: HookEventsToml {

View File

@@ -110,6 +110,17 @@ impl ConfigLayerEntry {
self.disabled_reason.is_some()
}
/// Returns true for config layers controlled by managed policy sources.
pub fn is_managed(&self) -> bool {
matches!(
self.name,
ConfigLayerSource::Mdm { .. }
| ConfigLayerSource::System { .. }
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm
)
}
pub fn raw_toml(&self) -> Option<&str> {
self.raw_toml.as_deref()
}

View File

@@ -245,6 +245,60 @@ fn schema_error_points_to_feature_value() {
assert_eq!(error.range.start.column, value_column);
}
#[tokio::test]
async fn returns_config_error_for_requirements_only_hook_policy_in_user_config() {
let tmp = tempdir().expect("tempdir");
let contents = "[hooks]\nallow_managed_hooks_only = true";
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let err = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.fallback_cwd(Some(tmp.path().to_path_buf()))
.build()
.await
.expect_err("expected error");
let config_error = config_error_from_io(&err);
let _guard = codex_utils_absolute_path::AbsolutePathBufGuard::new(tmp.path());
let expected_config_error =
codex_config::config_error_from_typed_toml::<ConfigToml>(&config_path, contents)
.expect("schema error");
assert_eq!(config_error, &expected_config_error);
}
#[tokio::test]
async fn requirements_only_hook_policy_at_user_config_root_does_not_enable_policy() {
let tmp = tempdir().expect("tempdir");
let contents = "allow_managed_hooks_only = true";
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let config = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.fallback_cwd(Some(tmp.path().to_path_buf()))
.build()
.await
.expect("config should load");
assert!(
config
.config_layer_stack
.requirements()
.allow_managed_hooks_only
.is_none()
);
assert!(
config
.config_layer_stack
.requirements_toml()
.hooks
.as_ref()
.and_then(|hooks| hooks.allow_managed_hooks_only)
.is_none()
);
}
#[tokio::test]
async fn merges_managed_config_layer_on_top() {
let tmp = tempdir().expect("tempdir");
@@ -1150,6 +1204,7 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result
let requirements = ConfigRequirementsToml {
hooks: Some(codex_config::ManagedHooksRequirementsToml {
allow_managed_hooks_only: None,
managed_dir: Some(managed_dir.clone()),
windows_managed_dir: None,
hooks: codex_config::HookEventsToml {

View File

@@ -2152,6 +2152,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,

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
@@ -19,7 +20,6 @@ use codex_plugin::PluginHookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use super::ConfiguredHandler;
use super::HookListEntry;
@@ -46,6 +46,17 @@ struct HookHandlerSource<'a> {
plugin_id: Option<String>,
}
#[derive(Clone, Copy)]
struct HookDiscoveryPolicy {
allow_managed_hooks_only: bool,
}
impl HookDiscoveryPolicy {
fn allows(self, source: &HookHandlerSource<'_>) -> bool {
!self.allow_managed_hooks_only || source.is_managed
}
}
pub(crate) fn discover_handlers(
config_layer_stack: Option<&ConfigLayerStack>,
plugin_hook_sources: Vec<PluginHookSource>,
@@ -56,6 +67,15 @@ pub(crate) fn discover_handlers(
let mut warnings = plugin_hook_load_warnings;
let mut display_order = 0_i64;
let hook_states = hook_states_from_stack(config_layer_stack);
let policy = HookDiscoveryPolicy {
allow_managed_hooks_only: config_layer_stack.is_some_and(|config_layer_stack| {
config_layer_stack
.requirements()
.allow_managed_hooks_only
.as_ref()
.is_some_and(|requirement| requirement.value)
}),
};
if let Some(config_layer_stack) = config_layer_stack {
append_managed_requirement_handlers(
@@ -65,6 +85,7 @@ pub(crate) fn discover_handlers(
&mut display_order,
config_layer_stack,
&hook_states,
policy,
);
for layer in config_layer_stack.get_layers(
@@ -72,6 +93,19 @@ pub(crate) fn discover_handlers(
/*include_disabled*/ false,
) {
let (hook_source, is_managed) = hook_metadata_for_config_layer_source(&layer.name);
let policy_path = config_toml_source_path(layer);
let policy_source = HookHandlerSource {
path: &policy_path,
key_source: policy_path.display().to_string(),
source: hook_source,
is_managed,
hook_states: &hook_states,
env: HashMap::new(),
plugin_id: None,
};
if !policy.allows(&policy_source) {
continue;
}
let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings);
let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings);
@@ -103,6 +137,7 @@ pub(crate) fn discover_handlers(
plugin_id: None,
},
hook_events,
policy,
);
}
}
@@ -115,6 +150,7 @@ pub(crate) fn discover_handlers(
&mut display_order,
plugin_hook_sources,
&hook_states,
policy,
);
DiscoveryResult {
@@ -131,15 +167,12 @@ fn append_managed_requirement_handlers(
display_order: &mut i64,
config_layer_stack: &ConfigLayerStack,
hook_states: &HashMap<String, HookStateToml>,
policy: HookDiscoveryPolicy,
) {
let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else {
return;
};
let Some(source_path) =
managed_hooks_source_path(managed_hooks.get(), managed_hooks.source.as_ref(), warnings)
else {
return;
};
let source_path = managed_hooks_source_path(managed_hooks.get(), managed_hooks.source.as_ref());
append_hook_events(
handlers,
hook_entries,
@@ -155,6 +188,7 @@ fn append_managed_requirement_handlers(
plugin_id: None,
},
managed_hooks.get().hooks.clone(),
policy,
);
}
@@ -165,6 +199,7 @@ fn append_plugin_hook_sources(
display_order: &mut i64,
plugin_hook_sources: Vec<PluginHookSource>,
hook_states: &HashMap<String, HookStateToml>,
policy: HookDiscoveryPolicy,
) {
for source in plugin_hook_sources {
let PluginHookSource {
@@ -200,6 +235,7 @@ fn append_plugin_hook_sources(
plugin_id: Some(plugin_id),
},
hooks,
policy,
);
}
}
@@ -207,45 +243,35 @@ fn append_plugin_hook_sources(
fn managed_hooks_source_path(
managed_hooks: &ManagedHooksRequirementsToml,
requirement_source: Option<&RequirementSource>,
warnings: &mut Vec<String>,
) -> Option<AbsolutePathBuf> {
let source = requirement_source
.map(ToString::to_string)
.unwrap_or_else(|| "managed requirements".to_string());
let Some(source_path) = managed_hooks.managed_dir_for_current_platform() else {
warnings.push(format!(
"skipping managed hooks from {source}: no managed hook directory is configured for this platform"
));
return None;
};
) -> AbsolutePathBuf {
if let Some(source_path) = managed_hooks.managed_dir_for_current_platform()
&& source_path.is_absolute()
&& let Ok(source_path) = AbsolutePathBuf::from_absolute_path(source_path)
{
return source_path;
}
if !source_path.is_absolute() {
warnings.push(format!(
"skipping managed hooks from {source}: managed hook directory {} is not absolute",
source_path.display()
));
None
} else if !source_path.exists() {
warnings.push(format!(
"skipping managed hooks from {source}: managed hook directory {} does not exist",
source_path.display()
));
None
} else if !source_path.is_dir() {
warnings.push(format!(
"skipping managed hooks from {source}: managed hook directory {} is not a directory",
source_path.display()
));
None
} else {
AbsolutePathBuf::from_absolute_path(source_path)
.inspect_err(|err| {
warnings.push(format!(
"skipping managed hooks from {source}: could not normalize managed hook directory {}: {err}",
source_path.display()
));
})
.ok()
fallback_managed_hooks_source_path(requirement_source)
}
fn fallback_managed_hooks_source_path(
requirement_source: Option<&RequirementSource>,
) -> AbsolutePathBuf {
match requirement_source {
Some(RequirementSource::SystemRequirementsToml { file })
| Some(RequirementSource::LegacyManagedConfigTomlFromFile { file }) => file.clone(),
Some(RequirementSource::MdmManagedPreferences { domain, key }) => {
synthetic_layer_path(&format!("<mdm:{domain}:{key}>/requirements.toml"))
}
Some(RequirementSource::CloudRequirements) => {
synthetic_layer_path("<cloud-requirements>/requirements.toml")
}
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")
}
Some(RequirementSource::Unknown) | None => {
synthetic_layer_path("<managed-requirements>/requirements.toml")
}
}
}
@@ -347,7 +373,12 @@ fn append_hook_events(
display_order: &mut i64,
source: HookHandlerSource<'_>,
hook_events: HookEventsToml,
policy: HookDiscoveryPolicy,
) {
if !policy.allows(&source) {
return;
}
for (event_name, groups) in hook_events.into_matcher_groups() {
append_matcher_groups(
handlers,

View File

@@ -15,6 +15,7 @@ use codex_config::HookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::TomlValue;
use codex_plugin::PluginHookSource;
use codex_plugin::PluginId;
@@ -40,6 +41,7 @@ fn managed_hooks_for_current_platform(
) -> ManagedHooksRequirementsToml {
let managed_dir = managed_dir.as_ref().to_path_buf();
ManagedHooksRequirementsToml {
allow_managed_hooks_only: None,
managed_dir: if cfg!(windows) {
None
} else {
@@ -54,6 +56,96 @@ fn managed_hooks_for_current_platform(
}
}
fn pre_tool_use_hook_events(command: impl Into<String>) -> HookEventsToml {
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: command.into(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
}
}
fn config_toml_with_pre_tool_use(command: &str) -> TomlValue {
let mut config_toml = TomlValue::Table(Default::default());
let TomlValue::Table(config_table) = &mut config_toml else {
unreachable!("config TOML root should be a table");
};
let mut hooks_table = TomlValue::Table(Default::default());
let TomlValue::Table(hooks_entries) = &mut hooks_table else {
unreachable!("hooks entry should be a table");
};
let mut pre_tool_use_group = TomlValue::Table(Default::default());
let TomlValue::Table(pre_tool_use_group_entries) = &mut pre_tool_use_group else {
unreachable!("PreToolUse group should be a table");
};
pre_tool_use_group_entries.insert(
"matcher".to_string(),
TomlValue::String("^Bash$".to_string()),
);
let mut handler = TomlValue::Table(Default::default());
let TomlValue::Table(handler_entries) = &mut handler else {
unreachable!("PreToolUse handler should be a table");
};
handler_entries.insert("type".to_string(), TomlValue::String("command".to_string()));
handler_entries.insert(
"command".to_string(),
TomlValue::String(command.to_string()),
);
handler_entries.insert("timeout".to_string(), TomlValue::Integer(10));
handler_entries.insert(
"statusMessage".to_string(),
TomlValue::String("checking".to_string()),
);
pre_tool_use_group_entries.insert("hooks".to_string(), TomlValue::Array(vec![handler]));
hooks_entries.insert(
"PreToolUse".to_string(),
TomlValue::Array(vec![pre_tool_use_group]),
);
config_table.insert("hooks".to_string(), hooks_table);
config_toml
}
fn requirements_with_managed_hooks_only(
allow_managed_hooks_only: bool,
managed_hooks: Option<ManagedHooksRequirementsToml>,
) -> (ConfigRequirements, ConfigRequirementsToml) {
let requirements_hooks = match managed_hooks.clone() {
Some(mut hooks) => {
hooks.allow_managed_hooks_only = Some(allow_managed_hooks_only);
Some(hooks)
}
None => Some(ManagedHooksRequirementsToml {
allow_managed_hooks_only: Some(allow_managed_hooks_only),
..ManagedHooksRequirementsToml::default()
}),
};
(
ConfigRequirements {
allow_managed_hooks_only: Some(Sourced::new(
allow_managed_hooks_only,
RequirementSource::CloudRequirements,
)),
managed_hooks: managed_hooks.map(|hooks| {
ConstrainedWithSource::new(
Constrained::allow_any(hooks),
Some(RequirementSource::CloudRequirements),
)
}),
..ConfigRequirements::default()
},
ConfigRequirementsToml {
hooks: requirements_hooks,
..ConfigRequirementsToml::default()
},
)
}
#[tokio::test]
async fn requirements_managed_hooks_execute_from_managed_dir() {
let temp = tempdir().expect("create temp dir");
@@ -453,7 +545,7 @@ fn trusted_plugin_hook_stack(
}
#[test]
fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
fn requirements_managed_hooks_load_when_managed_dir_is_missing() {
let temp = tempdir().expect("create temp dir");
let missing_dir = temp.path().join("missing-managed-hooks");
let managed_hooks = managed_hooks_for_current_platform(
@@ -462,7 +554,7 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: format!("python3 {}", missing_dir.join("pre.py").display()),
command: "echo hi".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
@@ -498,30 +590,314 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
},
);
assert!(engine.warnings().iter().any(|warning| {
warning.contains("managed hook directory")
&& warning.contains("does not exist")
&& warning.contains(&missing_dir.display().to_string())
}));
assert!(engine.warnings().is_empty());
let cwd = cwd();
assert!(
engine
.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd,
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
})
.is_empty()
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd,
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
});
assert_eq!(preview.len(), 1);
assert_eq!(engine.handlers[0].command, "echo hi");
assert_eq!(
engine.handlers[0].source_path,
AbsolutePathBuf::try_from(missing_dir).expect("absolute missing dir")
);
}
#[test]
fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() {
let temp = tempdir().expect("create temp dir");
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
let (requirements, requirements_toml) = requirements_with_managed_hooks_only(
/*allow_managed_hooks_only*/ false, /*managed_hooks*/ None,
);
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: config_path },
config_toml_with_pre_tool_use("python3 /tmp/user-hook.py"),
)],
requirements,
requirements_toml,
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().is_empty());
assert!(engine.handlers.is_empty());
let discovered =
super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new());
assert_eq!(discovered.hook_entries.len(), 1);
assert_eq!(discovered.hook_entries[0].source, HookSource::User);
assert!(!discovered.hook_entries[0].is_managed);
assert_eq!(
discovered.hook_entries[0].trust_status,
HookTrustStatus::Untrusted
);
assert_eq!(
discovered.hook_entries[0].command.as_deref(),
Some("python3 /tmp/user-hook.py")
);
}
#[test]
fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() {
let temp = tempdir().expect("create temp dir");
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
let mut config_toml = config_toml_with_pre_tool_use("python3 /tmp/user-hook.py");
let TomlValue::Table(config_table) = &mut config_toml else {
unreachable!("config TOML root should be a table");
};
let Some(TomlValue::Table(hooks_table)) = config_table.get_mut("hooks") else {
unreachable!("hooks config should be a table");
};
hooks_table.insert(
"allow_managed_hooks_only".to_string(),
TomlValue::Boolean(true),
);
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: config_path },
config_toml,
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().is_empty());
assert!(engine.handlers.is_empty());
let discovered =
super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new());
assert_eq!(discovered.hook_entries.len(), 1);
assert_eq!(discovered.hook_entries[0].source, HookSource::User);
assert!(!discovered.hook_entries[0].is_managed);
assert_eq!(
discovered.hook_entries[0].trust_status,
HookTrustStatus::Untrusted
);
assert_eq!(
discovered.hook_entries[0].command.as_deref(),
Some("python3 /tmp/user-hook.py")
);
}
#[test]
fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() {
let temp = tempdir().expect("create temp dir");
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
let hooks_json_path =
AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path");
fs::write(
hooks_json_path.as_path(),
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 /tmp/json-hook.py"
}
]
}
]
}
}"#,
)
.expect("write hooks.json");
let (requirements, requirements_toml) = requirements_with_managed_hooks_only(
/*allow_managed_hooks_only*/ true, /*managed_hooks*/ None,
);
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: config_path },
config_toml_with_pre_tool_use("python3 /tmp/toml-hook.py"),
)],
requirements,
requirements_toml,
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.handlers.is_empty());
assert!(engine.warnings().is_empty());
}
#[test]
fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() {
let temp = tempdir().expect("create temp dir");
let plugin_root =
AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root");
let plugin_data_root =
AbsolutePathBuf::try_from(temp.path().join("plugin-data")).expect("plugin data root");
let source_path = plugin_root.join("hooks/hooks.json");
let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id");
let plugin_hook_sources = vec![PluginHookSource {
plugin_id,
plugin_root,
plugin_data_root,
source_path,
source_relative_path: "hooks/hooks.json".to_string(),
hooks: pre_tool_use_hook_events("python3 /tmp/plugin-hook.py"),
}];
let (requirements, requirements_toml) = requirements_with_managed_hooks_only(
/*allow_managed_hooks_only*/ true, /*managed_hooks*/ None,
);
let config_layer_stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
plugin_hook_sources,
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.handlers.is_empty());
assert!(engine.warnings().is_empty());
}
#[test]
fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() {
let temp = tempdir().expect("create temp dir");
let managed_dir =
AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path");
fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir");
let system_config_path =
AbsolutePathBuf::try_from(temp.path().join("system").join("config.toml"))
.expect("absolute system config path");
let system_parent = system_config_path
.as_path()
.parent()
.expect("system config parent");
fs::create_dir_all(system_parent).expect("create system config dir");
let legacy_config_path = AbsolutePathBuf::try_from(temp.path().join("managed_config.toml"))
.expect("absolute legacy config path");
let managed_hooks = managed_hooks_for_current_platform(
managed_dir,
pre_tool_use_hook_events("python3 /tmp/requirements-hook.py"),
);
let (requirements, requirements_toml) = requirements_with_managed_hooks_only(
/*allow_managed_hooks_only*/ true,
Some(managed_hooks),
);
let config_layer_stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::Mdm {
domain: "com.openai.codex".to_string(),
key: "config".to_string(),
},
config_toml_with_pre_tool_use("python3 /tmp/mdm-hook.py"),
),
ConfigLayerEntry::new(
ConfigLayerSource::System {
file: system_config_path,
},
config_toml_with_pre_tool_use("python3 /tmp/system-hook.py"),
),
ConfigLayerEntry::new(
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: legacy_config_path,
},
config_toml_with_pre_tool_use("python3 /tmp/legacy-file-hook.py"),
),
ConfigLayerEntry::new(
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
config_toml_with_pre_tool_use("python3 /tmp/legacy-mdm-hook.py"),
),
],
requirements,
requirements_toml,
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().is_empty());
assert_eq!(
engine
.handlers
.iter()
.map(|handler| handler.command.as_str())
.collect::<Vec<_>>(),
vec![
"python3 /tmp/requirements-hook.py",
"python3 /tmp/mdm-hook.py",
"python3 /tmp/system-hook.py",
"python3 /tmp/legacy-file-hook.py",
"python3 /tmp/legacy-mdm-hook.py",
]
);
assert!(engine.handlers.iter().all(|handler| {
matches!(
handler.source,
HookSource::System
| HookSource::Mdm
| HookSource::CloudRequirements
| HookSource::LegacyManagedConfigFile
| HookSource::LegacyManagedConfigMdm
)
}));
}
#[test]
fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
let temp = tempdir().expect("create temp dir");

View File

@@ -272,6 +272,11 @@ fn render_session_flag_details(config: &TomlValue) -> Vec<Line<'static>> {
fn format_managed_hooks_requirements(hooks: &ManagedHooksRequirementsToml) -> String {
let mut parts = Vec::new();
if let Some(allow_managed_hooks_only) = hooks.allow_managed_hooks_only {
parts.push(format!(
"allow_managed_hooks_only={allow_managed_hooks_only}"
));
}
if let Some(managed_dir) = hooks.managed_dir.as_ref() {
parts.push(format!("managed_dir={}", managed_dir.display()));
}
@@ -919,6 +924,7 @@ approval_policy = "never"
let requirements = ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(ManagedHooksRequirementsToml {
allow_managed_hooks_only: None,
managed_dir: Some(if cfg!(windows) {
std::path::PathBuf::from(r"C:\enterprise\hooks")
} else {

View File

@@ -5,3 +5,9 @@ For basic configuration instructions, see [this documentation](https://developer
For advanced configuration instructions, see [this documentation](https://developers.openai.com/codex/config-advanced).
For a full configuration reference, see [this documentation](https://developers.openai.com/codex/config-reference).
## Lifecycle hooks
Admins can set `allow_managed_hooks_only = true` under `[hooks]` in
`requirements.toml` to ignore user, project, and session hook configs while
still allowing managed hooks from requirements and managed config layers.