mirror of
https://github.com/openai/codex.git
synced 2026-05-11 14:52:36 +00:00
Compare commits
8 Commits
jif/sqlite
...
codex/mana
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9782226c48 | ||
|
|
4c71499139 | ||
|
|
ca1fe83337 | ||
|
|
653bc5e8e0 | ||
|
|
53b6cafbae | ||
|
|
d20d41edcc | ||
|
|
534fa89a1b | ||
|
|
f781d91ee5 |
@@ -7549,6 +7549,12 @@
|
||||
},
|
||||
"ConfigRequirements": {
|
||||
"properties": {
|
||||
"allowManagedHooksOnly": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowedApprovalPolicies": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AskForApproval"
|
||||
|
||||
@@ -4005,6 +4005,12 @@
|
||||
},
|
||||
"ConfigRequirements": {
|
||||
"properties": {
|
||||
"allowManagedHooksOnly": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowedApprovalPolicies": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AskForApproval"
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
},
|
||||
"ConfigRequirements": {
|
||||
"properties": {
|
||||
"allowManagedHooksOnly": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowedApprovalPolicies": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AskForApproval"
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user