mirror of
https://github.com/openai/codex.git
synced 2026-05-06 12:26:38 +00:00
Compare commits
52 Commits
pr20381
...
codex/viya
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7402400e3 | ||
|
|
5983bd41ec | ||
|
|
2d9f594a74 | ||
|
|
35dfbccf61 | ||
|
|
9b4fdd566f | ||
|
|
71a9a27674 | ||
|
|
f2e03454f1 | ||
|
|
645f872180 | ||
|
|
991afec90d | ||
|
|
4982c9751f | ||
|
|
28552715ba | ||
|
|
9d16a0a52e | ||
|
|
6ea0cae246 | ||
|
|
363f1cc94c | ||
|
|
85f620d5ac | ||
|
|
c86f766d90 | ||
|
|
bf6213151d | ||
|
|
032c32d778 | ||
|
|
13f7f05f3d | ||
|
|
95f8b4e353 | ||
|
|
a0bb3073b7 | ||
|
|
b2f0342a94 | ||
|
|
e33ebb4860 | ||
|
|
5ab034b859 | ||
|
|
c5decba321 | ||
|
|
de886c57c4 | ||
|
|
b04edb1685 | ||
|
|
fa5270abef | ||
|
|
ccea133400 | ||
|
|
8110afae66 | ||
|
|
2b9e25210f | ||
|
|
025038925d | ||
|
|
0dc8b97861 | ||
|
|
f94384e81a | ||
|
|
ac0800ed36 | ||
|
|
ab48990728 | ||
|
|
fe460cdfae | ||
|
|
3555e247a7 | ||
|
|
801022672e | ||
|
|
66ae65a65e | ||
|
|
dd08f5eb35 | ||
|
|
62fdddb48f | ||
|
|
382471a107 | ||
|
|
36eb261eac | ||
|
|
c90928c22a | ||
|
|
db2fb275a6 | ||
|
|
d3e4866443 | ||
|
|
2e23ca8650 | ||
|
|
959e719f00 | ||
|
|
3b7e933e4f | ||
|
|
66880f3148 | ||
|
|
e0aaebaf16 |
@@ -6917,6 +6917,12 @@
|
||||
},
|
||||
"ConfigRequirements": {
|
||||
"properties": {
|
||||
"allowManagedHooksOnly": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowedApprovalPolicies": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AskForApproval"
|
||||
|
||||
@@ -3514,6 +3514,12 @@
|
||||
},
|
||||
"ConfigRequirements": {
|
||||
"properties": {
|
||||
"allowManagedHooksOnly": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"allowedApprovalPolicies": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AskForApproval"
|
||||
|
||||
@@ -61,6 +61,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};
|
||||
|
||||
@@ -890,6 +890,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>>,
|
||||
pub enforce_residency: Option<ResidencyRequirement>,
|
||||
#[experimental("configRequirements/read.network")]
|
||||
@@ -7845,6 +7846,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
feature_requirements: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
|
||||
@@ -205,7 +205,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 `details` returned by detect. When a request includes plugin imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background remote 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`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`.
|
||||
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), `allowManagedHooksOnly`, pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
|
||||
|
||||
### Example: Start or resume a thread
|
||||
|
||||
|
||||
@@ -391,6 +391,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
|
||||
}
|
||||
normalized
|
||||
}),
|
||||
allow_managed_hooks_only: requirements.allow_managed_hooks_only,
|
||||
feature_requirements: requirements
|
||||
.feature_requirements
|
||||
.map(|requirements| requirements.entries),
|
||||
@@ -566,6 +567,7 @@ mod tests {
|
||||
allowed_web_search_modes: Some(vec![
|
||||
codex_core::config_loader::WebSearchModeRequirement::Cached,
|
||||
]),
|
||||
allow_managed_hooks_only: Some(true),
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: Some(codex_core::config_loader::FeatureRequirementsToml {
|
||||
entries: std::collections::BTreeMap::from([
|
||||
@@ -632,6 +634,7 @@ mod tests {
|
||||
mapped.allowed_web_search_modes,
|
||||
Some(vec![WebSearchMode::Cached, WebSearchMode::Disabled]),
|
||||
);
|
||||
assert_eq!(mapped.allow_managed_hooks_only, Some(true));
|
||||
assert_eq!(
|
||||
mapped.feature_requirements,
|
||||
Some(std::collections::BTreeMap::from([
|
||||
@@ -676,6 +679,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -734,6 +738,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: Some(Vec::new()),
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
|
||||
@@ -1165,6 +1165,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1195,6 +1196,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1225,6 +1227,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1272,6 +1275,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1355,6 +1359,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1428,6 +1433,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1499,6 +1505,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1697,6 +1704,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1733,6 +1741,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1789,6 +1798,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1840,6 +1850,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1895,6 +1906,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -1951,6 +1963,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -2007,6 +2020,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -2096,6 +2110,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
@@ -2124,6 +2139,7 @@ enabled = false
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
|
||||
@@ -84,6 +84,7 @@ pub struct ConfigRequirements {
|
||||
pub approvals_reviewer: ConstrainedWithSource<ApprovalsReviewer>,
|
||||
pub sandbox_policy: ConstrainedWithSource<SandboxPolicy>,
|
||||
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
|
||||
pub allow_managed_hooks_only: Option<Sourced<bool>>,
|
||||
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
|
||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
|
||||
@@ -113,6 +114,7 @@ impl Default for ConfigRequirements {
|
||||
Constrained::allow_any(WebSearchMode::Cached),
|
||||
/*source*/ None,
|
||||
),
|
||||
allow_managed_hooks_only: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
exec_policy: None,
|
||||
@@ -621,6 +623,7 @@ pub struct ConfigRequirementsToml {
|
||||
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
|
||||
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
|
||||
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 mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
@@ -661,6 +664,7 @@ pub struct ConfigRequirementsWithSources {
|
||||
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 mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
pub apps: Option<Sourced<AppsRequirementsToml>>,
|
||||
@@ -694,6 +698,7 @@ impl ConfigRequirementsWithSources {
|
||||
allowed_approvals_reviewers: _,
|
||||
allowed_sandbox_modes: _,
|
||||
allowed_web_search_modes: _,
|
||||
allow_managed_hooks_only: _,
|
||||
feature_requirements: _,
|
||||
mcp_servers: _,
|
||||
apps: _,
|
||||
@@ -721,6 +726,7 @@ impl ConfigRequirementsWithSources {
|
||||
allowed_approvals_reviewers,
|
||||
allowed_sandbox_modes,
|
||||
allowed_web_search_modes,
|
||||
allow_managed_hooks_only,
|
||||
feature_requirements,
|
||||
mcp_servers,
|
||||
rules,
|
||||
@@ -746,6 +752,7 @@ impl ConfigRequirementsWithSources {
|
||||
allowed_approvals_reviewers,
|
||||
allowed_sandbox_modes,
|
||||
allowed_web_search_modes,
|
||||
allow_managed_hooks_only,
|
||||
feature_requirements,
|
||||
mcp_servers,
|
||||
apps,
|
||||
@@ -760,6 +767,7 @@ impl ConfigRequirementsWithSources {
|
||||
allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value),
|
||||
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
|
||||
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
|
||||
allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value),
|
||||
feature_requirements: feature_requirements.map(|sourced| sourced.value),
|
||||
mcp_servers: mcp_servers.map(|sourced| sourced.value),
|
||||
apps: apps.map(|sourced| sourced.value),
|
||||
@@ -811,6 +819,7 @@ impl ConfigRequirementsToml {
|
||||
&& self.allowed_approvals_reviewers.is_none()
|
||||
&& self.allowed_sandbox_modes.is_none()
|
||||
&& self.allowed_web_search_modes.is_none()
|
||||
&& self.allow_managed_hooks_only.is_none()
|
||||
&& self
|
||||
.feature_requirements
|
||||
.as_ref()
|
||||
@@ -840,6 +849,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
allowed_approvals_reviewers,
|
||||
allowed_sandbox_modes,
|
||||
allowed_web_search_modes,
|
||||
allow_managed_hooks_only,
|
||||
feature_requirements,
|
||||
mcp_servers,
|
||||
apps: _apps,
|
||||
@@ -1061,6 +1071,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
web_search_mode,
|
||||
allow_managed_hooks_only,
|
||||
feature_requirements,
|
||||
mcp_servers,
|
||||
exec_policy,
|
||||
@@ -1100,6 +1111,7 @@ mod tests {
|
||||
allowed_approvals_reviewers,
|
||||
allowed_sandbox_modes,
|
||||
allowed_web_search_modes,
|
||||
allow_managed_hooks_only,
|
||||
feature_requirements,
|
||||
mcp_servers,
|
||||
apps,
|
||||
@@ -1118,6 +1130,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)),
|
||||
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
@@ -1162,6 +1176,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()),
|
||||
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
|
||||
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
|
||||
allow_managed_hooks_only: Some(true),
|
||||
feature_requirements: Some(feature_requirements.clone()),
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
@@ -1190,6 +1205,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(),
|
||||
@@ -1230,6 +1249,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
@@ -1275,6 +1295,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
|
||||
@@ -100,6 +100,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()
|
||||
}
|
||||
|
||||
@@ -5313,6 +5313,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
|
||||
allowed_web_search_modes: Some(vec![
|
||||
crate::config_loader::WebSearchModeRequirement::Cached,
|
||||
]),
|
||||
allow_managed_hooks_only: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
@@ -5987,6 +5988,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: Some(vec![crate::config_loader::SandboxModeRequirement::ReadOnly]),
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
|
||||
@@ -1487,6 +1487,7 @@ impl Config {
|
||||
approvals_reviewer: mut constrained_approvals_reviewer,
|
||||
sandbox_policy: mut constrained_sandbox_policy,
|
||||
web_search_mode: mut constrained_web_search_mode,
|
||||
allow_managed_hooks_only: _,
|
||||
feature_requirements,
|
||||
mcp_servers,
|
||||
exec_policy: _,
|
||||
|
||||
@@ -646,6 +646,7 @@ allowed_approval_policies = ["on-request"]
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
@@ -699,6 +700,7 @@ allowed_approval_policies = ["on-request"]
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
@@ -855,6 +857,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
|
||||
@@ -3,7 +3,10 @@ use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::config_loader::ConfigLayerEntry;
|
||||
use codex_core::config_loader::ConfigLayerStack;
|
||||
use codex_core::config_loader::ConfigLayerStackOrdering;
|
||||
use codex_core::config_loader::NetworkConstraints;
|
||||
@@ -20,7 +23,9 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
@@ -577,6 +582,147 @@ fn sse_event(event: Value) -> String {
|
||||
sse(vec![event])
|
||||
}
|
||||
|
||||
fn enable_managed_hooks_only(config: &mut Config) -> Result<()> {
|
||||
let system_dir = config.codex_home.join("managed");
|
||||
let system_config_file =
|
||||
AbsolutePathBuf::from_absolute_path(system_dir.join(codex_config::CONFIG_TOML_FILE))
|
||||
.context("absolute managed config path")?;
|
||||
let mut layers = config
|
||||
.config_layer_stack
|
||||
.get_layers(
|
||||
codex_config::ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
layers.insert(
|
||||
0,
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::System {
|
||||
file: system_config_file,
|
||||
},
|
||||
toml::Value::Table(Default::default()),
|
||||
),
|
||||
);
|
||||
|
||||
let mut requirements = config.config_layer_stack.requirements().clone();
|
||||
requirements.allow_managed_hooks_only = Some(Sourced::new(
|
||||
/*value*/ true,
|
||||
RequirementSource::CloudRequirements,
|
||||
));
|
||||
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
|
||||
requirements_toml.allow_managed_hooks_only = Some(true);
|
||||
config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml)
|
||||
.context("rebuild config layer stack with managed hooks requirement")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn configured_hooks_emit_startup_warning() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) = write_session_start_hook_recording_transcript(home) {
|
||||
panic!("failed to write session start hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let warning = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::Warning(WarningEvent { message })
|
||||
if message.contains("Hooks run arbitrary shell commands outside the sandbox")
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let EventMsg::Warning(WarningEvent { message }) = warning else {
|
||||
panic!("expected warning event");
|
||||
};
|
||||
assert!(message.contains("hooks.json"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn managed_hooks_only_skips_user_hooks() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let _response = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_assistant_message("msg-1", "hello from managed hook test"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
let system_dir = home.join("managed");
|
||||
fs::create_dir_all(&system_dir).expect("create managed hooks dir");
|
||||
write_session_start_hook_recording_transcript(&system_dir)
|
||||
.expect("write managed session start hook");
|
||||
write_session_start_hook_recording_transcript(home)
|
||||
.expect("write user session start hook");
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
if let Err(error) = enable_managed_hooks_only(config) {
|
||||
panic!("failed to enable managed-hooks-only test config: {error:#}");
|
||||
}
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let warning = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::Warning(WarningEvent { message })
|
||||
if message.contains("skipping hooks config")
|
||||
&& message.contains("allow_managed_hooks_only")
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let EventMsg::Warning(WarningEvent { message }) = warning else {
|
||||
panic!("expected warning event");
|
||||
};
|
||||
assert!(
|
||||
message.contains(
|
||||
test.codex_home_path()
|
||||
.join("hooks.json")
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
)
|
||||
);
|
||||
|
||||
test.submit_turn("hello").await?;
|
||||
|
||||
let managed_hook_inputs =
|
||||
read_session_start_hook_inputs(&test.codex_home_path().join("managed"))?;
|
||||
assert_eq!(managed_hook_inputs.len(), 1);
|
||||
let user_hook_log_path = test.codex_home_path().join("session_start_hook_log.jsonl");
|
||||
assert!(
|
||||
!user_hook_log_path.exists(),
|
||||
"user hook should be skipped when managed hooks only is enabled"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_message_input_texts(body: &[u8], role: &str) -> Vec<String> {
|
||||
let body: Value = match serde_json::from_slice(body) {
|
||||
Ok(body) => body,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::fs;
|
||||
|
||||
@@ -9,8 +11,6 @@ use super::config::HooksFile;
|
||||
use super::config::MatcherGroup;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
use crate::events::common::validate_matcher_pattern;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
|
||||
pub(crate) struct DiscoveryResult {
|
||||
pub handlers: Vec<ConfiguredHandler>,
|
||||
@@ -28,6 +28,11 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
let mut handlers = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0_i64;
|
||||
let allow_managed_hooks_only = config_layer_stack
|
||||
.requirements()
|
||||
.allow_managed_hooks_only
|
||||
.as_ref()
|
||||
.is_some_and(|requirement| requirement.value);
|
||||
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
@@ -40,6 +45,13 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
if !source_path.as_path().is_file() {
|
||||
continue;
|
||||
}
|
||||
if allow_managed_hooks_only && !layer.is_managed() {
|
||||
warnings.push(format!(
|
||||
"skipping hooks config {} because `allow_managed_hooks_only` is enabled",
|
||||
source_path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(source_path.as_path()) {
|
||||
Ok(contents) => contents,
|
||||
@@ -107,6 +119,20 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
}
|
||||
}
|
||||
|
||||
if !handlers.is_empty() {
|
||||
let mut source_paths = handlers
|
||||
.iter()
|
||||
.map(|handler| handler.source_path.display().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
source_paths.sort();
|
||||
source_paths.dedup();
|
||||
warnings.push(format!(
|
||||
"Loaded {} lifecycle hook(s) from {}. Hooks run arbitrary shell commands outside the sandbox; review hooks.json changes before continuing.",
|
||||
handlers.len(),
|
||||
source_paths.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
DiscoveryResult { handlers, warnings }
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,47 @@ pub(crate) async fn execute_handlers<T>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn select_handlers_by_trust_precedence(
|
||||
handlers: &[ConfiguredHandler],
|
||||
event_name: HookEventName,
|
||||
matcher_input: Option<&str>,
|
||||
) -> Vec<Vec<ConfiguredHandler>> {
|
||||
let mut non_project = Vec::new();
|
||||
let mut project = Vec::new();
|
||||
for handler in select_handlers(handlers, event_name, matcher_input) {
|
||||
if handler.source == codex_protocol::protocol::HookSource::Project {
|
||||
project.push(handler);
|
||||
} else {
|
||||
non_project.push(handler);
|
||||
}
|
||||
}
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
if !non_project.is_empty() {
|
||||
tiers.push(non_project);
|
||||
}
|
||||
if !project.is_empty() {
|
||||
tiers.push(project);
|
||||
}
|
||||
tiers
|
||||
}
|
||||
|
||||
pub(crate) fn skipped_completed_event(
|
||||
handler: &ConfiguredHandler,
|
||||
turn_id: Option<String>,
|
||||
message: String,
|
||||
) -> HookCompletedEvent {
|
||||
let mut run = running_summary(handler);
|
||||
run.status = HookRunStatus::Failed;
|
||||
run.completed_at = Some(run.started_at);
|
||||
run.duration_ms = Some(0);
|
||||
run.entries = vec![codex_protocol::protocol::HookOutputEntry {
|
||||
kind: codex_protocol::protocol::HookOutputEntryKind::Error,
|
||||
text: message,
|
||||
}];
|
||||
HookCompletedEvent { turn_id, run }
|
||||
}
|
||||
|
||||
pub(crate) fn completed_summary(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: &CommandRunResult,
|
||||
@@ -128,6 +169,7 @@ mod tests {
|
||||
|
||||
use super::ConfiguredHandler;
|
||||
use super::select_handlers;
|
||||
use super::select_handlers_by_trust_precedence;
|
||||
|
||||
fn make_handler(
|
||||
event_name: HookEventName,
|
||||
@@ -147,6 +189,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_handler_with_source(
|
||||
event_name: HookEventName,
|
||||
matcher: Option<&str>,
|
||||
command: &str,
|
||||
source_path: &str,
|
||||
display_order: i64,
|
||||
) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name,
|
||||
matcher: matcher.map(str::to_owned),
|
||||
command: command.to_string(),
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf(source_path).abs(),
|
||||
source: if source_path.contains("/project/") {
|
||||
HookSource::Project
|
||||
} else {
|
||||
HookSource::User
|
||||
},
|
||||
display_order,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_handlers_keeps_duplicate_stop_handlers() {
|
||||
let handlers = vec![
|
||||
@@ -340,4 +405,53 @@ mod tests {
|
||||
assert_eq!(selected[1].command, "second");
|
||||
assert_eq!(selected[2].command, "third");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_handlers_by_trust_precedence_runs_non_project_before_project() {
|
||||
let handlers = vec![
|
||||
make_handler_with_source(
|
||||
HookEventName::PreToolUse,
|
||||
Some("^Bash$"),
|
||||
"echo system",
|
||||
"/etc/codex/hooks.json",
|
||||
/*display_order*/ 0,
|
||||
),
|
||||
make_handler_with_source(
|
||||
HookEventName::PreToolUse,
|
||||
Some("^Bash$"),
|
||||
"echo user one",
|
||||
"/tmp/home/.codex/hooks.json",
|
||||
/*display_order*/ 1,
|
||||
),
|
||||
make_handler_with_source(
|
||||
HookEventName::PreToolUse,
|
||||
Some("^Bash$"),
|
||||
"echo user two",
|
||||
"/tmp/home/.codex/hooks.json",
|
||||
/*display_order*/ 2,
|
||||
),
|
||||
make_handler_with_source(
|
||||
HookEventName::PreToolUse,
|
||||
Some("^Bash$"),
|
||||
"echo project",
|
||||
"/tmp/project/.codex/hooks.json",
|
||||
/*display_order*/ 3,
|
||||
),
|
||||
];
|
||||
|
||||
let tiers =
|
||||
select_handlers_by_trust_precedence(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
||||
|
||||
assert_eq!(
|
||||
tiers
|
||||
.iter()
|
||||
.map(|tier| {
|
||||
tier.iter()
|
||||
.map(|handler| handler.display_order)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec![vec![0, 1, 2], vec![3]],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,15 +103,43 @@ pub(crate) async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id.clone()),
|
||||
parse_completed,
|
||||
let mut results = Vec::new();
|
||||
let mut tiers = dispatcher::select_handlers_by_trust_precedence(
|
||||
&matched,
|
||||
HookEventName::PreToolUse,
|
||||
Some(&request.tool_name),
|
||||
)
|
||||
.await;
|
||||
.into_iter()
|
||||
.peekable();
|
||||
while let Some(tier) = tiers.next() {
|
||||
let tier_results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
tier,
|
||||
input_json.clone(),
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id.clone()),
|
||||
parse_completed,
|
||||
)
|
||||
.await;
|
||||
let tier_should_block = tier_results.iter().any(|result| result.data.should_block);
|
||||
results.extend(tier_results);
|
||||
if tier_should_block {
|
||||
let skipped_message =
|
||||
"skipped because a higher-precedence PreToolUse hook blocked the command"
|
||||
.to_string();
|
||||
for skipped_handler in tiers.flatten() {
|
||||
results.push(dispatcher::ParsedHandler {
|
||||
completed: dispatcher::skipped_completed_event(
|
||||
&skipped_handler,
|
||||
Some(request.turn_id.clone()),
|
||||
skipped_message.clone(),
|
||||
),
|
||||
data: PreToolUseHandlerData::default(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let should_block = results.iter().any(|result| result.data.should_block);
|
||||
let block_reason = results
|
||||
@@ -250,8 +278,10 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::PreToolUseHandlerData;
|
||||
use super::PreToolUseRequest;
|
||||
use super::parse_completed;
|
||||
use super::preview;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
use crate::events::common;
|
||||
@@ -466,6 +496,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn higher_precedence_block_skips_lower_precedence_handlers() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let marker_path = temp.path().join("project-ran");
|
||||
let (shell_program, shell_args, blocking_command, project_command) = if cfg!(windows) {
|
||||
(
|
||||
"cmd".to_string(),
|
||||
vec!["/C".to_string()],
|
||||
"echo blocked by policy 1>&2 && exit /b 2".to_string(),
|
||||
"type nul > project-ran".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"/bin/sh".to_string(),
|
||||
vec!["-c".to_string()],
|
||||
"printf 'blocked by policy' >&2; exit 2".to_string(),
|
||||
"touch project-ran".to_string(),
|
||||
)
|
||||
};
|
||||
let handlers = vec![
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: blocking_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/home/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
},
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: project_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/project/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::Project,
|
||||
display_order: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let outcome = super::run(
|
||||
&handlers,
|
||||
&CommandShell {
|
||||
program: shell_program,
|
||||
args: shell_args,
|
||||
},
|
||||
PreToolUseRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd: temp.path().to_path_buf().abs(),
|
||||
transcript_path: None,
|
||||
model: "gpt-5".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
tool_name: "Bash".to_string(),
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
command: "echo hello".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(outcome.should_block);
|
||||
assert_eq!(outcome.block_reason, Some("blocked by policy".to_string()));
|
||||
assert!(!marker_path.exists());
|
||||
assert_eq!(outcome.hook_events.len(), 2);
|
||||
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Blocked);
|
||||
assert_eq!(outcome.hook_events[1].run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
outcome.hook_events[1].run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "skipped because a higher-precedence PreToolUse hook blocked the command"
|
||||
.to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_and_completed_run_ids_include_tool_use_id() {
|
||||
let request = request_for_tool_use("tool-call-123");
|
||||
|
||||
@@ -52,7 +52,7 @@ pub struct SessionStartOutcome {
|
||||
pub additional_contexts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct SessionStartHandlerData {
|
||||
should_stop: bool,
|
||||
stop_reason: Option<String>,
|
||||
@@ -111,15 +111,47 @@ pub(crate) async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
turn_id,
|
||||
parse_completed,
|
||||
let mut results = Vec::new();
|
||||
let mut tiers = dispatcher::select_handlers_by_trust_precedence(
|
||||
&matched,
|
||||
HookEventName::SessionStart,
|
||||
Some(request.source.as_str()),
|
||||
)
|
||||
.await;
|
||||
.into_iter()
|
||||
.peekable();
|
||||
while let Some(tier) = tiers.next() {
|
||||
let tier_results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
tier,
|
||||
input_json.clone(),
|
||||
request.cwd.as_path(),
|
||||
turn_id.clone(),
|
||||
parse_completed,
|
||||
)
|
||||
.await;
|
||||
let tier_should_stop = tier_results.iter().any(|result| result.data.should_stop);
|
||||
results.extend(tier_results);
|
||||
if tier_should_stop {
|
||||
let skipped_message =
|
||||
"skipped because a higher-precedence SessionStart hook stopped processing"
|
||||
.to_string();
|
||||
for skipped_handler in tiers.flatten() {
|
||||
results.push(dispatcher::ParsedHandler {
|
||||
completed: dispatcher::skipped_completed_event(
|
||||
&skipped_handler,
|
||||
turn_id.clone(),
|
||||
skipped_message.clone(),
|
||||
),
|
||||
data: SessionStartHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
additional_contexts_for_model: Vec::new(),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let should_stop = results.iter().any(|result| result.data.should_stop);
|
||||
let stop_reason = results
|
||||
@@ -248,6 +280,7 @@ fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> Sessio
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
@@ -257,7 +290,10 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::SessionStartHandlerData;
|
||||
use super::SessionStartRequest;
|
||||
use super::SessionStartSource;
|
||||
use super::parse_completed;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
|
||||
@@ -353,6 +389,105 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn higher_precedence_stop_skips_lower_precedence_handlers() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let marker_path = temp.path().join("project-ran");
|
||||
let (shell_program, shell_args, stopping_command, project_command) = if cfg!(windows) {
|
||||
let stopping_script = temp.path().join("stopping.cmd");
|
||||
std::fs::write(
|
||||
&stopping_script,
|
||||
r#"@echo off
|
||||
more > nul
|
||||
echo {"continue":false,"stopReason":"pause","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"trusted context"}}
|
||||
"#,
|
||||
)?;
|
||||
let project_script = temp.path().join("project.cmd");
|
||||
std::fs::write(
|
||||
&project_script,
|
||||
r#"@echo off
|
||||
more > nul
|
||||
type nul > project-ran
|
||||
echo project context
|
||||
"#,
|
||||
)?;
|
||||
(
|
||||
"cmd.exe".to_string(),
|
||||
vec!["/D".to_string(), "/C".to_string()],
|
||||
format!("\"{}\"", stopping_script.display()),
|
||||
format!("\"{}\"", project_script.display()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"/bin/sh".to_string(),
|
||||
vec!["-c".to_string()],
|
||||
"cat >/dev/null; printf '%s' '{\"continue\":false,\"stopReason\":\"pause\",\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"trusted context\"}}'".to_string(),
|
||||
"cat >/dev/null; touch project-ran && printf 'project context'".to_string(),
|
||||
)
|
||||
};
|
||||
let handlers = vec![
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::SessionStart,
|
||||
matcher: Some("^startup$".to_string()),
|
||||
command: stopping_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/home/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
},
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::SessionStart,
|
||||
matcher: Some("^startup$".to_string()),
|
||||
command: project_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/project/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::Project,
|
||||
display_order: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let outcome = super::run(
|
||||
&handlers,
|
||||
&CommandShell {
|
||||
program: shell_program,
|
||||
args: shell_args,
|
||||
},
|
||||
SessionStartRequest {
|
||||
session_id: ThreadId::new(),
|
||||
cwd: temp.path().to_path_buf().abs(),
|
||||
transcript_path: None,
|
||||
model: "gpt-5".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
source: SessionStartSource::Startup,
|
||||
},
|
||||
Some("turn-1".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(outcome.should_stop);
|
||||
assert_eq!(outcome.stop_reason, Some("pause".to_string()));
|
||||
assert_eq!(
|
||||
outcome.additional_contexts,
|
||||
vec!["trusted context".to_string()]
|
||||
);
|
||||
assert!(!marker_path.exists());
|
||||
assert_eq!(outcome.hook_events.len(), 2);
|
||||
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Stopped);
|
||||
assert_eq!(outcome.hook_events[1].run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
outcome.hook_events[1].run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "skipped because a higher-precedence SessionStart hook stopped processing"
|
||||
.to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::SessionStart,
|
||||
|
||||
@@ -37,7 +37,7 @@ pub struct UserPromptSubmitOutcome {
|
||||
pub additional_contexts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct UserPromptSubmitHandlerData {
|
||||
should_stop: bool,
|
||||
stop_reason: Option<String>,
|
||||
@@ -97,15 +97,47 @@ pub(crate) async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id),
|
||||
parse_completed,
|
||||
let mut results = Vec::new();
|
||||
let mut tiers = dispatcher::select_handlers_by_trust_precedence(
|
||||
&matched,
|
||||
HookEventName::UserPromptSubmit,
|
||||
/*matcher_input*/ None,
|
||||
)
|
||||
.await;
|
||||
.into_iter()
|
||||
.peekable();
|
||||
while let Some(tier) = tiers.next() {
|
||||
let tier_results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
tier,
|
||||
input_json.clone(),
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id.clone()),
|
||||
parse_completed,
|
||||
)
|
||||
.await;
|
||||
let tier_should_stop = tier_results.iter().any(|result| result.data.should_stop);
|
||||
results.extend(tier_results);
|
||||
if tier_should_stop {
|
||||
let skipped_message =
|
||||
"skipped because a higher-precedence UserPromptSubmit hook stopped processing"
|
||||
.to_string();
|
||||
for skipped_handler in tiers.flatten() {
|
||||
results.push(dispatcher::ParsedHandler {
|
||||
completed: dispatcher::skipped_completed_event(
|
||||
&skipped_handler,
|
||||
Some(request.turn_id.clone()),
|
||||
skipped_message.clone(),
|
||||
),
|
||||
data: UserPromptSubmitHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
additional_contexts_for_model: Vec::new(),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let should_stop = results.iter().any(|result| result.data.should_stop);
|
||||
let stop_reason = results
|
||||
@@ -269,6 +301,7 @@ fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> UserPr
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
@@ -278,7 +311,9 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::UserPromptSubmitHandlerData;
|
||||
use super::UserPromptSubmitRequest;
|
||||
use super::parse_completed;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
|
||||
@@ -411,6 +446,106 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn higher_precedence_stop_skips_lower_precedence_handlers() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let marker_path = temp.path().join("project-ran");
|
||||
let (shell_program, shell_args, stopping_command, project_command) = if cfg!(windows) {
|
||||
let stopping_script = temp.path().join("stopping.cmd");
|
||||
std::fs::write(
|
||||
&stopping_script,
|
||||
r#"@echo off
|
||||
more > nul
|
||||
echo {"decision":"block","reason":"slow down","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"trusted context"}}
|
||||
"#,
|
||||
)?;
|
||||
let project_script = temp.path().join("project.cmd");
|
||||
std::fs::write(
|
||||
&project_script,
|
||||
r#"@echo off
|
||||
more > nul
|
||||
type nul > project-ran
|
||||
echo project context
|
||||
"#,
|
||||
)?;
|
||||
(
|
||||
"cmd.exe".to_string(),
|
||||
vec!["/D".to_string(), "/C".to_string()],
|
||||
format!("\"{}\"", stopping_script.display()),
|
||||
format!("\"{}\"", project_script.display()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"/bin/sh".to_string(),
|
||||
vec!["-c".to_string()],
|
||||
"cat >/dev/null; printf '%s' '{\"decision\":\"block\",\"reason\":\"slow down\",\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"trusted context\"}}'".to_string(),
|
||||
"cat >/dev/null; touch project-ran && printf 'project context'".to_string(),
|
||||
)
|
||||
};
|
||||
let handlers = vec![
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
matcher: None,
|
||||
command: stopping_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/home/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
},
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
matcher: None,
|
||||
command: project_command,
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: test_path_buf("/tmp/project/.codex/hooks.json").abs(),
|
||||
source: codex_protocol::protocol::HookSource::Project,
|
||||
display_order: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let outcome = super::run(
|
||||
&handlers,
|
||||
&CommandShell {
|
||||
program: shell_program,
|
||||
args: shell_args,
|
||||
},
|
||||
UserPromptSubmitRequest {
|
||||
session_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
cwd: temp.path().to_path_buf().abs(),
|
||||
transcript_path: None,
|
||||
model: "gpt-5".to_string(),
|
||||
permission_mode: "default".to_string(),
|
||||
prompt: "hello".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(outcome.should_stop);
|
||||
assert_eq!(outcome.stop_reason, Some("slow down".to_string()));
|
||||
assert_eq!(
|
||||
outcome.additional_contexts,
|
||||
vec!["trusted context".to_string()]
|
||||
);
|
||||
assert!(!marker_path.exists());
|
||||
assert_eq!(outcome.hook_events.len(), 2);
|
||||
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Blocked);
|
||||
assert_eq!(outcome.hook_events[1].run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
outcome.hook_events[1].run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text:
|
||||
"skipped because a higher-precedence UserPromptSubmit hook stopped processing"
|
||||
.to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
|
||||
@@ -144,6 +144,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(allow_managed_hooks_only) = requirements_toml.allow_managed_hooks_only {
|
||||
requirement_lines.push(requirement_line(
|
||||
"allow_managed_hooks_only",
|
||||
allow_managed_hooks_only.to_string(),
|
||||
requirements
|
||||
.allow_managed_hooks_only
|
||||
.as_ref()
|
||||
.map(|sourced| &sourced.source),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(feature_requirements) = requirements.feature_requirements.as_ref() {
|
||||
let value = join_or_empty(
|
||||
feature_requirements
|
||||
@@ -606,6 +617,10 @@ mod tests {
|
||||
Constrained::allow_any(WebSearchMode::Cached),
|
||||
Some(RequirementSource::CloudRequirements),
|
||||
),
|
||||
allow_managed_hooks_only: Some(Sourced::new(
|
||||
/*value*/ true,
|
||||
RequirementSource::CloudRequirements,
|
||||
)),
|
||||
feature_requirements: Some(Sourced::new(
|
||||
FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([("guardian_approval".to_string(), true)]),
|
||||
@@ -641,6 +656,7 @@ mod tests {
|
||||
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]),
|
||||
allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]),
|
||||
allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]),
|
||||
allow_managed_hooks_only: Some(true),
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: Some(FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([("guardian_approval".to_string(), true)]),
|
||||
@@ -696,6 +712,7 @@ mod tests {
|
||||
"allowed_web_search_modes: cached, disabled (source: cloud requirements)"
|
||||
)
|
||||
);
|
||||
assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)"));
|
||||
assert!(rendered.contains("features: guardian_approval=true (source: cloud requirements)"));
|
||||
assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))"));
|
||||
assert!(rendered.contains("enforce_residency: us (source: cloud requirements)"));
|
||||
@@ -844,6 +861,7 @@ approval_policy = "never"
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: Some(Vec::new()),
|
||||
allow_managed_hooks_only: None,
|
||||
guardian_policy_config: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
|
||||
@@ -56,6 +56,15 @@ Codex can run a notification hook when the agent finishes a turn. See the config
|
||||
|
||||
When Codex knows which client started the turn, the legacy notify JSON payload also includes a top-level `client` field. The TUI reports `codex-tui`, and the app server reports the `clientInfo.name` value from `initialize`.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Lifecycle hooks run arbitrary shell commands outside the sandbox. When hooks are configured,
|
||||
Codex emits a startup warning that lists the loaded `hooks.json` files so you can review
|
||||
changes before continuing.
|
||||
|
||||
Admins can set `allow_managed_hooks_only = true` in `requirements.toml` to ignore user and
|
||||
project hook files while still allowing managed hooks.
|
||||
|
||||
## JSON Schema
|
||||
|
||||
The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`.
|
||||
|
||||
Reference in New Issue
Block a user