codex: support hooks in config.toml and requirements.toml (#18893)

## Summary

Support the existing hooks schema in inline TOML so hooks can be
configured from both `config.toml` and enterprise-managed
`requirements.toml` without requiring a separate `hooks.json` payload.

This gives enterprise admins a way to ship managed hook policy through
the existing requirements channel while still leaving script delivery to
MDM or other device-management tooling, and it keeps `hooks.json`
working unchanged for existing users.

This also lays the groundwork for follow-on managed filtering work such
as #15937, while continuing to respect project trust gating from #14718.
It does **not** implement `allow_managed_hooks_only` itself.

NOTE: yes, it's a bit unfortunate that the toml isn't formatted as
closely as normal to our default styling. This is because we're trying
to stay compatible with the spec for plugins/hooks that we'll need to
support & the main usecase here is embedding into requirements.toml

## What changed

- moved the shared hook serde model out of `codex-rs/hooks` into
`codex-rs/config` so the same schema can power `hooks.json`, inline
`config.toml` hooks, and managed `requirements.toml` hooks
- added `hooks` support to both `ConfigToml` and
`ConfigRequirementsToml`, including requirements-side `managed_dir` /
`windows_managed_dir`
- treated requirements-managed hooks as one constrained value via
`Constrained`, so managed hook policy is merged atomically and cannot
drift across requirement sources
- updated hook discovery to load requirements-managed hooks first, then
per-layer `hooks.json`, then per-layer inline TOML hooks, with a warning
when a single layer defines both representations
- threaded managed hook metadata through discovered handlers and exposed
requirements hooks in app-server responses, generated schemas, and
`/debug-config`
- added hook/config coverage in `codex-rs/config`, `codex-rs/hooks`,
`codex-rs/core/src/config_loader/tests.rs`, and
`codex-rs/core/tests/suite/hooks.rs`

## Testing

- `cargo test -p codex-config`
- `cargo test -p codex-hooks`
- `cargo test -p codex-app-server config_api`

## Documentation

Companion updates are needed in the developers website repo for:

- the hooks guide
- the config reference, sample, basic, and advanced pages
- the enterprise managed configuration guide

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
Andrei Eternal
2026-04-22 21:20:09 -07:00
committed by GitHub
Unverified
parent 9955eacd22
commit 2b2de3f38b
35 changed files with 2464 additions and 270 deletions
@@ -7325,6 +7325,100 @@
"title": "ConfigWriteResponse",
"type": "object"
},
"ConfiguredHookHandler": {
"oneOf": [
{
"properties": {
"async": {
"type": "boolean"
},
"command": {
"type": "string"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"command"
],
"title": "CommandConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"async",
"command",
"type"
],
"title": "CommandConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"prompt"
],
"title": "PromptConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"type"
],
"title": "PromptConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"agent"
],
"title": "AgentConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"type"
],
"title": "AgentConfiguredHookHandler",
"type": "object"
}
]
},
"ConfiguredHookMatcherGroup": {
"properties": {
"hooks": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookHandler"
},
"type": "array"
},
"matcher": {
"type": [
"string",
"null"
]
}
},
"required": [
"hooks"
],
"type": "object"
},
"ContentItem": {
"oneOf": [
{
@@ -10018,6 +10112,67 @@
"title": "LogoutAccountResponse",
"type": "object"
},
"ManagedHooksRequirements": {
"properties": {
"PermissionRequest": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PostToolUse": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PreToolUse": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"SessionStart": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"Stop": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"managedDir": {
"type": [
"string",
"null"
]
},
"windowsManagedDir": {
"type": [
"string",
"null"
]
}
},
"required": [
"PermissionRequest",
"PostToolUse",
"PreToolUse",
"SessionStart",
"Stop",
"UserPromptSubmit"
],
"type": "object"
},
"MarketplaceAddParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -3905,6 +3905,100 @@
"title": "ConfigWriteResponse",
"type": "object"
},
"ConfiguredHookHandler": {
"oneOf": [
{
"properties": {
"async": {
"type": "boolean"
},
"command": {
"type": "string"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"command"
],
"title": "CommandConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"async",
"command",
"type"
],
"title": "CommandConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"prompt"
],
"title": "PromptConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"type"
],
"title": "PromptConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"agent"
],
"title": "AgentConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"type"
],
"title": "AgentConfiguredHookHandler",
"type": "object"
}
]
},
"ConfiguredHookMatcherGroup": {
"properties": {
"hooks": {
"items": {
"$ref": "#/definitions/ConfiguredHookHandler"
},
"type": "array"
},
"matcher": {
"type": [
"string",
"null"
]
}
},
"required": [
"hooks"
],
"type": "object"
},
"ContentItem": {
"oneOf": [
{
@@ -6753,6 +6847,67 @@
"title": "LogoutAccountResponse",
"type": "object"
},
"ManagedHooksRequirements": {
"properties": {
"PermissionRequest": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PostToolUse": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PreToolUse": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"SessionStart": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"Stop": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"managedDir": {
"type": [
"string",
"null"
]
},
"windowsManagedDir": {
"type": [
"string",
"null"
]
}
},
"required": [
"PermissionRequest",
"PostToolUse",
"PreToolUse",
"SessionStart",
"Stop",
"UserPromptSubmit"
],
"type": "object"
},
"MarketplaceAddParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -111,6 +111,161 @@
},
"type": "object"
},
"ConfiguredHookHandler": {
"oneOf": [
{
"properties": {
"async": {
"type": "boolean"
},
"command": {
"type": "string"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"command"
],
"title": "CommandConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"async",
"command",
"type"
],
"title": "CommandConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"prompt"
],
"title": "PromptConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"type"
],
"title": "PromptConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"agent"
],
"title": "AgentConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"type"
],
"title": "AgentConfiguredHookHandler",
"type": "object"
}
]
},
"ConfiguredHookMatcherGroup": {
"properties": {
"hooks": {
"items": {
"$ref": "#/definitions/ConfiguredHookHandler"
},
"type": "array"
},
"matcher": {
"type": [
"string",
"null"
]
}
},
"required": [
"hooks"
],
"type": "object"
},
"ManagedHooksRequirements": {
"properties": {
"PermissionRequest": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PostToolUse": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"PreToolUse": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"SessionStart": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"Stop": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"managedDir": {
"type": [
"string",
"null"
]
},
"windowsManagedDir": {
"type": [
"string",
"null"
]
}
},
"required": [
"PermissionRequest",
"PostToolUse",
"PreToolUse",
"SessionStart",
"Stop",
"UserPromptSubmit"
],
"type": "object"
},
"NetworkDomainPermission": {
"enum": [
"allow",
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ConfiguredHookHandler = { "type": "command", command: string, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfiguredHookHandler } from "./ConfiguredHookHandler";
export type ConfiguredHookMatcherGroup = { matcher: string | null, hooks: Array<ConfiguredHookHandler>, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup";
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
@@ -71,6 +71,8 @@ export type { ConfigRequirementsReadResponse } from "./ConfigRequirementsReadRes
export type { ConfigValueWriteParams } from "./ConfigValueWriteParams";
export type { ConfigWarningNotification } from "./ConfigWarningNotification";
export type { ConfigWriteResponse } from "./ConfigWriteResponse";
export type { ConfiguredHookHandler } from "./ConfiguredHookHandler";
export type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup";
export type { ContextCompactedNotification } from "./ContextCompactedNotification";
export type { CreditsSnapshot } from "./CreditsSnapshot";
export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification";
@@ -169,6 +171,7 @@ export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse"
export type { LoginAccountParams } from "./LoginAccountParams";
export type { LoginAccountResponse } from "./LoginAccountResponse";
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
export type { ManagedHooksRequirements } from "./ManagedHooksRequirements";
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
export type { MarketplaceAddResponse } from "./MarketplaceAddResponse";
export type { MarketplaceInterface } from "./MarketplaceInterface";
@@ -941,11 +941,71 @@ pub struct ConfigRequirements {
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub feature_requirements: Option<BTreeMap<String, bool>>,
#[experimental("configRequirements/read.hooks")]
pub hooks: Option<ManagedHooksRequirements>,
pub enforce_residency: Option<ResidencyRequirement>,
#[experimental("configRequirements/read.network")]
pub network: Option<NetworkRequirements>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ManagedHooksRequirements {
pub managed_dir: Option<PathBuf>,
pub windows_managed_dir: Option<PathBuf>,
#[serde(rename = "PreToolUse")]
#[ts(rename = "PreToolUse")]
pub pre_tool_use: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "PermissionRequest")]
#[ts(rename = "PermissionRequest")]
pub permission_request: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "PostToolUse")]
#[ts(rename = "PostToolUse")]
pub post_tool_use: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "SessionStart")]
#[ts(rename = "SessionStart")]
pub session_start: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "UserPromptSubmit")]
#[ts(rename = "UserPromptSubmit")]
pub user_prompt_submit: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "Stop")]
#[ts(rename = "Stop")]
pub stop: Vec<ConfiguredHookMatcherGroup>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfiguredHookMatcherGroup {
pub matcher: Option<String>,
pub hooks: Vec<ConfiguredHookHandler>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type")]
#[ts(tag = "type", export_to = "v2/")]
pub enum ConfiguredHookHandler {
#[serde(rename = "command")]
#[ts(rename = "command")]
Command {
command: String,
#[serde(rename = "timeoutSec")]
#[ts(rename = "timeoutSec")]
timeout_sec: Option<u64>,
r#async: bool,
#[serde(rename = "statusMessage")]
#[ts(rename = "statusMessage")]
status_message: Option<String>,
},
#[serde(rename = "prompt")]
#[ts(rename = "prompt")]
Prompt {},
#[serde(rename = "agent")]
#[ts(rename = "agent")]
Agent {},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -8837,6 +8897,7 @@ mod tests {
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
enforce_residency: None,
network: None,
});
+1 -1
View File
@@ -209,7 +209,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`), 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
+112
View File
@@ -12,9 +12,12 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ConfiguredHookHandler;
use codex_app_server_protocol::ConfiguredHookMatcherGroup;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ManagedHooksRequirements;
use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
@@ -22,6 +25,10 @@ use codex_app_server_protocol::SandboxMode;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::config_loader::HookEventsToml;
use codex_core::config_loader::HookHandlerConfig as CoreHookHandlerConfig;
use codex_core::config_loader::ManagedHooksRequirementsToml;
use codex_core::config_loader::MatcherGroup as CoreMatcherGroup;
use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::plugins::PluginId;
@@ -290,6 +297,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
enforce_residency: requirements
.enforce_residency
.map(map_residency_requirement_to_api),
@@ -297,6 +305,71 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
}
}
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
let ManagedHooksRequirementsToml {
managed_dir,
windows_managed_dir,
hooks,
} = hooks;
let HookEventsToml {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = hooks;
ManagedHooksRequirements {
managed_dir,
windows_managed_dir,
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
permission_request: map_hook_matcher_groups_to_api(permission_request),
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
session_start: map_hook_matcher_groups_to_api(session_start),
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
stop: map_hook_matcher_groups_to_api(stop),
}
}
fn map_hook_matcher_groups_to_api(
groups: Vec<CoreMatcherGroup>,
) -> Vec<ConfiguredHookMatcherGroup> {
groups
.into_iter()
.map(map_hook_matcher_group_to_api)
.collect()
}
fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup {
ConfiguredHookMatcherGroup {
matcher: group.matcher,
hooks: group
.hooks
.into_iter()
.map(map_hook_handler_to_api)
.collect(),
}
}
fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler {
match handler {
CoreHookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => ConfiguredHookHandler::Command {
command,
timeout_sec,
r#async,
status_message,
},
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
}
}
fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option<SandboxMode> {
match mode {
CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly),
@@ -476,6 +549,22 @@ mod tests {
("personality".to_string(), true),
]),
}),
hooks: Some(ManagedHooksRequirementsToml {
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
hooks: HookEventsToml {
pre_tool_use: vec![CoreMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![CoreHookHandlerConfig::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}),
mcp_servers: None,
apps: None,
rules: None,
@@ -542,6 +631,27 @@ mod tests {
("personality".to_string(), true),
])),
);
assert_eq!(
mapped.hooks,
Some(ManagedHooksRequirements {
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
pre_tool_use: vec![ConfiguredHookMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![ConfiguredHookHandler::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
permission_request: Vec::new(),
post_tool_use: Vec::new(),
session_start: Vec::new(),
user_prompt_submit: Vec::new(),
stop: Vec::new(),
}),
);
assert_eq!(
mapped.enforce_residency,
Some(codex_app_server_protocol::ResidencyRequirement::Us),
@@ -582,6 +692,7 @@ mod tests {
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -641,6 +752,7 @@ mod tests {
allowed_web_search_modes: Some(Vec::new()),
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -48,6 +48,7 @@ use opentelemetry_sdk::trace::SpanData;
use pretty_assertions::assert_eq;
use serial_test::serial;
use std::collections::BTreeMap;
use std::future::Future;
use std::path::Path;
use std::sync::Arc;
use std::sync::OnceLock;
@@ -291,6 +292,28 @@ fn build_test_processor(
(processor, outgoing_rx)
}
fn run_current_thread_test_with_stack<F>(name: &str, future: F) -> Result<()>
where
F: Future<Output = Result<()>> + Send + 'static,
{
const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024;
let handle = std::thread::Builder::new()
.name(name.to_string())
.stack_size(TEST_STACK_SIZE_BYTES)
.spawn(move || -> Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(Box::pin(future))
})?;
match handle.join() {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!("{name} thread panicked")),
}
}
fn span_attr<'a>(span: &'a SpanData, key: &str) -> Option<&'a str> {
span.attributes
.iter()
@@ -568,83 +591,96 @@ where
spans.into_iter().skip(baseline_len).collect()
}
#[tokio::test(flavor = "current_thread")]
#[test]
#[serial(app_server_tracing)]
async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> {
let mut harness = TracingHarness::new().await?;
fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> {
run_current_thread_test_with_stack(
"thread_start_jsonrpc_span_exports_server_span_and_parents_children",
async {
let mut harness = TracingHarness::new().await?;
let RemoteTrace {
trace_id: remote_trace_id,
parent_span_id: remote_parent_span_id,
context: remote_trace,
..
} = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022");
let RemoteTrace {
trace_id: remote_trace_id,
parent_span_id: remote_parent_span_id,
context: remote_trace,
..
} = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022");
let _: ThreadStartResponse = harness
.start_thread(/*request_id*/ 20_002, /*trace*/ None)
.await;
let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("thread/start")
})
})
.await;
let untraced_server_span = find_rpc_span_with_trace(
&untraced_spans,
SpanKind::Server,
"thread/start",
untraced_spans
.iter()
.rev()
.find(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.system") == Some("jsonrpc")
&& span_attr(span, "rpc.method") == Some("thread/start")
let _: ThreadStartResponse = harness
.start_thread(/*request_id*/ 20_002, /*trace*/ None)
.await;
let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("thread/start")
})
})
.unwrap_or_else(|| {
panic!(
"missing latest thread/start server span; exported spans:\n{}",
format_spans(&untraced_spans)
)
.await;
let untraced_server_span = find_rpc_span_with_trace(
&untraced_spans,
SpanKind::Server,
"thread/start",
untraced_spans
.iter()
.rev()
.find(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.system") == Some("jsonrpc")
&& span_attr(span, "rpc.method") == Some("thread/start")
})
.unwrap_or_else(|| {
panic!(
"missing latest thread/start server span; exported spans:\n{}",
format_spans(&untraced_spans)
)
})
.span_context
.trace_id(),
);
assert_has_internal_descendant_at_min_depth(
&untraced_spans,
untraced_server_span,
/*min_depth*/ 1,
);
let baseline_len = untraced_spans.len();
let _: ThreadStartResponse = harness
.start_thread(/*request_id*/ 20_003, Some(remote_trace))
.await;
let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("thread/start")
&& span.span_context.trace_id() == remote_trace_id
}) && spans.iter().any(|span| {
span.name.as_ref() == "app_server.thread_start.notify_started"
&& span.span_context.trace_id() == remote_trace_id
})
})
.span_context
.trace_id(),
);
assert_has_internal_descendant_at_min_depth(
&untraced_spans,
untraced_server_span,
/*min_depth*/ 1,
);
.await;
let baseline_len = untraced_spans.len();
let _: ThreadStartResponse = harness
.start_thread(/*request_id*/ 20_003, Some(remote_trace))
.await;
let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("thread/start")
&& span.span_context.trace_id() == remote_trace_id
}) && spans.iter().any(|span| {
span.name.as_ref() == "app_server.thread_start.notify_started"
&& span.span_context.trace_id() == remote_trace_id
})
})
.await;
let server_request_span =
find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id);
assert_eq!(server_request_span.name.as_ref(), "thread/start");
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
assert!(server_request_span.parent_span_is_remote);
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID);
assert_has_internal_descendant_at_min_depth(
&spans,
server_request_span,
/*min_depth*/ 1,
);
assert_has_internal_descendant_at_min_depth(
&spans,
server_request_span,
/*min_depth*/ 2,
);
harness.shutdown().await;
let server_request_span =
find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id);
assert_eq!(server_request_span.name.as_ref(), "thread/start");
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
assert!(server_request_span.parent_span_is_remote);
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID);
assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 1);
assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 2);
harness.shutdown().await;
Ok(())
Ok(())
},
)
}
#[tokio::test(flavor = "current_thread")]
+16
View File
@@ -1172,6 +1172,7 @@ mod tests {
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1203,6 +1204,7 @@ mod tests {
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1234,6 +1236,7 @@ mod tests {
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1282,6 +1285,7 @@ mod tests {
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1366,6 +1370,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1441,6 +1446,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1514,6 +1520,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1714,6 +1721,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1751,6 +1759,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1808,6 +1817,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1860,6 +1870,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1916,6 +1927,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1973,6 +1985,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -2030,6 +2043,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -2120,6 +2134,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -2149,6 +2164,7 @@ enabled = false
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
+168
View File
@@ -17,6 +17,7 @@ use super::requirements_exec_policy::RequirementsExecPolicy;
use super::requirements_exec_policy::RequirementsExecPolicyToml;
use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedHooksRequirementsToml;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequirementSource {
@@ -86,6 +87,7 @@ pub struct ConfigRequirements {
pub sandbox_policy: ConstrainedWithSource<SandboxPolicy>,
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
@@ -117,6 +119,7 @@ impl Default for ConfigRequirements {
/*source*/ None,
),
feature_requirements: None,
managed_hooks: None,
mcp_servers: None,
exec_policy: None,
enforce_residency: ConstrainedWithSource::new(
@@ -628,6 +631,7 @@ pub struct ConfigRequirementsToml {
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
#[serde(rename = "features", alias = "feature_requirements")]
pub feature_requirements: Option<FeatureRequirementsToml>,
pub hooks: Option<ManagedHooksRequirementsToml>,
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
pub apps: Option<AppsRequirementsToml>,
pub rules: Option<RequirementsExecPolicyToml>,
@@ -673,6 +677,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub apps: Option<Sourced<AppsRequirementsToml>>,
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
@@ -707,6 +712,7 @@ impl ConfigRequirementsWithSources {
remote_sandbox_config: _,
allowed_web_search_modes: _,
feature_requirements: _,
hooks: _,
mcp_servers: _,
apps: _,
rules: _,
@@ -734,6 +740,7 @@ impl ConfigRequirementsWithSources {
allowed_sandbox_modes,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
rules,
enforce_residency,
@@ -759,6 +766,7 @@ impl ConfigRequirementsWithSources {
allowed_sandbox_modes,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
apps,
rules,
@@ -774,6 +782,7 @@ impl ConfigRequirementsWithSources {
remote_sandbox_config: None,
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
feature_requirements: feature_requirements.map(|sourced| sourced.value),
hooks: hooks.map(|sourced| sourced.value),
mcp_servers: mcp_servers.map(|sourced| sourced.value),
apps: apps.map(|sourced| sourced.value),
rules: rules.map(|sourced| sourced.value),
@@ -858,6 +867,10 @@ impl ConfigRequirementsToml {
.feature_requirements
.as_ref()
.is_none_or(FeatureRequirementsToml::is_empty)
&& self
.hooks
.as_ref()
.is_none_or(ManagedHooksRequirementsToml::is_empty)
&& self.mcp_servers.is_none()
&& self
.apps
@@ -884,6 +897,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
allowed_sandbox_modes,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
apps: _apps,
rules,
@@ -1064,6 +1078,34 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
};
let feature_requirements =
feature_requirements.filter(|requirements| !requirements.value.is_empty());
let managed_hooks = hooks
.filter(|managed_hooks| managed_hooks.value.handler_count() > 0)
.map(|sourced_hooks| {
let Sourced {
value,
source: requirement_source,
} = sourced_hooks;
let allowed = value;
let allowed_for_error = format!("{allowed:?}");
let requirement_source_for_error = requirement_source.clone();
let constrained = Constrained::new(allowed.clone(), move |candidate| {
if candidate == &allowed {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "hooks",
candidate: format!("{candidate:?}"),
allowed: allowed_for_error.clone(),
requirement_source: requirement_source_for_error.clone(),
})
}
})?;
Ok(ConstrainedWithSource::new(
constrained,
Some(requirement_source),
))
})
.transpose()?;
let enforce_residency = match enforce_residency {
Some(Sourced {
@@ -1106,6 +1148,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
sandbox_policy,
web_search_mode,
feature_requirements,
managed_hooks,
mcp_servers,
exec_policy,
enforce_residency,
@@ -1119,6 +1162,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
#[cfg(test)]
mod tests {
use super::*;
use crate::HookEventsToml;
use anyhow::Result;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
@@ -1147,6 +1191,7 @@ mod tests {
remote_sandbox_config: _,
allowed_web_search_modes,
feature_requirements,
hooks,
mcp_servers,
apps,
rules,
@@ -1166,6 +1211,7 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
feature_requirements: feature_requirements
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)),
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)),
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
@@ -1210,6 +1256,7 @@ mod tests {
remote_sandbox_config: None,
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
feature_requirements: Some(feature_requirements.clone()),
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1241,6 +1288,7 @@ mod tests {
feature_requirements,
enforce_source.clone(),
)),
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1278,6 +1326,7 @@ mod tests {
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1323,6 +1372,7 @@ mod tests {
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -2232,6 +2282,124 @@ allowed_approvals_reviewers = ["user"]
Ok(())
}
#[test]
fn deserialize_managed_hooks_requirements() -> Result<()> {
let toml_str = r#"
managed_dir = "/enterprise/hooks"
windows_managed_dir = 'C:\enterprise\hooks'
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "python3 /enterprise/hooks/pre.py"
timeout = 10
statusMessage = "checking"
"#;
let hooks: ManagedHooksRequirementsToml = from_str(toml_str)?;
assert_eq!(
hooks.managed_dir.as_deref(),
Some(std::path::Path::new("/enterprise/hooks"))
);
assert_eq!(hooks.handler_count(), 1);
assert_eq!(hooks.hooks.pre_tool_use.len(), 1);
Ok(())
}
#[test]
fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> {
let mut target = ConfigRequirementsWithSources::default();
target.merge_unset_fields(
RequirementSource::CloudRequirements,
from_str::<ConfigRequirementsToml>(
r#"
[hooks]
managed_dir = "/cloud/hooks"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /cloud/hooks/pre.py"
"#,
)?,
);
target.merge_unset_fields(
RequirementSource::SystemRequirementsToml {
file: system_requirements_toml_file_for_test()?,
},
from_str::<ConfigRequirementsToml>(
r#"
[hooks]
managed_dir = "/system/hooks"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /system/hooks/pre.py"
"#,
)?,
);
assert_eq!(
target
.hooks
.as_ref()
.and_then(|hooks| hooks.value.managed_dir.as_ref())
.map(std::path::PathBuf::as_path),
Some(std::path::Path::new("/cloud/hooks"))
);
assert_eq!(
target.hooks.as_ref().map(|hooks| hooks.source.clone()),
Some(RequirementSource::CloudRequirements)
);
Ok(())
}
#[test]
fn managed_hooks_constraint_rejects_drift() -> Result<()> {
let config: ConfigRequirementsToml = from_str(
r#"
[hooks]
managed_dir = "/enterprise/hooks"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /enterprise/hooks/pre.py"
"#,
)?;
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
let mut managed_hooks = requirements
.managed_hooks
.expect("expected managed hooks requirements");
let err = managed_hooks
.set(ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/other/hooks")),
windows_managed_dir: None,
hooks: HookEventsToml::default(),
})
.expect_err("managed hooks should reject drift");
assert!(matches!(
err,
ConstraintError::InvalidValue {
field_name: "hooks",
requirement_source: RequirementSource::Unknown,
..
}
));
Ok(())
}
#[test]
fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> {
let toml_str = r#"
+4
View File
@@ -4,6 +4,7 @@ use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::Path;
use crate::HookEventsToml;
use crate::permissions_toml::PermissionsToml;
use crate::profile_toml::ConfigProfile;
use crate::types::AnalyticsConfigToml;
@@ -332,6 +333,9 @@ pub struct ConfigToml {
/// User-level skill config entries keyed by SKILL.md path.
pub skills: Option<SkillsConfig>,
/// Lifecycle hooks configured inline in TOML.
pub hooks: Option<HookEventsToml>,
/// User-level plugin config entries keyed by plugin name.
#[serde(default)]
pub plugins: HashMap<String, PluginConfig>,
+148
View File
@@ -0,0 +1,148 @@
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct HooksFile {
#[serde(default)]
pub hooks: HookEventsToml,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct HookEventsToml {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "PermissionRequest", default)]
pub permission_request: Vec<MatcherGroup>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
impl HookEventsToml {
pub fn is_empty(&self) -> bool {
let Self {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = self;
pre_tool_use.is_empty()
&& permission_request.is_empty()
&& post_tool_use.is_empty()
&& session_start.is_empty()
&& user_prompt_submit.is_empty()
&& stop.is_empty()
}
pub fn handler_count(&self) -> usize {
let Self {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = self;
[
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
]
.into_iter()
.flatten()
.map(|group| group.hooks.len())
.sum()
}
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 6] {
[
(HookEventName::PreToolUse, self.pre_tool_use),
(HookEventName::PermissionRequest, self.permission_request),
(HookEventName::PostToolUse, self.post_tool_use),
(HookEventName::SessionStart, self.session_start),
(HookEventName::UserPromptSubmit, self.user_prompt_submit),
(HookEventName::Stop, self.stop),
]
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MatcherGroup {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub hooks: Vec<HookHandlerConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
pub enum HookHandlerConfig {
#[serde(rename = "command")]
Command {
command: String,
#[serde(default, rename = "timeout")]
timeout_sec: Option<u64>,
#[serde(default)]
r#async: bool,
#[serde(default, rename = "statusMessage")]
status_message: Option<String>,
},
#[serde(rename = "prompt")]
Prompt {},
#[serde(rename = "agent")]
Agent {},
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedHooksRequirementsToml {
pub managed_dir: Option<PathBuf>,
pub windows_managed_dir: Option<PathBuf>,
#[serde(flatten)]
pub hooks: HookEventsToml,
}
impl ManagedHooksRequirementsToml {
pub fn is_empty(&self) -> bool {
let Self {
managed_dir,
windows_managed_dir,
hooks,
} = self;
managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty()
}
pub fn handler_count(&self) -> usize {
self.hooks.handler_count()
}
pub fn managed_dir_for_current_platform(&self) -> Option<&Path> {
#[cfg(windows)]
{
self.windows_managed_dir.as_deref()
}
#[cfg(not(windows))]
{
self.managed_dir.as_deref()
}
}
}
#[cfg(test)]
#[path = "hooks_tests.rs"]
mod tests;
+119
View File
@@ -0,0 +1,119 @@
use pretty_assertions::assert_eq;
use super::HookEventsToml;
use super::HookHandlerConfig;
use super::HooksFile;
use super::ManagedHooksRequirementsToml;
use super::MatcherGroup;
#[test]
fn hooks_file_deserializes_existing_json_shape() {
let parsed: HooksFile = serde_json::from_str(
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 /tmp/pre.py",
"timeout": 10,
"statusMessage": "checking"
}
]
}
]
}
}"#,
)
.expect("hooks.json should deserialize");
assert_eq!(
parsed,
HooksFile {
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}
);
}
#[test]
fn hook_events_deserialize_from_toml_arrays_of_tables() {
let parsed: HookEventsToml = toml::from_str(
r#"
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "python3 /tmp/pre.py"
timeout = 10
statusMessage = "checking"
"#,
)
.expect("hook events TOML should deserialize");
assert_eq!(
parsed,
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
}
);
}
#[test]
fn managed_hooks_requirements_flatten_hook_events() {
let parsed: ManagedHooksRequirementsToml = toml::from_str(
r#"
managed_dir = "/enterprise/place"
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "python3 /enterprise/place/pre.py"
"#,
)
.expect("requirements hooks TOML should deserialize");
assert_eq!(
parsed,
ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
windows_managed_dir: None,
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /enterprise/place/pre.py".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
},
}
);
}
+9 -2
View File
@@ -4,6 +4,7 @@ pub mod config_toml;
mod constraint;
mod diagnostics;
mod fingerprint;
mod hook_config;
mod host_name;
mod key_aliases;
mod marketplace_edit;
@@ -27,6 +28,8 @@ pub const CONFIG_TOML_FILE: &str = "config.toml";
pub use cloud_requirements::CloudRequirementsLoadError;
pub use cloud_requirements::CloudRequirementsLoadErrorCode;
pub use cloud_requirements::CloudRequirementsLoader;
pub use codex_app_server_protocol::ConfigLayerSource;
pub use codex_utils_absolute_path::AbsolutePathBuf;
pub use config_requirements::AppRequirementToml;
pub use config_requirements::AppsRequirementsToml;
pub use config_requirements::ConfigRequirements;
@@ -65,6 +68,11 @@ pub use diagnostics::format_config_error;
pub use diagnostics::format_config_error_with_source;
pub use diagnostics::io_error_from_config_error;
pub use fingerprint::version_for_toml;
pub use hook_config::HookEventsToml;
pub use hook_config::HookHandlerConfig;
pub use hook_config::HooksFile;
pub use hook_config::ManagedHooksRequirementsToml;
pub use hook_config::MatcherGroup;
pub use host_name::host_name;
pub use marketplace_edit::MarketplaceConfigUpdate;
pub use marketplace_edit::RemoveMarketplaceConfigOutcome;
@@ -106,5 +114,4 @@ pub use thread_config::ThreadConfigLoadErrorCode;
pub use thread_config::ThreadConfigLoader;
pub use thread_config::ThreadConfigSource;
pub use thread_config::UserThreadConfig;
pub use codex_app_server_protocol::ConfigLayerSource;
pub use toml::Value as TomlValue;
+135
View File
@@ -848,6 +848,117 @@
}
]
},
"HookEventsToml": {
"properties": {
"PermissionRequest": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"PostToolUse": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"PreToolUse": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"SessionStart": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"Stop": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
}
},
"type": "object"
},
"HookHandlerConfig": {
"oneOf": [
{
"properties": {
"async": {
"default": false,
"type": "boolean"
},
"command": {
"type": "string"
},
"statusMessage": {
"default": null,
"type": "string"
},
"timeout": {
"default": null,
"format": "uint64",
"minimum": 0.0,
"type": "integer"
},
"type": {
"enum": [
"command"
],
"type": "string"
}
},
"required": [
"command",
"type"
],
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"prompt"
],
"type": "string"
}
},
"required": [
"type"
],
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"agent"
],
"type": "string"
}
},
"required": [
"type"
],
"type": "object"
}
]
},
"MarketplaceConfig": {
"additionalProperties": false,
"properties": {
@@ -898,6 +1009,22 @@
],
"type": "string"
},
"MatcherGroup": {
"properties": {
"hooks": {
"default": [],
"items": {
"$ref": "#/definitions/HookHandlerConfig"
},
"type": "array"
},
"matcher": {
"default": null,
"type": "string"
}
},
"type": "object"
},
"McpServerEnvVar": {
"anyOf": [
{
@@ -2634,6 +2761,14 @@
"default": null,
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
},
"hooks": {
"allOf": [
{
"$ref": "#/definitions/HookEventsToml"
}
],
"description": "Lifecycle hooks configured inline in TOML."
},
"include_apps_instructions": {
"description": "Whether to inject the `<apps_instructions>` developer block.",
"type": "boolean"
+2
View File
@@ -5732,6 +5732,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
crate::config_loader::WebSearchModeRequirement::Cached,
]),
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -6407,6 +6408,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
remote_sandbox_config: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
+1
View File
@@ -1577,6 +1577,7 @@ impl Config {
sandbox_policy: mut constrained_sandbox_policy,
web_search_mode: mut constrained_web_search_mode,
feature_requirements,
managed_hooks: _,
mcp_servers,
exec_policy: _,
enforce_residency,
+4
View File
@@ -45,7 +45,11 @@ pub use codex_config::ConstrainedWithSource;
pub use codex_config::FeatureRequirementsToml;
pub use codex_config::FilesystemConstraints;
pub use codex_config::FilesystemDenyReadPattern;
pub use codex_config::HookEventsToml;
pub use codex_config::HookHandlerConfig;
pub use codex_config::LoaderOverrides;
pub use codex_config::ManagedHooksRequirementsToml;
pub use codex_config::MatcherGroup;
pub use codex_config::McpServerIdentity;
pub use codex_config::McpServerRequirement;
pub use codex_config::NetworkConstraints;
+59
View File
@@ -777,6 +777,7 @@ allowed_approval_policies = ["on-request"]
remote_sandbox_config: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -833,6 +834,7 @@ allowed_approval_policies = ["on-request"]
remote_sandbox_config: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1041,6 +1043,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
remote_sandbox_config: None,
allowed_web_search_modes: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -1084,6 +1087,62 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
Ok(())
}
#[tokio::test]
async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result<()> {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
let managed_dir = tmp.path().join("managed-hooks");
tokio::fs::create_dir_all(&managed_dir).await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let requirements = ConfigRequirementsToml {
hooks: Some(codex_config::ManagedHooksRequirementsToml {
managed_dir: Some(managed_dir.clone()),
windows_managed_dir: None,
hooks: codex_config::HookEventsToml {
pre_tool_use: vec![codex_config::MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![codex_config::HookHandlerConfig::Command {
command: format!("python3 {}/pre.py", managed_dir.display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}),
..ConfigRequirementsToml::default()
};
let expected = requirements.clone();
let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) });
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
cloud_requirements,
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
assert_eq!(layers.requirements_toml().hooks, expected.hooks);
assert_eq!(
layers
.requirements()
.managed_hooks
.as_ref()
.map(|hooks| hooks.source.clone()),
Some(Some(RequirementSource::CloudRequirements))
);
Ok(())
}
#[tokio::test]
async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> {
let tmp = tempdir()?;
+3 -3
View File
@@ -482,12 +482,12 @@ impl TestCodexBuilder {
..built_in_model_providers(/*openai_base_url*/ None)["openai"].clone()
};
let cwd = Arc::new(TempDir::new()?);
let mut config = load_default_config_for_test(home).await;
config.cwd = cwd_override;
config.model_provider = model_provider;
for hook in self.pre_build_hooks.drain(..) {
hook(home.path());
}
let mut config = load_default_config_for_test(home).await;
config.cwd = cwd_override;
config.model_provider = model_provider;
if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") {
config.codex_self_exe = Some(path);
} else if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex-exec") {
+260 -9
View File
@@ -254,6 +254,74 @@ elif mode == "exit_2":
Ok(())
}
fn write_pre_tool_use_hook_toml(
home: &Path,
script_name: &str,
log_name: &str,
matcher: Option<&str>,
mode: &str,
reason: &str,
) -> Result<()> {
let script_path = home.join(script_name);
let log_path = home.join(log_name);
let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?;
let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
log_path = Path(r"{log_path}")
mode = {mode_json}
reason = {reason_json}
payload = json.load(sys.stdin)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
if mode == "json_deny":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}}
}}))
elif mode == "exit_2":
sys.stderr.write(reason + "\n")
raise SystemExit(2)
"#,
log_path = log_path.display(),
mode_json = mode_json,
reason_json = reason_json,
);
let matcher_line = matcher
.map(|matcher| format!("matcher = '{matcher}'\n"))
.unwrap_or_default();
let config_toml = format!(
r#"[features]
codex_hooks = true
[hooks]
[[hooks.PreToolUse]]
{matcher_line}
[[hooks.PreToolUse.hooks]]
type = "command"
command = 'python3 {script_path}'
statusMessage = "running pre tool use hook"
"#,
matcher_line = matcher_line,
script_path = script_path.display(),
);
fs::write(&script_path, script).context("write TOML pre tool use hook script")?;
fs::write(home.join("config.toml"), config_toml).context("write config.toml hooks")?;
Ok(())
}
fn write_permission_request_hook(
home: &Path,
matcher: Option<&str>,
@@ -546,12 +614,7 @@ fn read_stop_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
}
fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(home.join("pre_tool_use_hook_log.jsonl"))
.context("read pre tool use hook log")?
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).context("parse pre tool use hook log line"))
.collect()
read_hook_inputs_from_log(home.join("pre_tool_use_hook_log.jsonl").as_path())
}
fn read_permission_request_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
@@ -605,11 +668,15 @@ fn assert_single_permission_request_hook_input_for_tool(
}
fn read_post_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(home.join("post_tool_use_hook_log.jsonl"))
.context("read post tool use hook log")?
read_hook_inputs_from_log(home.join("post_tool_use_hook_log.jsonl").as_path())
}
fn read_hook_inputs_from_log(log_path: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(log_path)
.with_context(|| format!("read hook log {}", log_path.display()))?
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).context("parse post tool use hook log line"))
.map(|line| serde_json::from_str(line).context("parse hook log line"))
.collect()
}
@@ -1785,6 +1852,190 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-config-toml";
let marker = std::env::temp_dir().join("pretooluse-config-toml-marker");
let command = format!("printf blocked > {}", marker.display());
let args = serde_json::json!({ "command": command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "config.toml hook blocked it"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex().with_pre_build_hook(|home| {
if let Err(error) = write_pre_tool_use_hook_toml(
home,
"pre_tool_use_config_hook.py",
"pre_tool_use_config_hook_log.jsonl",
Some("^Bash$"),
"json_deny",
"blocked by config toml hook",
) {
panic!("failed to write config.toml hook test fixture: {error}");
}
});
let test = builder.build(&server).await?;
if marker.exists() {
fs::remove_file(&marker).context("remove leftover config.toml marker")?;
}
test.submit_turn_with_policy(
"run the blocked shell command from config toml",
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell command output string");
assert!(
output.contains("Command blocked by PreToolUse hook: blocked by config toml hook"),
"blocked tool output should surface the config.toml hook reason",
);
assert!(
!marker.exists(),
"config.toml hook should block command execution"
);
let hook_inputs = read_hook_inputs_from_log(
test.codex_home_path()
.join("pre_tool_use_config_hook_log.jsonl")
.as_path(),
)?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-merged-sources";
let command = "printf merged-hooks".to_string();
let args = serde_json::json!({ "command": command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "merged hook context observed"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex().with_pre_build_hook(|home| {
if let Err(error) = write_pre_tool_use_hook(home, Some("^Bash$"), "allow", "unused") {
panic!("failed to write hooks.json hook fixture: {error}");
}
if let Err(error) = write_pre_tool_use_hook_toml(
home,
"pre_tool_use_toml_hook.py",
"pre_tool_use_toml_hook_log.jsonl",
Some("^Bash$"),
"allow",
"unused",
) {
panic!("failed to write config.toml hook fixture: {error}");
}
});
let test = builder.build(&server).await?;
test.submit_turn("run the shell command with merged hook sources")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell command output string");
assert!(
output.contains("merged-hooks"),
"shell command output should still reach the model",
);
let json_hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?
.into_iter()
.map(|hook_input| {
serde_json::json!({
"hook_event_name": hook_input["hook_event_name"],
"tool_name": hook_input["tool_name"],
"tool_use_id": hook_input["tool_use_id"],
"tool_input": hook_input["tool_input"],
})
})
.collect::<Vec<_>>();
let toml_hook_inputs = read_hook_inputs_from_log(
test.codex_home_path()
.join("pre_tool_use_toml_hook_log.jsonl")
.as_path(),
)?
.into_iter()
.map(|hook_input| {
serde_json::json!({
"hook_event_name": hook_input["hook_event_name"],
"tool_name": hook_input["tool_name"],
"tool_use_id": hook_input["tool_use_id"],
"tool_input": hook_input["tool_input"],
})
})
.collect::<Vec<_>>();
let expected_hook_inputs = vec![serde_json::json!({
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_use_id": call_id,
"tool_input": {
"command": command,
},
})];
assert_eq!(expected_hook_inputs, json_hook_inputs);
assert_eq!(expected_hook_inputs, toml_hook_inputs);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
-50
View File
@@ -1,50 +0,0 @@
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HooksFile {
#[serde(default)]
pub hooks: HookEvents,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HookEvents {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "PermissionRequest", default)]
pub permission_request: Vec<MatcherGroup>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct MatcherGroup {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub hooks: Vec<HookHandlerConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub(crate) enum HookHandlerConfig {
#[serde(rename = "command")]
Command {
command: String,
#[serde(default, rename = "timeout", alias = "timeoutSec")]
timeout_sec: Option<u64>,
#[serde(default)]
r#async: bool,
#[serde(default, rename = "statusMessage")]
status_message: Option<String>,
},
#[serde(rename = "prompt")]
Prompt {},
#[serde(rename = "agent")]
Agent {},
}
+354 -134
View File
@@ -1,15 +1,23 @@
use std::fs;
use std::path::Path;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::HooksFile;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::RequirementSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
use serde::Deserialize;
use super::ConfiguredHandler;
use super::config::HookHandlerConfig;
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 {
@@ -17,6 +25,13 @@ pub(crate) struct DiscoveryResult {
pub warnings: Vec<String>,
}
#[derive(Clone, Copy)]
struct HookHandlerSource<'a> {
path: &'a AbsolutePathBuf,
is_managed: bool,
source: HookSource,
}
pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -> DiscoveryResult {
let Some(config_layer_stack) = config_layer_stack else {
return DiscoveryResult {
@@ -29,80 +44,58 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
let mut warnings = Vec::new();
let mut display_order = 0_i64;
append_managed_requirement_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
config_layer_stack,
);
for layer in config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
let Some(folder) = layer.config_folder() else {
continue;
};
let source_path = folder.join("hooks.json");
if !source_path.as_path().is_file() {
continue;
let hook_source = hook_source_for_config_layer_source(&layer.name);
let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings);
let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings);
if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) =
(&json_hooks, &toml_hooks)
&& !json_events.is_empty()
&& !toml_events.is_empty()
{
warnings.push(format!(
"loading hooks from both {} and {}; prefer a single representation for this layer",
json_source_path.display(),
toml_source_path.display()
));
}
let contents = match fs::read_to_string(source_path.as_path()) {
Ok(contents) => contents,
Err(err) => {
warnings.push(format!(
"failed to read hooks config {}: {err}",
source_path.display()
));
continue;
}
};
let parsed: HooksFile = match serde_json::from_str(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
"failed to parse hooks config {}: {err}",
source_path.display()
));
continue;
}
};
let super::config::HookEvents {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = parsed.hooks;
for (event_name, groups) in [
(
codex_protocol::protocol::HookEventName::PreToolUse,
pre_tool_use,
),
(
codex_protocol::protocol::HookEventName::PermissionRequest,
permission_request,
),
(
codex_protocol::protocol::HookEventName::PostToolUse,
post_tool_use,
),
(
codex_protocol::protocol::HookEventName::SessionStart,
session_start,
),
(
codex_protocol::protocol::HookEventName::UserPromptSubmit,
user_prompt_submit,
),
(codex_protocol::protocol::HookEventName::Stop, stop),
] {
append_matcher_groups(
if let Some((source_path, hook_events)) = json_hooks {
append_hook_events(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path,
hook_source_for_config_layer_source(&layer.name),
event_name,
groups,
HookHandlerSource {
path: &source_path,
is_managed: false,
source: hook_source,
},
hook_events,
);
}
if let Some((source_path, hook_events)) = toml_hooks {
append_hook_events(
&mut handlers,
&mut warnings,
&mut display_order,
HookHandlerSource {
path: &source_path,
is_managed: false,
source: hook_source,
},
hook_events,
);
}
}
@@ -110,71 +103,272 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
DiscoveryResult { handlers, warnings }
}
fn append_managed_requirement_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
config_layer_stack: &ConfigLayerStack,
) {
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;
};
append_hook_events(
handlers,
warnings,
display_order,
HookHandlerSource {
path: &source_path,
is_managed: true,
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
},
managed_hooks.get().hooks.clone(),
);
}
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;
};
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()
}
}
fn load_hooks_json(
config_folder: Option<&Path>,
warnings: &mut Vec<String>,
) -> Option<(AbsolutePathBuf, HookEventsToml)> {
let source_path = config_folder?.join("hooks.json");
if !source_path.as_path().is_file() {
return None;
}
let contents = match fs::read_to_string(source_path.as_path()) {
Ok(contents) => contents,
Err(err) => {
warnings.push(format!(
"failed to read hooks config {}: {err}",
source_path.display()
));
return None;
}
};
let parsed: HooksFile = match serde_json::from_str(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
"failed to parse hooks config {}: {err}",
source_path.display()
));
return None;
}
};
let source_path = AbsolutePathBuf::from_absolute_path(&source_path)
.inspect_err(|err| {
warnings.push(format!(
"failed to normalize hooks config path {}: {err}",
source_path.display()
));
})
.ok()?;
(!parsed.hooks.is_empty()).then_some((source_path, parsed.hooks))
}
fn load_toml_hooks_from_layer(
layer: &ConfigLayerEntry,
warnings: &mut Vec<String>,
) -> Option<(AbsolutePathBuf, HookEventsToml)> {
let source_path = config_toml_source_path(layer);
let hook_value = layer.config.get("hooks")?.clone();
let parsed = match HookEventsToml::deserialize(hook_value) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
"failed to parse TOML hooks in {}: {err}",
source_path.display()
));
return None;
}
};
(!parsed.is_empty()).then_some((source_path, parsed))
}
fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
match &layer.name {
ConfigLayerSource::System { file }
| ConfigLayerSource::User { file }
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(),
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder.join(CONFIG_TOML_FILE),
ConfigLayerSource::Mdm { domain, key } => {
synthetic_layer_path(&format!("<mdm:{domain}:{key}>/{CONFIG_TOML_FILE}"))
}
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")
}
ConfigLayerSource::SessionFlags => synthetic_layer_path("<session-flags>/config.toml"),
}
}
fn synthetic_layer_path(path: &str) -> AbsolutePathBuf {
#[cfg(windows)]
{
AbsolutePathBuf::resolve_path_against_base(path, r"C:\")
}
#[cfg(not(windows))]
{
AbsolutePathBuf::resolve_path_against_base(path, "/")
}
}
fn append_hook_events(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source: HookHandlerSource<'_>,
hook_events: HookEventsToml,
) {
for (event_name, groups) in hook_events.into_matcher_groups() {
append_matcher_groups(
handlers,
warnings,
display_order,
source,
event_name,
groups,
);
}
}
fn append_matcher_groups(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source_path: &AbsolutePathBuf,
source: HookSource,
source: HookHandlerSource<'_>,
event_name: codex_protocol::protocol::HookEventName,
groups: Vec<MatcherGroup>,
) {
for group in groups {
let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref());
if let Some(matcher) = matcher
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source_path.display()
));
continue;
}
append_group_handlers(
handlers,
warnings,
display_order,
source,
event_name,
matcher_pattern_for_event(event_name, group.matcher.as_deref()),
group.hooks,
);
}
}
for handler in group.hooks {
match handler {
HookHandlerConfig::Command {
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source: HookHandlerSource<'_>,
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
group_handlers: Vec<HookHandlerConfig>,
) {
if let Some(matcher) = matcher
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source.path.display()
));
return;
}
for handler in group_handlers {
match handler {
HookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => {
if r#async {
warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source.path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source.path.display()
));
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
is_managed: source.is_managed,
matcher: matcher.map(ToOwned::to_owned),
command,
timeout_sec,
r#async,
status_message,
} => {
if r#async {
warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source_path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source_path.display()
));
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
matcher: matcher.map(ToOwned::to_owned),
command,
timeout_sec,
status_message,
source_path: source_path.clone(),
source,
display_order: *display_order,
});
*display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source_path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source_path.display()
)),
source_path: source.path.clone(),
source: source.source,
display_order: *display_order,
});
*display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source.path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source.path.display()
)),
}
}
}
@@ -193,6 +387,22 @@ fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource
}
}
fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> HookSource {
match source {
Some(RequirementSource::MdmManagedPreferences { .. }) => HookSource::Mdm,
Some(RequirementSource::SystemRequirementsToml { .. }) => HookSource::System,
Some(RequirementSource::LegacyManagedConfigTomlFromFile { .. }) => {
HookSource::LegacyManagedConfigFile
}
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
HookSource::LegacyManagedConfigMdm
}
Some(RequirementSource::CloudRequirements | RequirementSource::Unknown) | None => {
HookSource::Unknown
}
}
}
#[cfg(test)]
mod tests {
use codex_config::ConfigLayerSource;
@@ -204,9 +414,9 @@ mod tests {
use pretty_assertions::assert_eq;
use super::ConfiguredHandler;
use super::HookHandlerConfig;
use super::MatcherGroup;
use super::append_matcher_groups;
use codex_config::HookHandlerConfig;
use codex_config::MatcherGroup;
fn source_path() -> AbsolutePathBuf {
test_path_buf("/tmp/hooks.json").abs()
@@ -216,6 +426,14 @@ mod tests {
HookSource::User
}
fn hook_handler_source(path: &AbsolutePathBuf) -> super::HookHandlerSource<'_> {
super::HookHandlerSource {
path,
is_managed: false,
source: hook_source(),
}
}
fn command_group(matcher: Option<&str>) -> MatcherGroup {
MatcherGroup {
matcher: matcher.map(str::to_string),
@@ -233,13 +451,13 @@ mod tests {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
hook_handler_source(&source_path),
HookEventName::UserPromptSubmit,
vec![command_group(Some("["))],
);
@@ -249,11 +467,12 @@ mod tests {
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
is_managed: false,
matcher: None,
command: "echo hello".to_string(),
timeout_sec: 600,
status_message: None,
source_path: source_path(),
source_path: source_path.clone(),
source: hook_source(),
display_order: 0,
}]
@@ -265,13 +484,13 @@ mod tests {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
hook_handler_source(&source_path),
HookEventName::PreToolUse,
vec![command_group(Some("^Bash$"))],
);
@@ -281,11 +500,12 @@ mod tests {
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::PreToolUse,
is_managed: false,
matcher: Some("^Bash$".to_string()),
command: "echo hello".to_string(),
timeout_sec: 600,
status_message: None,
source_path: source_path(),
source_path: source_path.clone(),
source: hook_source(),
display_order: 0,
}]
@@ -297,13 +517,13 @@ mod tests {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
hook_handler_source(&source_path),
HookEventName::PreToolUse,
vec![command_group(Some("*"))],
);
@@ -318,13 +538,13 @@ mod tests {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
hook_handler_source(&source_path),
HookEventName::PostToolUse,
vec![command_group(Some("Edit|Write"))],
);
+1
View File
@@ -156,6 +156,7 @@ mod tests {
) -> ConfiguredHandler {
ConfiguredHandler {
event_name,
is_managed: false,
matcher: matcher.map(str::to_owned),
command: command.to_string(),
timeout_sec: 5,
+5 -1
View File
@@ -1,5 +1,4 @@
pub(crate) mod command_runner;
pub(crate) mod config;
pub(crate) mod discovery;
pub(crate) mod dispatcher;
pub(crate) mod output_parser;
@@ -32,6 +31,7 @@ pub(crate) struct CommandShell {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConfiguredHandler {
pub event_name: codex_protocol::protocol::HookEventName,
pub is_managed: bool,
pub matcher: Option<String>,
pub command: String,
pub timeout_sec: u64,
@@ -170,3 +170,7 @@ impl ClaudeHooksEngine {
crate::events::stop::run(&self.handlers, &self.shell, request).await
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+327
View File
@@ -0,0 +1,327 @@
use std::fs;
use std::path::Path;
use codex_config::AbsolutePathBuf;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::Constrained;
use codex_config::ConstrainedWithSource;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::RequirementSource;
use codex_config::TomlValue;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use super::ClaudeHooksEngine;
use super::CommandShell;
use crate::events::pre_tool_use::PreToolUseRequest;
fn cwd() -> AbsolutePathBuf {
AbsolutePathBuf::current_dir().expect("current dir")
}
fn managed_hooks_for_current_platform(
managed_dir: impl AsRef<Path>,
hooks: HookEventsToml,
) -> ManagedHooksRequirementsToml {
let managed_dir = managed_dir.as_ref().to_path_buf();
ManagedHooksRequirementsToml {
managed_dir: if cfg!(windows) {
None
} else {
Some(managed_dir.clone())
},
windows_managed_dir: if cfg!(windows) {
Some(managed_dir)
} else {
None
},
hooks,
}
}
#[tokio::test]
async fn requirements_managed_hooks_execute_from_managed_dir() {
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 script_path = managed_dir.join("pre_tool_use.py");
let log_path = managed_dir.join("pre_tool_use_log.jsonl");
fs::write(
script_path.as_path(),
format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
"#,
log_path = log_path.display(),
),
)
.expect("write managed hook script");
let managed_hooks = managed_hooks_for_current_platform(
managed_dir.clone(),
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: format!("python3 {}", script_path.display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
);
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(managed_hooks.clone()),
Some(RequirementSource::CloudRequirements),
)),
..ConfigRequirements::default()
},
ConfigRequirementsToml {
hooks: Some(managed_hooks),
..ConfigRequirementsToml::default()
},
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().is_empty());
assert_eq!(engine.handlers.len(), 1);
assert!(engine.handlers[0].is_managed);
let cwd = cwd();
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: cwd.clone(),
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(),
command: "echo hello".to_string(),
});
assert_eq!(preview.len(), 1);
assert_eq!(preview[0].source_path, managed_dir);
let outcome = engine
.run_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(),
command: "echo hello".to_string(),
})
.await;
assert!(!outcome.should_block);
let log_contents = fs::read_to_string(log_path).expect("read managed hook log");
assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));
}
#[test]
fn requirements_managed_hooks_warn_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(
missing_dir.clone(),
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: format!("python3 {}", missing_dir.join("pre.py").display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
);
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(managed_hooks.clone()),
Some(RequirementSource::CloudRequirements),
)),
..ConfigRequirements::default()
},
ConfigRequirementsToml {
hooks: Some(managed_hooks),
..ConfigRequirementsToml::default()
},
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().iter().any(|warning| {
warning.contains("managed hook directory")
&& warning.contains("does not exist")
&& warning.contains(&missing_dir.display().to_string())
}));
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(),
command: "echo hello".to_string(),
})
.is_empty()
);
}
#[test]
fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
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 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()),
);
pre_tool_use_group_entries.insert(
"hooks".to_string(),
TomlValue::Array(vec![TomlValue::Table(Default::default())]),
);
let Some(TomlValue::Array(hooks_array)) = pre_tool_use_group_entries.get_mut("hooks") else {
unreachable!("PreToolUse hooks should be an array");
};
let Some(TomlValue::Table(handler_entries)) = hooks_array.first_mut() 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("python3 /tmp/toml-hook.py".to_string()),
);
hooks_entries.insert(
"PreToolUse".to_string(),
TomlValue::Array(vec![pre_tool_use_group]),
);
config_table.insert("hooks".to_string(), hooks_table);
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_path.clone(),
},
config_toml,
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().iter().any(|warning| {
warning.contains("loading hooks from both")
&& warning.contains(&hooks_json_path.display().to_string())
&& warning.contains(&config_path.display().to_string())
}));
let cwd = cwd();
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(),
command: "echo hello".to_string(),
});
assert_eq!(preview.len(), 2);
assert!(engine.handlers.iter().all(|handler| !handler.is_managed));
assert_eq!(preview[0].source_path, hooks_json_path);
assert_eq!(preview[1].source_path, config_path);
}
@@ -545,6 +545,7 @@ mod tests {
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::PostToolUse,
is_managed: false,
matcher: Some("^Bash$".to_string()),
command: "python3 post_tool_use_hook.py".to_string(),
timeout_sec: 5,
@@ -534,6 +534,7 @@ mod tests {
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::PreToolUse,
is_managed: false,
matcher: Some("^Bash$".to_string()),
command: "echo hook".to_string(),
timeout_sec: 5,
@@ -356,6 +356,7 @@ mod tests {
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::SessionStart,
is_managed: false,
matcher: None,
command: "echo hook".to_string(),
timeout_sec: 600,
+1
View File
@@ -523,6 +523,7 @@ mod tests {
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::Stop,
is_managed: false,
matcher: None,
command: "echo hook".to_string(),
timeout_sec: 600,
@@ -414,6 +414,7 @@ mod tests {
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
is_managed: false,
matcher: None,
command: "echo hook".to_string(),
timeout_sec: 5,
+79
View File
@@ -4,6 +4,7 @@ use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::NetworkConstraints;
use codex_config::NetworkDomainPermissionToml;
use codex_config::NetworkUnixSocketPermissionToml;
@@ -168,6 +169,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
));
}
if let Some(hooks) = requirements_toml.hooks.as_ref() {
requirement_lines.push(requirement_line(
"hooks",
format_managed_hooks_requirements(hooks),
requirements
.managed_hooks
.as_ref()
.and_then(|managed_hooks| managed_hooks.source.as_ref()),
));
}
if let Some(servers) = requirements_toml.mcp_servers.as_ref() {
let value = join_or_empty(servers.keys().cloned().collect::<Vec<_>>());
requirement_lines.push(requirement_line(
@@ -257,6 +269,23 @@ fn render_session_flag_details(config: &TomlValue) -> Vec<Line<'static>> {
.collect()
}
fn format_managed_hooks_requirements(hooks: &ManagedHooksRequirementsToml) -> String {
let mut parts = Vec::new();
if let Some(managed_dir) = hooks.managed_dir.as_ref() {
parts.push(format!("managed_dir={}", managed_dir.display()));
}
if let Some(windows_managed_dir) = hooks.windows_managed_dir.as_ref() {
parts.push(format!(
"windows_managed_dir={}",
windows_managed_dir.display()
));
}
parts.push(format!("handlers={}", hooks.handler_count()));
join_or_empty(parts)
}
fn render_mdm_layer_details(layer: &ConfigLayerEntry) -> Vec<Line<'static>> {
let value = layer
.raw_toml()
@@ -484,6 +513,10 @@ mod tests {
use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::FilesystemConstraints;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::NetworkConstraints;
@@ -655,6 +688,7 @@ mod tests {
feature_requirements: Some(FeatureRequirementsToml {
entries: BTreeMap::from([("guardian_approval".to_string(), true)]),
}),
hooks: None,
mcp_servers: Some(BTreeMap::from([(
"docs".to_string(),
McpServerRequirement {
@@ -860,6 +894,7 @@ approval_policy = "never"
allowed_web_search_modes: Some(Vec::new()),
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -877,6 +912,50 @@ approval_policy = "never"
);
}
#[test]
fn debug_config_output_lists_managed_hooks_requirement() {
let requirements = ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(ManagedHooksRequirementsToml {
managed_dir: Some(if cfg!(windows) {
std::path::PathBuf::from(r"C:\enterprise\hooks")
} else {
std::path::PathBuf::from("/enterprise/hooks")
}),
windows_managed_dir: Some(std::path::PathBuf::from(r"C:\enterprise\hooks")),
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}),
Some(RequirementSource::CloudRequirements),
)),
..ConfigRequirements::default()
};
let requirements_toml = ConfigRequirementsToml {
hooks: requirements
.managed_hooks
.as_ref()
.map(|hooks| hooks.get().clone()),
..ConfigRequirementsToml::default()
};
let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
.expect("config layer stack");
let rendered = render_to_text(&render_debug_config_lines(&stack));
assert!(rendered.contains("hooks:"));
assert!(rendered.contains("handlers=1"));
assert!(rendered.contains("(source: cloud requirements)"));
}
#[test]
fn session_all_proxy_url_uses_socks_when_enabled() {
assert_eq!(