From 2b2de3f38bcfbd2b20d2ecab650be5aed91608e3 Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Wed, 22 Apr 2026 21:20:09 -0700 Subject: [PATCH 1/8] 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 --- .../codex_app_server_protocol.schemas.json | 155 ++++++ .../codex_app_server_protocol.v2.schemas.json | 155 ++++++ .../v2/ConfigRequirementsReadResponse.json | 155 ++++++ .../typescript/v2/ConfiguredHookHandler.ts | 5 + .../v2/ConfiguredHookMatcherGroup.ts | 6 + .../typescript/v2/ManagedHooksRequirements.ts | 6 + .../schema/typescript/v2/index.ts | 3 + .../app-server-protocol/src/protocol/v2.rs | 61 +++ codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/config_api.rs | 112 ++++ .../src/message_processor/tracing_tests.rs | 176 ++++--- codex-rs/cloud-requirements/src/lib.rs | 16 + codex-rs/config/src/config_requirements.rs | 168 ++++++ codex-rs/config/src/config_toml.rs | 4 + codex-rs/config/src/hook_config.rs | 148 ++++++ codex-rs/config/src/hooks_tests.rs | 119 +++++ codex-rs/config/src/lib.rs | 11 +- codex-rs/core/config.schema.json | 135 +++++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/mod.rs | 1 + codex-rs/core/src/config_loader/mod.rs | 4 + codex-rs/core/src/config_loader/tests.rs | 59 +++ codex-rs/core/tests/common/test_codex.rs | 6 +- codex-rs/core/tests/suite/hooks.rs | 269 +++++++++- codex-rs/hooks/src/engine/config.rs | 50 -- codex-rs/hooks/src/engine/discovery.rs | 488 +++++++++++++----- codex-rs/hooks/src/engine/dispatcher.rs | 1 + codex-rs/hooks/src/engine/mod.rs | 6 +- codex-rs/hooks/src/engine/mod_tests.rs | 327 ++++++++++++ codex-rs/hooks/src/events/post_tool_use.rs | 1 + codex-rs/hooks/src/events/pre_tool_use.rs | 1 + codex-rs/hooks/src/events/session_start.rs | 1 + codex-rs/hooks/src/events/stop.rs | 1 + .../hooks/src/events/user_prompt_submit.rs | 1 + codex-rs/tui/src/debug_config.rs | 79 +++ 35 files changed, 2464 insertions(+), 270 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts create mode 100644 codex-rs/config/src/hook_config.rs create mode 100644 codex-rs/config/src/hooks_tests.rs delete mode 100644 codex-rs/hooks/src/engine/config.rs create mode 100644 codex-rs/hooks/src/engine/mod_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e16d84cadd..5e5269bfb2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 20e0f8f6e3..aaf0856627 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index f75833af1a..545d8dc9b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts new file mode 100644 index 0000000000..a81ce61f6e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts @@ -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", }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts new file mode 100644 index 0000000000..2c00fc166c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts new file mode 100644 index 0000000000..3386d16ec3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -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, PermissionRequest: Array, PostToolUse: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index aa0ed36d25..72a7a0d63b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1f23c7b536..9f1801c64f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -941,11 +941,71 @@ pub struct ConfigRequirements { pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, pub feature_requirements: Option>, + #[experimental("configRequirements/read.hooks")] + pub hooks: Option, pub enforce_residency: Option, #[experimental("configRequirements/read.network")] pub network: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ManagedHooksRequirements { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[serde(rename = "PreToolUse")] + #[ts(rename = "PreToolUse")] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest")] + #[ts(rename = "PermissionRequest")] + pub permission_request: Vec, + #[serde(rename = "PostToolUse")] + #[ts(rename = "PostToolUse")] + pub post_tool_use: Vec, + #[serde(rename = "SessionStart")] + #[ts(rename = "SessionStart")] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit")] + #[ts(rename = "UserPromptSubmit")] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop")] + #[ts(rename = "Stop")] + pub stop: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfiguredHookMatcherGroup { + pub matcher: Option, + pub hooks: Vec, +} + +#[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, + r#async: bool, + #[serde(rename = "statusMessage")] + #[ts(rename = "statusMessage")] + status_message: Option, + }, + #[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, }); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 15b7f9a5be..8a6ce3b728 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index a1df875c28..ce0ea34069 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -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, +) -> Vec { + 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 { 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, diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index e0a1ed4bc4..84fddf5e85 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -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(name: &str, future: F) -> Result<()> +where + F: Future> + 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")] diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 0b50aa834f..aeed90e8b0 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -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, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 1d3ea49cf3..56ff26f907 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -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, pub web_search_mode: ConstrainedWithSource, pub feature_requirements: Option>, + pub managed_hooks: Option>, pub mcp_servers: Option>>, pub exec_policy: Option>, pub enforce_residency: ConstrainedWithSource>, @@ -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>, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, + pub hooks: Option, pub mcp_servers: Option>, pub apps: Option, pub rules: Option, @@ -673,6 +677,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_sandbox_modes: Option>>, pub allowed_web_search_modes: Option>>, pub feature_requirements: Option>, + pub hooks: Option>, pub mcp_servers: Option>>, pub apps: Option>, pub rules: Option>, @@ -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 for ConfigRequirements { allowed_sandbox_modes, allowed_web_search_modes, feature_requirements, + hooks, mcp_servers, apps: _apps, rules, @@ -1064,6 +1078,34 @@ impl TryFrom 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 for ConfigRequirements { sandbox_policy, web_search_mode, feature_requirements, + managed_hooks, mcp_servers, exec_policy, enforce_residency, @@ -1119,6 +1162,7 @@ impl TryFrom 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::( + 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::( + 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#" diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 40f384f412..543cb59ef3 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -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, + /// Lifecycle hooks configured inline in TOML. + pub hooks: Option, + /// User-level plugin config entries keyed by plugin name. #[serde(default)] pub plugins: HashMap, diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs new file mode 100644 index 0000000000..8a5c73d6b9 --- /dev/null +++ b/codex-rs/config/src/hook_config.rs @@ -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, + #[serde(rename = "PermissionRequest", default)] + pub permission_request: Vec, + #[serde(rename = "PostToolUse", default)] + pub post_tool_use: Vec, + #[serde(rename = "SessionStart", default)] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit", default)] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop", default)] + pub stop: Vec, +} + +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); 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, + #[serde(default)] + pub hooks: Vec, +} + +#[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, + #[serde(default)] + r#async: bool, + #[serde(default, rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + Agent {}, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedHooksRequirementsToml { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[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; diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs new file mode 100644 index 0000000000..5e3f1df674 --- /dev/null +++ b/codex-rs/config/src/hooks_tests.rs @@ -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() + }, + } + ); +} diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e70550f009..e90d09c09d 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -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; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 245a55e1bd..a3cf84af44 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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 `` developer block.", "type": "boolean" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 500483f659..deaf4b2db8 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e89cb7ca0e..771c1a4869 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 95dd76f622..4681aa0753 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -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; diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 337960f1b9..7f61952690 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -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()?; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 74b05f6e17..b108be15f8 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -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") { diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 022c528753..3a0669c008 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -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> { } fn read_pre_tool_use_hook_inputs(home: &Path) -> Result> { - 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> { @@ -605,11 +668,15 @@ fn assert_single_permission_request_hook_input_for_tool( } fn read_post_tool_use_hook_inputs(home: &Path) -> Result> { - 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> { + 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::>(); + 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::>(); + 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(())); diff --git a/codex-rs/hooks/src/engine/config.rs b/codex-rs/hooks/src/engine/config.rs deleted file mode 100644 index f70a6fceb0..0000000000 --- a/codex-rs/hooks/src/engine/config.rs +++ /dev/null @@ -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, - #[serde(rename = "PermissionRequest", default)] - pub permission_request: Vec, - #[serde(rename = "PostToolUse", default)] - pub post_tool_use: Vec, - #[serde(rename = "SessionStart", default)] - pub session_start: Vec, - #[serde(rename = "UserPromptSubmit", default)] - pub user_prompt_submit: Vec, - #[serde(rename = "Stop", default)] - pub stop: Vec, -} - -#[derive(Debug, Default, Deserialize)] -pub(crate) struct MatcherGroup { - #[serde(default)] - pub matcher: Option, - #[serde(default)] - pub hooks: Vec, -} - -#[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, - #[serde(default)] - r#async: bool, - #[serde(default, rename = "statusMessage")] - status_message: Option, - }, - #[serde(rename = "prompt")] - Prompt {}, - #[serde(rename = "agent")] - Agent {}, -} diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 3b6354e829..4e704e0a03 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -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, } +#[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, + warnings: &mut Vec, + 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, +) -> Option { + 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, +) -> 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, +) -> 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!("/{CONFIG_TOML_FILE}")) + } + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { + synthetic_layer_path("/managed_config.toml") + } + ConfigLayerSource::SessionFlags => synthetic_layer_path("/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, + warnings: &mut Vec, + 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, warnings: &mut Vec, display_order: &mut i64, - source_path: &AbsolutePathBuf, - source: HookSource, + source: HookHandlerSource<'_>, event_name: codex_protocol::protocol::HookEventName, groups: Vec, ) { 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, + warnings: &mut Vec, + display_order: &mut i64, + source: HookHandlerSource<'_>, + event_name: codex_protocol::protocol::HookEventName, + matcher: Option<&str>, + group_handlers: Vec, +) { + 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"))], ); diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index d2c342897d..d1cda96541 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -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, diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index b4d59fc6cc..3bfb17f6d6 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -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, 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; diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs new file mode 100644 index 0000000000..527574b500 --- /dev/null +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -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, + 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); +} diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 294b08628e..265572069b 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -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, diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index ba4ebe167e..926fa9a603 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -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, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 3c51426fa4..b1ccdd440a 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -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, diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 5fe55568a4..f376dccd2c 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -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, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 9aaff9a7d4..2acd4808b8 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -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, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index c3383497fb..1502db16f7 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -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> { )); } + 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::>()); requirement_lines.push(requirement_line( @@ -257,6 +269,23 @@ fn render_session_flag_details(config: &TomlValue) -> Vec> { .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> { 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!( From 082fc4f632800f6aa5b0b3badcfbe3aa300f24a7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 21:29:32 -0700 Subject: [PATCH 2/8] protocol: report session permission profiles (#18282) ## Why Clients that observe `SessionConfigured` need the same canonical permission view that app-server thread responses provide. Reporting the profile in protocol events lets clients keep their local state synchronized without reinterpreting legacy sandbox fields. ## What changed This adds `permission_profile` to `SessionConfigured` and propagates it through core, exec JSON output, MCP server messages, and TUI history/widget handling. ## Verification - `cargo test -p codex-tui permissions -- --nocapture` - `cargo test -p codex-core --test all permissions_messages -- --nocapture` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/18282). * #18288 * #18287 * #18286 * #18285 * #18284 * #18283 * __->__ #18282 --- codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/session.rs | 9 +++++++ codex-rs/core/src/session/tests.rs | 27 +++++++++++++++++++ codex-rs/exec/src/lib.rs | 5 ++++ .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 +++ codex-rs/protocol/src/protocol.rs | 11 +++++++- codex-rs/tui/src/app/config_persistence.rs | 1 + codex-rs/tui/src/app/tests.rs | 6 +++++ codex-rs/tui/src/chatwidget.rs | 1 + .../chatwidget/tests/composer_submission.rs | 7 +++++ .../tui/src/chatwidget/tests/exec_flow.rs | 1 + .../src/chatwidget/tests/history_replay.rs | 8 ++++++ .../tui/src/chatwidget/tests/permissions.rs | 2 ++ .../tui/src/chatwidget/tests/plan_mode.rs | 2 ++ codex-rs/tui/src/history_cell.rs | 1 + 16 files changed, 85 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d740ee7421..902fc205df 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -94,6 +94,7 @@ use codex_protocol::models::BaseInstructions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::format_allow_prefixes; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::FileChange; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c64b1c30ac..95740cc4d3 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -794,6 +794,14 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); + let permission_profile = if matches!( + session_configuration.file_system_sandbox_policy.kind, + FileSystemSandboxKind::ExternalSandbox + ) { + None + } else { + Some(session_configuration.permission_profile()) + }; let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -806,6 +814,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, sandbox_policy: session_configuration.sandbox_policy.get().clone(), + permission_profile, cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 015b144d0f..9b1ddd6c7b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1470,6 +1470,33 @@ async fn record_initial_history_reconstructs_forked_transcript() { assert_eq!(expected, history.raw_items()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn session_configured_omits_permission_profile_for_external_sandbox() -> anyhow::Result<()> { + let server = start_mock_server().await; + let sandbox_policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Restricted, + }; + let expected_sandbox_policy = sandbox_policy.clone(); + let mut builder = test_codex().with_config(move |config| { + config.permissions.sandbox_policy = codex_config::Constrained::allow_any(sandbox_policy); + config.permissions.file_system_sandbox_policy = FileSystemSandboxPolicy::external_sandbox(); + config.permissions.network_sandbox_policy = NetworkSandboxPolicy::Restricted; + }); + + let test = builder.build(&server).await?; + + assert_eq!( + test.session_configured.sandbox_policy, + expected_sandbox_policy + ); + assert_eq!( + test.session_configured.permission_profile, None, + "ExternalSandbox is enforced outside the PermissionProfile model, so SessionConfigured must \ + not expose a lossy root-write profile" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c8059ad5fe..76665803ca 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -76,6 +76,7 @@ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; use codex_protocol::config_types::SandboxMode; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; @@ -1024,6 +1025,7 @@ fn session_configured_from_thread_start_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -1042,6 +1044,7 @@ fn session_configured_from_thread_resume_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -1070,6 +1073,7 @@ fn session_configured_from_thread_response( approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, sandbox_policy: SandboxPolicy, + permission_profile: Option, cwd: AbsolutePathBuf, reasoning_effort: Option, ) -> Result { @@ -1086,6 +1090,7 @@ fn session_configured_from_thread_response( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, cwd, reasoning_effort, history_log_id: 0, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 02e52988cb..e894b8f4eb 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -115,6 +115,7 @@ fn session_configured_produces_thread_started_event() { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index e94de16587..67ceb91dd8 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -305,6 +305,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -349,6 +350,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -418,6 +420,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c50671df17..fff783d7a7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3672,9 +3672,17 @@ pub struct SessionConfiguredEvent { #[serde(default)] pub approvals_reviewer: ApprovalsReviewer, - /// How to sandbox commands executed in the system + /// Legacy sandbox projection for commands executed in the system. + /// + /// Consumers should prefer `permission_profile` when it is present. This + /// field remains available as a compatibility fallback for older emitters + /// and sessions that only expose legacy sandbox state. pub sandbox_policy: SandboxPolicy, + /// Canonical effective permissions for commands executed in the session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, + /// Working directory that should be treated as the *root* of the /// session. pub cwd: AbsolutePathBuf, @@ -5249,6 +5257,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 20056e8d5d..1ea89d8847 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -638,6 +638,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: next_cwd.clone().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 3cf6ff1a1b..f473804cf2 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3514,6 +3514,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::High), history_log_id: 0, @@ -4122,6 +4123,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4185,6 +4187,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4278,6 +4281,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4661,6 +4665,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4773,6 +4778,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e154880fec..412455cffe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1395,6 +1395,7 @@ fn thread_session_state_to_legacy_event( approval_policy: session.approval_policy, approvals_reviewer: session.approvals_reviewer, sandbox_policy: session.sandbox_policy, + permission_profile: session.permission_profile, cwd: session.cwd, reasoning_effort: session.reasoning_effort, history_log_id: session.history_log_id, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 2f8c6a9b2b..d35c7e9ded 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -17,6 +17,7 @@ async fn submission_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -101,6 +102,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -196,6 +198,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -261,6 +264,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -301,6 +305,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -341,6 +346,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -384,6 +390,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index cb6bea9cc6..206564d06a 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1005,6 +1005,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 084743f98d..3e2f5b4d74 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -17,6 +17,7 @@ async fn resumed_initial_messages_render_history() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -130,6 +131,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -191,6 +193,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -259,6 +262,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), + permission_profile: None, cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -302,6 +306,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -355,6 +360,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -676,6 +682,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -723,6 +730,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 6a3421fe33..51c6a4df19 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -473,6 +473,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -526,6 +527,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 1f91a8e1e7..f19a55d22e 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1061,6 +1061,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -1306,6 +1307,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a09b313e26..33a05776b9 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3073,6 +3073,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, From 18591d6518190fa76779ee005924f6c131baa626 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 21:39:46 -0700 Subject: [PATCH 3/8] app-server: accept command permission profiles --- .../schema/json/ClientRequest.json | 13 +- .../codex_app_server_protocol.schemas.json | 13 +- .../codex_app_server_protocol.v2.schemas.json | 13 +- .../schema/json/v2/CommandExecParams.json | 282 +++++++++++++++++- .../schema/typescript/v2/CommandExecParams.ts | 13 +- .../src/protocol/mappers.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 18 +- codex-rs/app-server/README.md | 12 +- .../app-server/src/codex_message_processor.rs | 123 +++++++- .../app-server/tests/suite/v2/command_exec.rs | 122 ++++++++ 10 files changed, 594 insertions(+), 16 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d941238ca8..8f2324d814 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -218,6 +218,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -234,7 +245,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5e5269bfb2..59f1afc0d3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6432,6 +6432,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -6448,7 +6459,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index aaf0856627..07d3a97557 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3012,6 +3012,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -3028,7 +3039,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 986ec4cc1e..4def45c049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -27,6 +27,217 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "NetworkAccess": { "enum": [ "restricted", @@ -34,6 +245,64 @@ ], "type": "string" }, + "PermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionProfileFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "ReadOnlyAccess": { "oneOf": [ { @@ -247,6 +516,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -263,7 +543,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 097cfdfecc..659974feaf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; /** @@ -92,6 +93,14 @@ size?: CommandExecTerminalSize | null, * Optional sandbox policy for this command. * * Uses the same shape as thread/turn execution sandbox configuration and - * defaults to the user's configured policy when omitted. + * defaults to the user's configured policy when omitted. Cannot be + * combined with `permissionProfile`. */ -sandboxPolicy?: SandboxPolicy | null, }; +sandboxPolicy?: SandboxPolicy | null, +/** + * Optional full permissions profile for this command. + * + * Defaults to the user's configured permissions when omitted. Cannot be + * combined with `sandboxPolicy`. + */ +permissionProfile?: PermissionProfile | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/mappers.rs b/codex-rs/app-server-protocol/src/protocol/mappers.rs index 93f12691be..dae91e650f 100644 --- a/codex-rs/app-server-protocol/src/protocol/mappers.rs +++ b/codex-rs/app-server-protocol/src/protocol/mappers.rs @@ -18,6 +18,7 @@ impl From for v2::CommandExecParams { env: None, size: None, sandbox_policy: value.sandbox_policy.map(std::convert::Into::into), + permission_profile: None, } } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9f1801c64f..45d30f317f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3126,9 +3126,16 @@ pub struct CommandExecParams { /// Optional sandbox policy for this command. /// /// Uses the same shape as thread/turn execution sandbox configuration and - /// defaults to the user's configured policy when omitted. + /// defaults to the user's configured policy when omitted. Cannot be + /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, + /// Optional full permissions profile for this command. + /// + /// Defaults to the user's configured permissions when omitted. Cannot be + /// combined with `sandboxPolicy`. + #[ts(optional = nullable)] + pub permission_profile: Option, } /// Final buffered result for `command/exec`. @@ -8270,6 +8277,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, } ); } @@ -8290,6 +8298,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8304,6 +8313,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, "outputBytesCap": null, }) ); @@ -8329,6 +8339,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8345,6 +8356,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8373,6 +8385,7 @@ mod tests { ])), size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8391,6 +8404,7 @@ mod tests { }, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8460,6 +8474,7 @@ mod tests { cols: 120, }), sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8478,6 +8493,7 @@ mod tests { "cols": 120, }, "sandboxPolicy": null, + "permissionProfile": null, }) ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8a6ce3b728..9c0ad202c8 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -819,7 +819,13 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config + "permissionProfile": { // optional; defaults to user config + "fileSystem": { "entries": [ + { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, + { "path": { "type": "special", "value": { "kind": "current_working_directory" } }, "access": "write" } + ] }, + "network": { "enabled": false } + }, "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout @@ -832,12 +838,12 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin } } ``` -- For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. +- For clients that are already sandboxed externally, set the legacy `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. Notes: - Empty `command` arrays are rejected. -- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`). +- Prefer `permissionProfile` for command permission overrides. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. - `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. - When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 525e75e650..a1fd89a904 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -310,6 +310,8 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationStartParams; @@ -2073,7 +2075,16 @@ impl CodexMessageProcessor { env: env_overrides, size, sandbox_policy, + permission_profile, } = params; + if sandbox_policy.is_some() && permission_profile.is_some() { + self.send_invalid_request_error( + request_id, + "`permissionProfile` cannot be combined with `sandboxPolicy`".to_string(), + ) + .await; + return; + } if size.is_some() && !tty { let error = JSONRPCErrorError { @@ -2205,13 +2216,56 @@ impl CodexMessageProcessor { arg0: None, }; - let requested_policy = sandbox_policy.map(|policy| policy.to_core()); let ( effective_policy, effective_file_system_sandbox_policy, effective_network_sandbox_policy, - ) = match requested_policy { - Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { + ) = if let Some(permission_profile) = permission_profile { + let permission_profile = + codex_protocol::models::PermissionProfile::from(permission_profile); + let sandbox_policy = match permission_profile.to_legacy_sandbox_policy(&sandbox_cwd) { + Ok(sandbox_policy) => sandbox_policy, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid permission profile: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + }; + match self + .config + .permissions + .sandbox_policy + .can_set(&sandbox_policy) + { + Ok(()) => { + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + Self::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &self.config.permissions.file_system_sandbox_policy, + ); + ( + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + } + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid permission profile: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + } + } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + match self.config.permissions.sandbox_policy.can_set(&policy) { Ok(()) => { let file_system_sandbox_policy = codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, &sandbox_cwd); @@ -2228,12 +2282,13 @@ impl CodexMessageProcessor { self.outgoing.send_error(request, error).await; return; } - }, - None => ( + } + } else { + ( self.config.permissions.sandbox_policy.get().clone(), self.config.permissions.file_system_sandbox_policy.clone(), self.config.permissions.network_sandbox_policy, - ), + ) }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); @@ -2290,6 +2345,30 @@ impl CodexMessageProcessor { } } + fn preserve_configured_deny_read_restrictions( + file_system_sandbox_policy: &mut FileSystemSandboxPolicy, + configured_file_system_sandbox_policy: &FileSystemSandboxPolicy, + ) { + if file_system_sandbox_policy.glob_scan_max_depth.is_none() { + file_system_sandbox_policy.glob_scan_max_depth = + configured_file_system_sandbox_policy.glob_scan_max_depth; + } + + for deny_entry in configured_file_system_sandbox_policy + .entries + .iter() + .filter(|entry| entry.access == FileSystemAccessMode::None) + { + if !file_system_sandbox_policy + .entries + .iter() + .any(|entry| entry == deny_entry) + { + file_system_sandbox_policy.entries.push(deny_entry.clone()); + } + } + } + async fn command_exec_write( &self, request_id: ConnectionRequestId, @@ -10085,6 +10164,8 @@ mod tests { use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; @@ -10352,6 +10433,36 @@ mod tests { )); } + #[test] + fn command_profile_preserves_configured_deny_read_restrictions() { + let readable_entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: test_path_buf("/tmp/project").abs(), + }, + access: FileSystemAccessMode::Read, + }; + let deny_entry = FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "/tmp/project/**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }; + let mut file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]); + let mut configured_file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); + configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2); + + CodexMessageProcessor::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &configured_file_system_sandbox_policy, + ); + + let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]); + expected.glob_scan_max_depth = Some(2); + assert_eq!(file_system_sandbox_policy, expected); + } + #[test] fn config_load_error_marks_cloud_requirements_failures_for_relogin() { let err = std::io::Error::other(CloudRequirementsLoadError::new( diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index d7645f13e3..66f879176f 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -13,9 +13,17 @@ use codex_app_server_protocol::CommandExecResponse; use codex_app_server_protocol::CommandExecTerminalSize; use codex_app_server_protocol::CommandExecTerminateParams; use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::PermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::collections::HashMap; use tempfile::TempDir; @@ -57,6 +65,7 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; let terminate_request_id = mcp @@ -109,6 +118,7 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -167,6 +177,7 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un ])), size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -186,6 +197,91 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un Ok(()) } +#[tokio::test] +async fn command_exec_accepts_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'profile'".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "profile".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "`permissionProfile` cannot be combined with `sandboxPolicy`" + ); + + Ok(()) +} + #[tokio::test] async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; @@ -209,6 +305,7 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -246,6 +343,7 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -283,6 +381,7 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -320,6 +419,7 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -361,6 +461,7 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -408,6 +509,7 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -471,6 +573,7 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -546,6 +649,7 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -619,6 +723,7 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { cols: 101, }), sandbox_policy: None, + permission_profile: None, }) .await?; @@ -888,6 +993,23 @@ fn decode_delta_notification( serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") } +fn root_read_only_permission_profile() -> PermissionProfile { + PermissionProfile { + network: Some(PermissionProfileNetworkPermissions { + enabled: Some(false), + }), + file_system: Some(PermissionProfileFileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }], + glob_scan_max_depth: None, + }), + } +} + async fn read_initialize_response( stream: &mut super::connection_handling_websocket::WsClient, request_id: i64, From a167893dc67962a06a553002d97549a8a53b419b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 21:39:46 -0700 Subject: [PATCH 4/8] tui: sync session permission profiles --- codex-rs/tui/src/chatwidget.rs | 9 +++++++ .../src/chatwidget/tests/history_replay.rs | 26 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 412455cffe..a9829f9e69 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -138,6 +138,7 @@ use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::MessagePhase; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; @@ -2104,6 +2105,12 @@ impl ChatWidget { display: SessionConfiguredDisplay, fork_parent_title: Option, ) { + let permission_profile = event.permission_profile.clone().unwrap_or_else(|| { + PermissionProfile::from_legacy_sandbox_policy(&event.sandbox_policy, &event.cwd) + }); + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); self.visible_user_turn_count = 0; @@ -2140,6 +2147,8 @@ impl ChatWidget { self.config.permissions.sandbox_policy = Constrained::allow_only(event.sandbox_policy.clone()); } + self.config.permissions.file_system_sandbox_policy = file_system_sandbox_policy; + self.config.permissions.network_sandbox_policy = network_sandbox_policy; self.config.approvals_reviewer = event.approvals_reviewer; self.status_line_project_root_name_cache = None; let forked_from_id = event.forked_from_id; diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 3e2f5b4d74..583d35c201 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -252,6 +252,26 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { let expected_sandbox = SandboxPolicy::new_read_only_policy(); let expected_cwd = test_path_buf("/home/user/sub-agent").abs(); + let expected_file_system_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::GlobPattern { + pattern: "**/.secret".to_string(), + }, + access: codex_protocol::permissions::FileSystemAccessMode::None, + }, + ]); + let expected_permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions( + &expected_file_system_policy, + codex_protocol::permissions::NetworkSandboxPolicy::Restricted, + ); let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, @@ -262,7 +282,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), - permission_profile: None, + permission_profile: Some(expected_permission_profile.clone()), cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -285,6 +305,10 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.config_ref().permissions.sandbox_policy.get(), &expected_sandbox ); + assert_eq!( + chat.config_ref().permissions.permission_profile(), + expected_permission_profile + ); assert_eq!(&chat.config_ref().cwd, &expected_cwd); } From c2ad4f12bc7588f9a26ae0bb792d6fc0105d2f27 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 21:39:46 -0700 Subject: [PATCH 5/8] tui: carry permission profiles on user turns --- codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/src/session/handlers.rs | 3 +- codex-rs/core/src/session/tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 8 +++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 ++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 ++++ .../core/tests/suite/model_visible_layout.rs | 5 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/pending_input.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 ++++ codex-rs/core/tests/suite/prompt_caching.rs | 5 ++ codex-rs/core/tests/suite/remote_models.rs | 7 ++ .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + .../suite/responses_api_proxy_headers.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 12 ++++ .../tests/suite/safety_check_downgrade.rs | 4 ++ codex-rs/core/tests/suite/shell_snapshot.rs | 4 ++ codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 ++ codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 7 ++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 ++++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/protocol/src/protocol.rs | 7 ++ codex-rs/tui/src/app/thread_routing.rs | 2 + codex-rs/tui/src/app_command.rs | 6 ++ codex-rs/tui/src/app_server_session.rs | 19 ++--- codex-rs/tui/src/chatwidget.rs | 9 +++ .../chatwidget/tests/composer_submission.rs | 69 +++++++++++++++++++ 46 files changed, 246 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 50123ef503..b2aed54a80 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -691,6 +691,7 @@ async fn run_review_on_session( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: params.model.clone(), effort: params.reasoning_effort, summary: Some(params.reasoning_summary), diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 9c91f78188..fd4e8cd6bd 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -135,6 +135,7 @@ pub(super) async fn user_input_or_turn_inner( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort, summary, @@ -162,7 +163,7 @@ pub(super) async fn user_input_or_turn_inner( approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), - permission_profile: None, + permission_profile, windows_sandbox_level: None, collaboration_mode, reasoning_summary: summary, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9b1ddd6c7b..bb5cc90962 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3923,6 +3923,7 @@ async fn user_turn_updates_approvals_reviewer() { approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview), sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: turn_context.model_info.slug.clone(), effort: config.model_reasoning_effort, summary: config.model_reasoning_summary, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index b108be15f8..9a010417fb 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -663,6 +663,7 @@ impl TestCodex { approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a789940b0e..588dd98d50 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -367,6 +367,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1005,6 +1006,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, @@ -1103,6 +1105,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1188,6 +1191,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1344,6 +1348,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1412,6 +1417,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1489,6 +1495,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1566,6 +1573,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 1a29d201b4..145579d194 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -595,6 +595,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 95fba68a02..cde1794a1c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1660,6 +1660,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: session_configured.model.clone(), effort: Some(ReasoningEffort::Low), summary: Some( @@ -1781,6 +1782,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: session_configured.model, effort: None, summary: Some(ReasoningSummary::Concise), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index d52a1454e0..413fd90bbb 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2619,6 +2619,7 @@ text( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 3cc8bd8298..26d8d6aacc 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -186,6 +186,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: Some( @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: Some( diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index da4c09345c..60c962a4db 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1687,6 +1687,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: resumed.session_configured.model.clone(), effort: None, summary: None, @@ -1779,6 +1780,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: previous_model.to_string(), effort: None, summary: None, @@ -1805,6 +1807,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: next_model.to_string(), effort: None, summary: None, @@ -1917,6 +1920,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: previous_model.to_string(), effort: None, summary: None, @@ -1967,6 +1971,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: next_model.to_string(), effort: None, summary: None, @@ -3204,6 +3209,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: previous_model.to_string(), effort: None, summary: None, @@ -3230,6 +3236,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: next_model.to_string(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 8b2654a720..4fae6aafdf 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -54,6 +54,7 @@ async fn submit_user_turn( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -136,6 +137,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 0e2d88237d..eb9751720a 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -126,6 +126,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 6cf42b5eed..db4f7d7584 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -540,6 +540,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -618,6 +619,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -728,6 +730,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -916,6 +919,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -1031,6 +1035,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index d6728deb0c..01783a365d 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -83,6 +83,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index cfafdea3f7..25206a6fd3 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -64,6 +64,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index db760d8f89..b3e9215635 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -131,6 +131,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -170,6 +171,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -270,6 +273,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -413,6 +417,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -435,6 +440,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -544,6 +550,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -566,6 +573,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -678,6 +686,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -700,6 +709,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -814,6 +824,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -844,6 +855,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -1002,6 +1014,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: large_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -1063,6 +1076,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: smaller_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 6cbbb7389c..2f06d5af21 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -124,6 +124,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -149,6 +150,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { approval_policy: AskForApproval::OnRequest, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -229,6 +231,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -254,6 +257,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -367,6 +371,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: resumed.session_configured.model.clone(), effort: resumed.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 5728205b6c..8eb8fca8ac 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -100,6 +100,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index d46214d1f2..0551dcf8e1 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -111,6 +111,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 10907f0263..25048d5c81 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -120,6 +120,7 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 9232dc71cd..0a1c76295a 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -106,6 +106,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -158,6 +159,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -217,6 +219,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -282,6 +285,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -335,6 +339,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -375,6 +380,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -443,6 +449,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -483,6 +490,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -564,6 +572,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -604,6 +613,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -724,6 +734,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -846,6 +857,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -886,6 +898,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 834bab73fb..ca0b3e281a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -728,6 +728,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: new_policy.clone(), + permission_profile: None, model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: Some(ReasoningSummary::Detailed), @@ -842,6 +843,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), + permission_profile: None, model: default_model.clone(), effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -864,6 +866,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), + permission_profile: None, model: default_model.clone(), effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -970,6 +973,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), + permission_profile: None, model: default_model, effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -992,6 +996,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: Some(ReasoningSummary::Detailed), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index f2b9d16ba2..790d9ca4f7 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -171,6 +171,7 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind summary: None, service_tier: None, collaboration_mode: None, + permission_profile: None, personality: None, environments: None, }) @@ -248,6 +249,7 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re summary: None, service_tier: None, collaboration_mode: None, + permission_profile: None, personality: None, environments: None, }) @@ -324,6 +326,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re summary: None, service_tier: None, collaboration_mode: None, + permission_profile: None, personality: None, environments: None, }) @@ -408,6 +411,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: requested_model.to_string(), effort: None, summary: None, @@ -468,6 +472,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: requested_model.to_string(), effort: None, summary: Some( @@ -636,6 +641,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REMOTE_MODEL_SLUG.to_string(), effort: None, summary: Some(ReasoningSummary::Auto), @@ -863,6 +869,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: model.to_string(), effort: None, summary: Some(ReasoningSummary::Auto), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 0b9dbf5844..bcc938734d 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -197,6 +197,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 567af82944..4df6602cdc 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -150,6 +150,7 @@ async fn submit_turn( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 4c59c8e7f3..7814f4936c 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -141,6 +141,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -260,6 +261,7 @@ where approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index de35b12a2b..5df7e516a8 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -143,6 +143,7 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 1243849fba..cabeb91ae2 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -290,6 +290,7 @@ async fn call_cwd_tool( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -424,6 +425,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -856,6 +858,7 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -989,6 +992,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1089,6 +1093,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1241,6 +1246,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1479,6 +1485,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: text_only_model_slug.to_string(), effort: None, summary: None, @@ -1588,6 +1595,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1725,6 +1733,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1835,6 +1844,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1960,6 +1970,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2177,6 +2188,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index f3211e00d6..ce16922201 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -48,6 +48,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -148,6 +149,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -235,6 +237,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -286,6 +289,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index b3a53fde11..1204f2b078 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -167,6 +167,7 @@ async fn run_snapshot_command_with_options( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -259,6 +260,7 @@ async fn run_shell_command_snapshot_with_options( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -331,6 +333,7 @@ async fn run_tool_turn_on_harness( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -567,6 +570,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index de4827d6be..44c2fb7b68 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -54,6 +54,7 @@ async fn submit_turn_with_policies( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 015f4ef0f2..1edaefb783 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -115,6 +115,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 6a3f9b7927..8ad9ede5b9 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -409,6 +409,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index ca86d3e9fe..db93c0b66e 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -88,6 +88,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -160,6 +161,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -242,6 +244,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -339,6 +342,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -444,6 +448,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 3158804fad..a0ee7b1140 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -45,6 +45,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -363,6 +364,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 861ec1eed5..5e522c0aa0 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -510,6 +510,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 58dbad7b1d..d531710773 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -176,6 +176,7 @@ async fn submit_unified_exec_turn( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -261,6 +262,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1752,6 +1754,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1846,6 +1849,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2320,6 +2324,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { approvals_reviewer: None, // Important! sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2429,6 +2434,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: read_only_policy, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2558,6 +2564,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 0aa52b7de8..01521dab01 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -180,6 +180,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: fixture.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 4567403449..695acd0dd7 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -169,6 +169,7 @@ async fn assert_user_turn_local_image_resizes_to( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -290,6 +291,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -422,6 +424,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -522,6 +525,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -613,6 +617,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -714,6 +719,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -819,6 +825,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -922,6 +929,7 @@ await codex.emitImage(out); approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1043,6 +1051,7 @@ console.log(out.type); approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -1137,6 +1146,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1219,6 +1229,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1306,6 +1317,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1444,6 +1456,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: model_slug.to_string(), effort: None, summary: None, @@ -1525,6 +1538,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 6611ebdf5d..dbe2e3c6d7 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -160,6 +160,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index fff783d7a7..68ac2879e0 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -537,6 +537,13 @@ pub enum Op { /// Policy to use for tool calls such as `local_shell`. sandbox_policy: SandboxPolicy, + /// Full permissions profile to use for tool calls such as `local_shell`. + /// + /// When omitted, `sandbox_policy` is used as a legacy compatibility + /// projection. + #[serde(default, skip_serializing_if = "Option::is_none")] + permission_profile: Option, + /// Must be a valid model slug for the configured client session /// associated with this conversation. model: String, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 241695cc27..bf1f95555a 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -497,6 +497,7 @@ impl App { approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort, summary, @@ -580,6 +581,7 @@ impl App { approvals_reviewer .unwrap_or(self.chat_widget.config_ref().approvals_reviewer), sandbox_policy.clone(), + permission_profile.clone(), model.to_string(), effort, *summary, diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 9bd024679a..1febbfa1e7 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -8,6 +8,7 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::mcp::RequestId as McpRequestId; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ConversationAudioParams; @@ -44,6 +45,7 @@ pub(crate) enum AppCommandView<'a> { approval_policy: AskForApproval, approvals_reviewer: &'a Option, sandbox_policy: &'a SandboxPolicy, + permission_profile: &'a Option, model: &'a str, effort: Option, summary: &'a Option, @@ -140,6 +142,7 @@ impl AppCommand { cwd: PathBuf, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, + permission_profile: Option, model: String, effort: Option, summary: Option, @@ -155,6 +158,7 @@ impl AppCommand { approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile, model, effort, summary, @@ -291,6 +295,7 @@ impl AppCommand { approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort, summary, @@ -305,6 +310,7 @@ impl AppCommand { approval_policy: *approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort: *effort, summary, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index b1f1e01c9a..7c488499ef 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -526,6 +526,7 @@ impl AppServerSession { approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, sandbox_policy: SandboxPolicy, + permission_profile: Option, model: String, effort: Option, summary: Option, @@ -535,12 +536,15 @@ impl AppServerSession { output_schema: Option, ) -> Result { let request_id = self.next_request_id(); - let sandbox_policy = if self.is_remote() - || matches!(sandbox_policy, SandboxPolicy::ExternalSandbox { .. }) - { - Some(sandbox_policy.into()) + let is_external_sandbox = matches!(&sandbox_policy, SandboxPolicy::ExternalSandbox { .. }); + let should_send_permission_profile = + !self.is_remote() && !is_external_sandbox && permission_profile.is_some(); + let (sandbox_policy, permission_profile) = if should_send_permission_profile { + (None, permission_profile.map(Into::into)) + } else if self.is_remote() || is_external_sandbox { + (Some(sandbox_policy.into()), None) } else { - None + (None, None) }; self.client .request_typed(ClientRequest::TurnStart { @@ -553,11 +557,8 @@ impl AppServerSession { cwd: Some(cwd), approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()), - // Embedded sessions already installed their full profile - // at thread start/resume/fork. Avoid sending a lossy - // legacy projection until user turns carry profiles. sandbox_policy, - permission_profile: None, + permission_profile, model: Some(model), service_tier, effort, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a9829f9e69..0c7c327c82 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5952,11 +5952,20 @@ impl ChatWidget { .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); let service_tier = Some(self.config.service_tier); + let permission_profile = if matches!( + self.config.permissions.sandbox_policy.get(), + SandboxPolicy::ExternalSandbox { .. } + ) { + None + } else { + Some(self.config.permissions.permission_profile()) + }; let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), self.config.permissions.approval_policy.value(), self.config.permissions.sandbox_policy.get().clone(), + permission_profile, effective_mode.model().to_string(), effective_mode.reasoning_effort(), /*summary*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index d35c7e9ded..a23f137b40 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -86,6 +86,75 @@ async fn submission_preserves_text_elements_and_local_images() { assert!(stored_remote_image_urls.is_empty()); } +#[tokio::test] +async fn submission_includes_configured_permission_profile() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let expected_permission_profile = PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(false), + }), + file_system: Some(FileSystemPermissions { + entries: vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::GlobPattern { + pattern: "/home/user/project/secrets/**".to_string(), + }, + access: codex_protocol::permissions::FileSystemAccessMode::None, + }, + ], + glob_scan_max_depth: None, + }), + }; + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: Some(expected_permission_profile.clone()), + cwd: test_path_buf("/home/user/project").abs(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + chat.bottom_pane.set_composer_text( + "submit with configured permissions".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let permission_profile = match next_submit_op(&mut op_rx) { + Op::UserTurn { + permission_profile, .. + } => permission_profile, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(permission_profile, Some(expected_permission_profile)); +} + #[tokio::test] async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 247dd38205a2d5c5451ed59f09b2a9f6a56ab47b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 21:39:46 -0700 Subject: [PATCH 6/8] mcp: include permission profiles in sandbox state --- codex-rs/codex-mcp/src/mcp_connection_manager.rs | 3 +++ codex-rs/core/src/mcp_tool_call.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/codex-rs/codex-mcp/src/mcp_connection_manager.rs b/codex-rs/codex-mcp/src/mcp_connection_manager.rs index 18c15cff61..1e1e0fd3f6 100644 --- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager.rs @@ -42,6 +42,7 @@ use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::RequestId as ProtocolRequestId; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -646,6 +647,8 @@ pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SandboxState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, pub sandbox_policy: SandboxPolicy, pub codex_linux_sandbox_exe: Option, pub sandbox_cwd: PathBuf, diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 82bbc7ca4a..f94a9eecc8 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -519,6 +519,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( } let sandbox_state = serde_json::to_value(SandboxState { + permission_profile: Some(turn_context.permission_profile()), sandbox_policy: turn_context.sandbox_policy.get().clone(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), sandbox_cwd: turn_context.cwd.to_path_buf(), From 1809c700ca22702b73eccd1f247e81271a832db4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 21:39:46 -0700 Subject: [PATCH 7/8] shell-escalation: carry resolved permission profiles --- .../tools/runtimes/shell/unix_escalation.rs | 38 +++++++++++-------- .../runtimes/shell/unix_escalation_tests.rs | 12 +++--- codex-rs/protocol/src/approvals.rs | 17 +++++---- codex-rs/shell-escalation/src/lib.rs | 4 +- .../src/unix/escalate_server.rs | 16 ++++---- codex-rs/shell-escalation/src/unix/mod.rs | 2 +- 6 files changed, 52 insertions(+), 37 deletions(-) diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 361de9a96b..f4a6ac3cf7 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -54,8 +54,8 @@ use codex_shell_escalation::EscalationPolicy; use codex_shell_escalation::EscalationSession; use codex_shell_escalation::ExecParams; use codex_shell_escalation::ExecResult; -use codex_shell_escalation::Permissions as EscalatedPermissions; use codex_shell_escalation::PreparedExec; +use codex_shell_escalation::ResolvedPermissionProfile; use codex_shell_escalation::ShellCommandExecutor; use codex_shell_escalation::Stopwatch; use codex_utils_absolute_path::AbsolutePathBuf; @@ -371,13 +371,17 @@ impl CoreShellActionProvider { .map(|_| { // Shell request additional permissions were already normalized and // merged into the first-attempt sandbox policy. - EscalationExecution::Permissions(EscalationPermissions::Permissions( - EscalatedPermissions { - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: file_system_sandbox_policy.clone(), - network_sandbox_policy, - }, - )) + EscalationExecution::Permissions( + EscalationPermissions::ResolvedPermissionProfile( + ResolvedPermissionProfile { + permission_profile: PermissionProfile::from_runtime_permissions( + file_system_sandbox_policy, + network_sandbox_policy, + ), + sandbox_policy: sandbox_policy.clone(), + }, + ), + ) }) .unwrap_or(EscalationExecution::TurnDefault), } @@ -844,9 +848,9 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { additional_permissions: None, })? } - EscalationExecution::Permissions(EscalationPermissions::PermissionProfile( - permission_profile, - )) => { + EscalationExecution::Permissions( + EscalationPermissions::AdditionalPermissionProfile(permission_profile), + ) => { // Merge additive permissions into the existing turn/request sandbox policy. self.prepare_sandboxed_exec(PrepareSandboxedExecParams { command, @@ -858,15 +862,19 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { additional_permissions: Some(permission_profile), })? } - EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => { - // Use a fully specified sandbox policy instead of merging into the turn policy. + EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( + permissions, + )) => { + // Use a fully specified permission profile instead of merging into the turn policy. + let (file_system_sandbox_policy, network_sandbox_policy) = + permissions.permission_profile.to_runtime_permissions(); self.prepare_sandboxed_exec(PrepareSandboxedExecParams { command, workdir, env, sandbox_policy: &permissions.sandbox_policy, - file_system_sandbox_policy: &permissions.file_system_sandbox_policy, - network_sandbox_policy: permissions.network_sandbox_policy, + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy, additional_permissions: None, })? } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index deaaf067ea..a2b6ca145e 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -33,7 +33,7 @@ use codex_sandboxing::SandboxType; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; -use codex_shell_escalation::Permissions as EscalatedPermissions; +use codex_shell_escalation::ResolvedPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::Value; @@ -317,11 +317,13 @@ fn shell_request_escalation_execution_is_explicit() { network_sandbox_policy, Some(&requested_permissions), ), - EscalationExecution::Permissions(EscalationPermissions::Permissions( - EscalatedPermissions { + EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( + ResolvedPermissionProfile { + permission_profile: PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + ), sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, }, )), ); diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 8799db4a6e..2b9aea2721 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -1,8 +1,6 @@ use crate::mcp::RequestId; use crate::models::PermissionProfile; use crate::parse_command::ParsedCommand; -use crate::permissions::FileSystemSandboxPolicy; -use crate::permissions::NetworkSandboxPolicy; use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; @@ -16,18 +14,23 @@ use std::collections::HashMap; use std::path::PathBuf; use ts_rs::TS; +/// Fully resolved permissions for rerunning an intercepted child process. +/// +/// `permission_profile` is the canonical permission model. `sandbox_policy` +/// remains as the legacy adapter for sandbox backends that still require it. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Permissions { +pub struct ResolvedPermissionProfile { + pub permission_profile: PermissionProfile, pub sandbox_policy: SandboxPolicy, - pub file_system_sandbox_policy: FileSystemSandboxPolicy, - pub network_sandbox_policy: NetworkSandboxPolicy, } #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, Eq)] pub enum EscalationPermissions { - PermissionProfile(PermissionProfile), - Permissions(Permissions), + /// Permissions to merge with the active turn permissions. + AdditionalPermissionProfile(PermissionProfile), + /// Fully resolved permissions that should replace the active turn permissions. + ResolvedPermissionProfile(ResolvedPermissionProfile), } /// Proposed execpolicy change to allow commands starting with this prefix. diff --git a/codex-rs/shell-escalation/src/lib.rs b/codex-rs/shell-escalation/src/lib.rs index 6c7e1ec605..0f3c0afd30 100644 --- a/codex-rs/shell-escalation/src/lib.rs +++ b/codex-rs/shell-escalation/src/lib.rs @@ -22,10 +22,10 @@ pub use unix::ExecParams; #[cfg(unix)] pub use unix::ExecResult; #[cfg(unix)] -pub use unix::Permissions; -#[cfg(unix)] pub use unix::PreparedExec; #[cfg(unix)] +pub use unix::ResolvedPermissionProfile; +#[cfg(unix)] pub use unix::ShellCommandExecutor; #[cfg(unix)] pub use unix::Stopwatch; diff --git a/codex-rs/shell-escalation/src/unix/escalate_server.rs b/codex-rs/shell-escalation/src/unix/escalate_server.rs index 42e31f6a2a..ce73f262f4 100644 --- a/codex-rs/shell-escalation/src/unix/escalate_server.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_server.rs @@ -922,7 +922,7 @@ mod tests { server, Arc::new(DeterministicEscalationPolicy { decision: EscalationDecision::escalate(EscalationExecution::Permissions( - EscalationPermissions::PermissionProfile(PermissionProfile { + EscalationPermissions::AdditionalPermissionProfile(PermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), @@ -931,12 +931,14 @@ mod tests { )), }), Arc::new(PermissionAssertingShellCommandExecutor { - expected_permissions: EscalationPermissions::PermissionProfile(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - ..Default::default() - }), + expected_permissions: EscalationPermissions::AdditionalPermissionProfile( + PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + ), }), CancellationToken::new(), CancellationToken::new(), diff --git a/codex-rs/shell-escalation/src/unix/mod.rs b/codex-rs/shell-escalation/src/unix/mod.rs index b5d999411c..8fb8688443 100644 --- a/codex-rs/shell-escalation/src/unix/mod.rs +++ b/codex-rs/shell-escalation/src/unix/mod.rs @@ -76,4 +76,4 @@ pub use self::escalation_policy::EscalationPolicy; pub use self::execve_wrapper::main_execve_wrapper; pub use self::stopwatch::Stopwatch; pub use codex_protocol::approvals::EscalationPermissions; -pub use codex_protocol::approvals::Permissions; +pub use codex_protocol::approvals::ResolvedPermissionProfile; From ea0e559397595a01d0143d790929396364210872 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 21:39:46 -0700 Subject: [PATCH 8/8] tests: isolate approval fixtures from host rules --- codex-rs/core/tests/suite/approvals.rs | 2 +- codex-rs/core/tests/suite/otel.rs | 36 +++++++++++++++---- .../core/tests/suite/permissions_messages.rs | 2 ++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 145579d194..f915ec45a3 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -593,7 +593,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, - approvals_reviewer: None, + approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, permission_profile: None, model: session_model, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 0ad23ccf53..6407ec2702 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1146,7 +1146,11 @@ async fn handle_container_exec_user_approved_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("user_approved_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "user_approved_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1215,7 +1219,11 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_local_shell_call("user_approved_session_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "user_approved_session_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1283,7 +1291,11 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("sandbox_retry_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "sandbox_retry_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1351,7 +1363,11 @@ async fn handle_container_exec_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("user_denied_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "user_denied_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1419,7 +1435,11 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_local_shell_call("sandbox_session_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "sandbox_session_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1487,7 +1507,11 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "sandbox_deny_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 838a20f00a..1bf2ee121c 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_core::ForkSnapshot; use codex_core::config::Constrained; +use codex_core::config_loader::ConfigLayerStack; use codex_core::context::ContextualUserFragment; use codex_core::context::PermissionsInstructions; use codex_core::load_exec_policy; @@ -551,6 +552,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.config_layer_stack = ConfigLayerStack::default(); }); let test = builder.build(&server).await?;