diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index af0a0c776..c81f69d10 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -5,6 +5,23 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AdditionalPermissions": { + "properties": { + "fs_read": { + "items": { + "type": "string" + }, + "type": "array" + }, + "fs_write": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "AgentMessageContent": { "oneOf": [ { @@ -1613,6 +1630,17 @@ }, { "properties": { + "additional_permissions": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalPermissions" + }, + { + "type": "null" + } + ], + "description": "Optional additional filesystem permissions requested for this command." + }, "approval_id": { "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", "type": [ @@ -6914,6 +6942,17 @@ }, { "properties": { + "additional_permissions": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalPermissions" + }, + { + "type": "null" + } + ], + "description": "Optional additional filesystem permissions requested for this command." + }, "approval_id": { "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", "type": [ 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 02df08274..859970ef6 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 @@ -1,6 +1,23 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AdditionalPermissions": { + "properties": { + "fs_read": { + "items": { + "type": "string" + }, + "type": "array" + }, + "fs_write": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "AgentMessageContent": { "oneOf": [ { @@ -2674,6 +2691,17 @@ }, { "properties": { + "additional_permissions": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalPermissions" + }, + { + "type": "null" + } + ], + "description": "Optional additional filesystem permissions requested for this command." + }, "approval_id": { "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", "type": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/AdditionalPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/AdditionalPermissions.ts new file mode 100644 index 000000000..363807a2a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/AdditionalPermissions.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 AdditionalPermissions = { fs_read?: Array, fs_write?: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts index 71b285760..c777831e8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts @@ -1,6 +1,7 @@ // 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 { AdditionalPermissions } from "./AdditionalPermissions"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; @@ -46,4 +47,8 @@ proposed_execpolicy_amendment?: ExecPolicyAmendment, /** * Proposed network policy amendments (for example allow/deny this host in future). */ -proposed_network_policy_amendments?: Array, parsed_cmd: Array, }; +proposed_network_policy_amendments?: Array, +/** + * Optional additional filesystem permissions requested for this command. + */ +additional_permissions?: AdditionalPermissions, parsed_cmd: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index e5c856695..3a1281cf2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -3,6 +3,7 @@ export type { AbsolutePathBuf } from "./AbsolutePathBuf"; export type { AddConversationListenerParams } from "./AddConversationListenerParams"; export type { AddConversationSubscriptionResponse } from "./AddConversationSubscriptionResponse"; +export type { AdditionalPermissions } from "./AdditionalPermissions"; export type { AgentMessageContent } from "./AgentMessageContent"; export type { AgentMessageContentDeltaEvent } from "./AgentMessageContentDeltaEvent"; export type { AgentMessageDeltaEvent } from "./AgentMessageDeltaEvent"; diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index 25dad0e0a..0bf6e98cf 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -478,6 +478,7 @@ fn assert_permissions_message(item: &ResponseItem) { AskForApproval::Never, &Policy::empty(), &PathBuf::from("/tmp"), + false, ) .into_text(); assert_eq!( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index de3353e30..e3d93cf96 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -370,6 +370,9 @@ "remote_models": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "request_rule": { "type": "boolean" }, @@ -1657,6 +1660,9 @@ "remote_models": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "request_rule": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 90ed386b1..ef8ea368d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -70,6 +70,7 @@ use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::AdditionalPermissions; use codex_protocol::models::BaseInstructions; use codex_protocol::models::format_allow_prefixes; use codex_protocol::openai_models::ModelInfo; @@ -2573,6 +2574,7 @@ impl Session { reason: Option, network_approval_context: Option, proposed_execpolicy_amendment: Option, + additional_permissions: Option, ) -> ReviewDecision { // command-level approvals use `call_id`. // `approval_id` is only present for subcommand callbacks (execve intercept) @@ -2616,6 +2618,7 @@ impl Session { network_approval_context, proposed_execpolicy_amendment, proposed_network_policy_amendments, + additional_permissions, parsed_cmd, }); self.send_event(turn_context, event).await; @@ -2999,6 +3002,7 @@ impl Session { turn_context.approval_policy.value(), self.services.exec_policy.current().as_ref(), &turn_context.cwd, + turn_context.features.enabled(Feature::RequestPermissions), ) .into(), ); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index fb0f5ad93..9662830c8 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -320,6 +320,7 @@ async fn handle_exec_approval( reason, network_approval_context, proposed_execpolicy_amendment, + additional_permissions, .. } = event; // Race approval with cancellation and timeout to avoid hangs. @@ -332,6 +333,7 @@ async fn handle_exec_approval( reason, network_approval_context, proposed_execpolicy_amendment, + additional_permissions, ); let decision = await_approval_with_cancel( approval_fut, diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 87a31031b..54c5667d4 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,5 +1,6 @@ use crate::codex::TurnContext; use crate::environment_context::EnvironmentContext; +use crate::features::Feature; use crate::shell::Shell; use codex_execpolicy::Policy; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ fn build_permissions_update_item( next.approval_policy.value(), exec_policy, &next.cwd, + next.features.enabled(Feature::RequestPermissions), ) .into(), ) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 1e0e59d4e..288ee7f21 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -206,6 +206,7 @@ pub async fn process_exec_tool_call( env, expiration, sandbox_permissions, + additional_permissions: None, justification, }; @@ -225,7 +226,7 @@ pub async fn process_exec_tool_call( .map_err(CodexErr::from)?; // Route through the sandboxing module for a single, unified execution path. - crate::sandboxing::execute_env(exec_req, sandbox_policy, stdout_stream).await + crate::sandboxing::execute_env(exec_req, stdout_stream).await } pub(crate) async fn execute_exec_env( @@ -242,6 +243,7 @@ pub(crate) async fn execute_exec_env( sandbox, windows_sandbox_level, sandbox_permissions, + sandbox_policy: _sandbox_policy_from_env, justification, arg0, } = env; @@ -517,6 +519,9 @@ pub(crate) mod errors { SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation( "seatbelt sandbox is only available on macOS".to_string(), ), + SandboxTransformError::InvalidAdditionalPermissionsPath(path) => { + CodexErr::InvalidRequest(format!("invalid additional_permissions path: {path}")) + } } } } diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index d89961aec..e390996c4 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -539,7 +539,7 @@ pub fn render_decision_for_unmatched_command( // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for // non‑escalated, non‑dangerous commands — let the sandbox enforce // restrictions (e.g., block network/write) without a user prompt. - if sandbox_permissions.requires_escalated_permissions() { + if sandbox_permissions.requires_additional_permissions() { Decision::Prompt } else { Decision::Allow @@ -554,7 +554,7 @@ pub fn render_decision_for_unmatched_command( Decision::Allow } SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => { - if sandbox_permissions.requires_escalated_permissions() { + if sandbox_permissions.requires_additional_permissions() { Decision::Prompt } else { Decision::Allow diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 521eb7146..f5d87f7a9 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -88,6 +88,8 @@ pub enum Feature { ShellZshFork, /// Include the freeform apply_patch tool. ApplyPatchFreeform, + /// Allow requesting additional filesystem permissions while staying sandboxed. + RequestPermissions, /// Allow the model to request web searches that fetch live content. WebSearchRequest, /// Allow the model to request web searches that fetch cached content. @@ -520,6 +522,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RequestPermissions, + key: "request_permissions", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::UseLinuxSandboxBwrap, key: "use_linux_sandbox_bwrap", diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index f9c3c171e..061b709b6 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -24,8 +24,12 @@ use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use crate::tools::sandboxing::SandboxablePreference; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::AdditionalPermissions; pub use codex_protocol::models::SandboxPermissions; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; +use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; @@ -37,6 +41,7 @@ pub struct CommandSpec { pub env: HashMap, pub expiration: ExecExpiration, pub sandbox_permissions: SandboxPermissions, + pub additional_permissions: Option, pub justification: Option, } @@ -50,6 +55,7 @@ pub struct ExecRequest { pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, pub sandbox_permissions: SandboxPermissions, + pub sandbox_policy: SandboxPolicy, pub justification: Option, pub arg0: Option, } @@ -81,11 +87,183 @@ pub enum SandboxPreference { pub(crate) enum SandboxTransformError { #[error("missing codex-linux-sandbox executable path")] MissingLinuxSandboxExecutable, + #[error("invalid additional permissions path: {0}")] + InvalidAdditionalPermissionsPath(String), #[cfg(not(target_os = "macos"))] #[error("seatbelt sandbox is only available on macOS")] SeatbeltUnavailable, } +pub(crate) fn normalize_additional_permissions( + additional_permissions: AdditionalPermissions, + command_cwd: &Path, +) -> Result { + let fs_read = + normalize_permission_paths(additional_permissions.fs_read, command_cwd, "fs_read")?; + let fs_write = + normalize_permission_paths(additional_permissions.fs_write, command_cwd, "fs_write")?; + Ok(AdditionalPermissions { fs_read, fs_write }) +} + +fn normalize_permission_paths( + paths: Vec, + command_cwd: &Path, + permission_kind: &str, +) -> Result, String> { + let mut out = Vec::with_capacity(paths.len()); + let mut seen = HashSet::new(); + + for path in paths { + if path.as_os_str().is_empty() { + return Err(format!("{permission_kind} contains an empty path")); + } + + let resolved = if path.is_absolute() { + AbsolutePathBuf::from_absolute_path(path.clone()).map_err(|err| { + format!( + "{permission_kind} path `{}` is invalid: {err}", + path.display() + ) + })? + } else { + AbsolutePathBuf::resolve_path_against_base(&path, command_cwd).map_err(|err| { + format!( + "{permission_kind} path `{}` cannot be resolved against cwd `{}`: {err}", + path.display(), + command_cwd.display() + ) + })? + }; + + let canonicalized = resolved + .as_path() + .canonicalize() + .ok() + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .unwrap_or(resolved); + let canonicalized = canonicalized.to_path_buf(); + if seen.insert(canonicalized.clone()) { + out.push(canonicalized); + } + } + + Ok(out) +} + +fn dedup_absolute_paths(paths: Vec) -> Vec { + let mut out = Vec::with_capacity(paths.len()); + let mut seen = HashSet::new(); + for path in paths { + if seen.insert(path.to_path_buf()) { + out.push(path); + } + } + out +} + +fn additional_permission_roots( + additional_permissions: &AdditionalPermissions, +) -> Result<(Vec, Vec), SandboxTransformError> { + let to_abs = |paths: &[PathBuf]| { + let mut out = Vec::with_capacity(paths.len()); + for path in paths { + let absolute = AbsolutePathBuf::from_absolute_path(path.clone()).map_err(|err| { + SandboxTransformError::InvalidAdditionalPermissionsPath(format!( + "`{}`: {err}", + path.display() + )) + })?; + out.push(absolute); + } + Ok(dedup_absolute_paths(out)) + }; + + Ok(( + to_abs(&additional_permissions.fs_read)?, + to_abs(&additional_permissions.fs_write)?, + )) +} + +fn merge_read_only_access_with_additional_reads( + read_only_access: &ReadOnlyAccess, + extra_reads: Vec, +) -> ReadOnlyAccess { + match read_only_access { + ReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess, + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => { + let mut merged = readable_roots.clone(); + merged.extend(extra_reads); + ReadOnlyAccess::Restricted { + include_platform_defaults: *include_platform_defaults, + readable_roots: dedup_absolute_paths(merged), + } + } + } +} + +fn sandbox_policy_with_additional_permissions( + sandbox_policy: &SandboxPolicy, + additional_permissions: &AdditionalPermissions, +) -> Result { + if additional_permissions.is_empty() { + return Ok(sandbox_policy.clone()); + } + + let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions)?; + + let policy = match sandbox_policy { + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + sandbox_policy.clone() + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + read_only_access, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => { + let mut merged_writes = writable_roots.clone(); + merged_writes.extend(extra_writes); + SandboxPolicy::WorkspaceWrite { + writable_roots: dedup_absolute_paths(merged_writes), + read_only_access: merge_read_only_access_with_additional_reads( + read_only_access, + extra_reads, + ), + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + } + } + SandboxPolicy::ReadOnly { access } => { + if extra_writes.is_empty() { + SandboxPolicy::ReadOnly { + access: merge_read_only_access_with_additional_reads(access, extra_reads), + } + } else { + // todo(dylan) - for now, this grants more access than the request. We should restrict this, + // but we should add a new SandboxPolicy variant to handle this. While the feature is still + // UnderDevelopment, it's a useful approximation of the desired behavior. + SandboxPolicy::WorkspaceWrite { + writable_roots: dedup_absolute_paths(extra_writes), + read_only_access: merge_read_only_access_with_additional_reads( + access, + extra_reads, + ), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + } + } + }; + + Ok(policy) +} + #[derive(Default)] pub struct SandboxManager; @@ -145,8 +323,14 @@ impl SandboxManager { use_linux_sandbox_bwrap, windows_sandbox_level, } = request; + let effective_policy = + if let Some(additional_permissions) = spec.additional_permissions.take() { + sandbox_policy_with_additional_permissions(policy, &additional_permissions)? + } else { + policy.clone() + }; let mut env = spec.env; - if !policy.has_full_network_access() { + if !effective_policy.has_full_network_access() { env.insert( CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(), "1".to_string(), @@ -171,7 +355,7 @@ impl SandboxManager { .map_or_else(Vec::new, |path| vec![path.clone()]); let mut args = create_seatbelt_command_args( command.clone(), - policy, + &effective_policy, sandbox_policy_cwd, enforce_managed_network, network, @@ -190,7 +374,7 @@ impl SandboxManager { let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); let mut args = create_linux_sandbox_command_args( command.clone(), - policy, + &effective_policy, sandbox_policy_cwd, use_linux_sandbox_bwrap, allow_proxy_network, @@ -225,6 +409,7 @@ impl SandboxManager { sandbox, windows_sandbox_level, sandbox_permissions: spec.sandbox_permissions, + sandbox_policy: effective_policy, justification: spec.justification, arg0: arg0_override, }) @@ -237,10 +422,10 @@ impl SandboxManager { pub async fn execute_env( env: ExecRequest, - policy: &SandboxPolicy, stdout_stream: Option, ) -> crate::error::Result { - execute_exec_env(env, policy, stdout_stream).await + let effective_policy = env.sandbox_policy.clone(); + execute_exec_env(env, &effective_policy, stdout_stream).await } #[cfg(test)] diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 3e378fcd7..18dc5006e 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -147,6 +147,7 @@ pub(crate) async fn execute_user_shell_command( ) .await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; let exec_env = ExecRequest { command: exec_command.clone(), cwd: cwd.clone(), @@ -161,6 +162,7 @@ pub(crate) async fn execute_user_shell_command( sandbox: SandboxType::None, windows_sandbox_level: turn_context.windows_sandbox_level, sandbox_permissions: SandboxPermissions::UseDefault, + sandbox_policy: sandbox_policy.clone(), justification: None, arg0: None, }; @@ -171,7 +173,6 @@ pub(crate) async fn execute_user_shell_command( tx_event: session.get_tx_event(), }); - let sandbox_policy = SandboxPolicy::DangerFullAccess; let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream) .or_cancel(&cancellation_token) .await; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index e5229aa1d..ba52a2d01 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -17,9 +17,14 @@ mod view_image; pub use plan::PLAN_TOOL; use serde::Deserialize; +use std::path::Path; use crate::function_tool::FunctionCallError; +use crate::sandboxing::SandboxPermissions; +use crate::sandboxing::normalize_additional_permissions; pub use apply_patch::ApplyPatchHandler; +use codex_protocol::models::AdditionalPermissions; +use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; pub use grep_files::GrepFilesHandler; pub use js_repl::JsReplHandler; @@ -49,3 +54,58 @@ where FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}")) }) } + +/// Validates feature/policy constraints for `with_additional_permissions` and +/// returns normalized absolute paths. Errors if paths are invalid. +pub(super) fn normalize_and_validate_additional_permissions( + request_permission_enabled: bool, + approval_policy: AskForApproval, + sandbox_permissions: SandboxPermissions, + additional_permissions: Option, + cwd: &Path, +) -> Result, String> { + let uses_additional_permissions = matches!( + sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ); + + if !request_permission_enabled + && (uses_additional_permissions || additional_permissions.is_some()) + { + return Err( + "additional permissions are disabled; enable `features.request_permission` before using `with_additional_permissions`" + .to_string(), + ); + } + + if uses_additional_permissions { + if !matches!(approval_policy, AskForApproval::OnRequest) { + return Err(format!( + "approval policy is {approval_policy:?}; reject command — you cannot request additional permissions unless the approval policy is OnRequest" + )); + } + let Some(additional_permissions) = additional_permissions else { + return Err( + "missing `additional_permissions`; provide `fs_read` and/or `fs_write` when using `with_additional_permissions`" + .to_string(), + ); + }; + let normalized = normalize_additional_permissions(additional_permissions, cwd)?; + if normalized.is_empty() { + return Err( + "`additional_permissions` must include at least one path in `fs_read` or `fs_write`" + .to_string(), + ); + } + return Ok(Some(normalized)); + } + + if additional_permissions.is_some() { + Err( + "`additional_permissions` requires `sandbox_permissions` set to `with_additional_permissions`" + .to_string(), + ) + } else { + Ok(None) + } +} diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 229ef5a3d..84329b81e 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -9,6 +9,7 @@ use crate::codex::TurnContext; use crate::exec::ExecParams; use crate::exec_env::create_env; use crate::exec_policy::ExecApprovalRequest; +use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::ExecCommandSource; @@ -20,6 +21,7 @@ use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; use crate::tools::handlers::apply_patch::intercept_apply_patch; +use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::registry::ToolHandler; @@ -27,6 +29,7 @@ use crate::tools::registry::ToolKind; use crate::tools::runtimes::shell::ShellRequest; use crate::tools::runtimes::shell::ShellRuntime; use crate::tools::sandboxing::ToolCtx; +use codex_protocol::models::AdditionalPermissions; pub struct ShellHandler; @@ -35,6 +38,7 @@ pub struct ShellCommandHandler; struct RunExecLikeArgs { tool_name: String, exec_params: ExecParams, + additional_permissions: Option, prefix_rule: Option>, session: Arc, turn: Arc, @@ -150,6 +154,7 @@ impl ToolHandler for ShellHandler { Self::run_exec_like(RunExecLikeArgs { tool_name: tool_name.clone(), exec_params, + additional_permissions: params.additional_permissions.clone(), prefix_rule, session, turn, @@ -165,6 +170,7 @@ impl ToolHandler for ShellHandler { Self::run_exec_like(RunExecLikeArgs { tool_name: tool_name.clone(), exec_params, + additional_permissions: None, prefix_rule: None, session, turn, @@ -247,6 +253,7 @@ impl ToolHandler for ShellCommandHandler { ShellHandler::run_exec_like(RunExecLikeArgs { tool_name, exec_params, + additional_permissions: params.additional_permissions.clone(), prefix_rule, session, turn, @@ -263,6 +270,7 @@ impl ShellHandler { let RunExecLikeArgs { tool_name, exec_params, + additional_permissions, prefix_rule, session, turn, @@ -284,10 +292,20 @@ impl ShellHandler { } } + let request_permission_enabled = session.features().enabled(Feature::RequestPermissions); + let normalized_additional_permissions = normalize_and_validate_additional_permissions( + request_permission_enabled, + turn.approval_policy.value(), + exec_params.sandbox_permissions, + additional_permissions, + &exec_params.cwd, + ) + .map_err(FunctionCallError::RespondToModel)?; + // Approval policy guard for explicit escalation in non-OnRequest modes. if exec_params .sandbox_permissions - .requires_escalated_permissions() + .requires_additional_permissions() && !matches!( turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest @@ -345,6 +363,7 @@ impl ShellHandler { explicit_env_overrides, network: exec_params.network.clone(), sandbox_permissions: exec_params.sandbox_permissions, + additional_permissions: normalized_additional_permissions, justification: exec_params.justification.clone(), exec_approval_requirement, }; @@ -466,6 +485,7 @@ mod tests { login, timeout_ms, sandbox_permissions: Some(sandbox_permissions), + additional_permissions: None, prefix_rule: None, justification: justification.clone(), }; @@ -525,6 +545,7 @@ mod tests { login: None, timeout_ms: None, sandbox_permissions: None, + additional_permissions: None, prefix_rule: None, justification: None, }; diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 12451c51d..9c5e3bc4b 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,3 +1,4 @@ +use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; @@ -10,6 +11,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::handlers::apply_patch::intercept_apply_patch; +use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; @@ -19,6 +21,7 @@ use crate::unified_exec::UnifiedExecProcessManager; use crate::unified_exec::UnifiedExecResponse; use crate::unified_exec::WriteStdinRequest; use async_trait::async_trait; +use codex_protocol::models::AdditionalPermissions; use codex_protocol::models::FunctionCallOutputBody; use serde::Deserialize; use std::path::PathBuf; @@ -44,6 +47,8 @@ pub(crate) struct ExecCommandArgs { #[serde(default)] sandbox_permissions: SandboxPermissions, #[serde(default)] + additional_permissions: Option, + #[serde(default)] justification: Option, #[serde(default)] prefix_rule: Option>, @@ -153,12 +158,16 @@ impl ToolHandler for UnifiedExecHandler { yield_time_ms, max_output_tokens, sandbox_permissions, + additional_permissions, justification, prefix_rule, .. } = args; - if sandbox_permissions.requires_escalated_permissions() + let request_permission_enabled = + session.features().enabled(Feature::RequestPermissions); + + if sandbox_permissions.requires_additional_permissions() && !matches!( context.turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest @@ -175,6 +184,20 @@ impl ToolHandler for UnifiedExecHandler { let workdir = workdir.map(|dir| context.turn.resolve_path(Some(dir))); let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone()); + let normalized_additional_permissions = + match normalize_and_validate_additional_permissions( + request_permission_enabled, + context.turn.approval_policy.value(), + sandbox_permissions, + additional_permissions, + &cwd, + ) { + Ok(normalized) => normalized, + Err(err) => { + manager.release_process_id(&process_id).await; + return Err(FunctionCallError::RespondToModel(err)); + } + }; if let Some(output) = intercept_apply_patch( &command, @@ -203,6 +226,7 @@ impl ToolHandler for UnifiedExecHandler { network: context.turn.network.clone(), tty, sandbox_permissions, + additional_permissions: normalized_additional_permissions, justification, prefix_rule, }, diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 79195d97f..6e6bb6228 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -588,6 +588,7 @@ impl JsReplManager { env, expiration: ExecExpiration::DefaultTimeout, sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, justification: None, }; diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index d6a715145..ca31ec4d4 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -341,6 +341,7 @@ impl NetworkApprovalService { Some(prompt_reason), Some(network_approval_context.clone()), None, + None, ) .await; diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 1f1b7f3e0..fe0827f4f 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -124,6 +124,7 @@ impl ToolRouter { workdir: exec.working_directory, timeout_ms: exec.timeout_ms, sandbox_permissions: Some(SandboxPermissions::UseDefault), + additional_permissions: None, prefix_rule: None, justification: None, }; diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 749e1abd2..208c78c72 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -66,6 +66,7 @@ impl ApplyPatchRuntime { // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, justification: None, }) } @@ -162,7 +163,7 @@ impl ToolRuntime for ApplyPatchRuntime { let env = attempt .env_for(spec, None) .map_err(|err| ToolError::Codex(err.into()))?; - let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) + let out = execute_env(env, Self::stdout_stream(ctx)) .await .map_err(ToolError::Codex)?; Ok(out) diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 92c8f3922..6dc20002d 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -10,6 +10,7 @@ use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::tools::sandboxing::ToolError; +use codex_protocol::models::AdditionalPermissions; use std::collections::HashMap; use std::path::Path; @@ -25,6 +26,7 @@ pub(crate) fn build_command_spec( env: &HashMap, expiration: ExecExpiration, sandbox_permissions: SandboxPermissions, + additional_permissions: Option, justification: Option, ) -> Result { let (program, args) = command @@ -37,6 +39,7 @@ pub(crate) fn build_command_spec( env: env.clone(), expiration, sandbox_permissions, + additional_permissions, justification, }) } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 11ad66916..300e8f437 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -25,9 +25,11 @@ use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::sandbox_override_for_first_attempt; use crate::tools::sandboxing::with_cached_approval; use crate::zsh_exec_bridge::ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR; use codex_network_proxy::NetworkProxy; +use codex_protocol::models::AdditionalPermissions; use codex_protocol::protocol::ReviewDecision; use futures::future::BoxFuture; use std::path::PathBuf; @@ -41,6 +43,7 @@ pub struct ShellRequest { pub explicit_env_overrides: std::collections::HashMap, pub network: Option, pub sandbox_permissions: SandboxPermissions, + pub additional_permissions: Option, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -53,6 +56,7 @@ pub(crate) struct ApprovalKey { command: Vec, cwd: PathBuf, sandbox_permissions: SandboxPermissions, + additional_permissions: Option, } impl ShellRuntime { @@ -86,6 +90,7 @@ impl Approvable for ShellRuntime { command: canonicalize_command_for_approval(&req.command), cwd: req.cwd.clone(), sandbox_permissions: req.sandbox_permissions, + additional_permissions: req.additional_permissions.clone(), }] } @@ -118,6 +123,7 @@ impl Approvable for ShellRuntime { req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), + req.additional_permissions.clone(), ) .await }) @@ -130,19 +136,7 @@ impl Approvable for ShellRuntime { } fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride { - if req.sandbox_permissions.requires_escalated_permissions() - || matches!( - req.exec_approval_requirement, - ExecApprovalRequirement::Skip { - bypass_sandbox: true, - .. - } - ) - { - SandboxOverride::BypassSandboxFirstAttempt - } else { - SandboxOverride::NoOverride - } + sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement) } } @@ -198,6 +192,7 @@ impl ToolRuntime for ShellRuntime { &zsh_fork_env, req.timeout_ms.into(), req.sandbox_permissions, + req.additional_permissions.clone(), req.justification.clone(), )?; let env = attempt @@ -217,12 +212,13 @@ impl ToolRuntime for ShellRuntime { &req.env, req.timeout_ms.into(), req.sandbox_permissions, + req.additional_permissions.clone(), req.justification.clone(), )?; let env = attempt .env_for(spec, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; - let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) + let out = execute_env(env, Self::stdout_stream(ctx)) .await .map_err(ToolError::Codex)?; Ok(out) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5e06ca397..893dae27e 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -26,11 +26,13 @@ use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::sandbox_override_for_first_attempt; use crate::tools::sandboxing::with_cached_approval; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; use codex_network_proxy::NetworkProxy; +use codex_protocol::models::AdditionalPermissions; use codex_protocol::protocol::ReviewDecision; use futures::future::BoxFuture; use std::collections::HashMap; @@ -45,6 +47,7 @@ pub struct UnifiedExecRequest { pub network: Option, pub tty: bool, pub sandbox_permissions: SandboxPermissions, + pub additional_permissions: Option, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -55,6 +58,7 @@ pub struct UnifiedExecApprovalKey { pub cwd: PathBuf, pub tty: bool, pub sandbox_permissions: SandboxPermissions, + pub additional_permissions: Option, } pub struct UnifiedExecRuntime<'a> { @@ -86,6 +90,7 @@ impl Approvable for UnifiedExecRuntime<'_> { cwd: req.cwd.clone(), tty: req.tty, sandbox_permissions: req.sandbox_permissions, + additional_permissions: req.additional_permissions.clone(), }] } @@ -118,6 +123,7 @@ impl Approvable for UnifiedExecRuntime<'_> { req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), + req.additional_permissions.clone(), ) .await }) @@ -133,19 +139,7 @@ impl Approvable for UnifiedExecRuntime<'_> { } fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride { - if req.sandbox_permissions.requires_escalated_permissions() - || matches!( - req.exec_approval_requirement, - ExecApprovalRequirement::Skip { - bypass_sandbox: true, - .. - } - ) - { - SandboxOverride::BypassSandboxFirstAttempt - } else { - SandboxOverride::NoOverride - } + sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement) } } @@ -194,6 +188,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &env, ExecExpiration::DefaultTimeout, req.sandbox_permissions, + req.additional_permissions.clone(), req.justification.clone(), ) .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 540eb1643..25ea10159 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -10,6 +10,7 @@ use crate::error::CodexErr; use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; +use crate::sandboxing::SandboxPermissions; use crate::sandboxing::SandboxTransformError; use crate::state::SessionServices; use crate::tools::network_approval::NetworkApprovalSpec; @@ -200,6 +201,27 @@ pub(crate) enum SandboxOverride { BypassSandboxFirstAttempt, } +pub(crate) fn sandbox_override_for_first_attempt( + sandbox_permissions: SandboxPermissions, + exec_approval_requirement: &ExecApprovalRequirement, +) -> SandboxOverride { + // ExecPolicy `Allow` can intentionally imply full trust (Skip + bypass_sandbox=true), + // which supersedes `with_additional_permissions` sandboxed execution hints. + if sandbox_permissions.requires_escalated_permissions() + || matches!( + exec_approval_requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + .. + } + ) + { + SandboxOverride::BypassSandboxFirstAttempt + } else { + SandboxOverride::NoOverride + } +} + pub(crate) trait Approvable { type ApprovalKey: Hash + Eq + Clone + Debug + Serialize; @@ -328,6 +350,7 @@ impl<'a> SandboxAttempt<'a> { #[cfg(test)] mod tests { use super::*; + use crate::sandboxing::SandboxPermissions; use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::RejectConfig; use pretty_assertions::assert_eq; @@ -400,4 +423,18 @@ mod tests { } ); } + + #[test] + fn additional_permissions_allow_bypass_sandbox_first_attempt_when_execpolicy_skips() { + assert_eq!( + sandbox_override_for_first_attempt( + SandboxPermissions::WithAdditionalPermissions, + &ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + }, + ), + SandboxOverride::BypassSandboxFirstAttempt + ); + } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 60bd8bb03..0c36675f5 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -40,6 +40,7 @@ pub(crate) struct ToolsConfig { pub web_search_mode: Option, pub agent_roles: BTreeMap, pub search_tool: bool, + pub request_permission_enabled: bool, pub js_repl_enabled: bool, pub js_repl_tools_only: bool, pub collab_tools: bool, @@ -67,6 +68,7 @@ impl ToolsConfig { let include_collab_tools = features.enabled(Feature::Collab); let include_collaboration_modes_tools = true; let include_search_tool = features.enabled(Feature::Apps); + let request_permission_enabled = features.enabled(Feature::RequestPermissions); let shell_type = if !features.enabled(Feature::ShellTool) { ConfigShellToolType::Disabled @@ -102,6 +104,7 @@ impl ToolsConfig { web_search_mode: *web_search_mode, agent_roles: BTreeMap::new(), search_tool: include_search_tool, + request_permission_enabled, js_repl_enabled: include_js_repl, js_repl_tools_only: include_js_repl_tools_only, collab_tools: include_collab_tools, @@ -177,14 +180,18 @@ impl From for AdditionalProperties { } } -fn create_approval_parameters() -> BTreeMap { +fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap { let mut properties = BTreeMap::from([ ( "sandbox_permissions".to_string(), JsonSchema::String { description: Some( - "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." - .to_string(), + if request_permission_enabled { + "Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem access (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." + } else { + "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." + } + .to_string(), ), }, ), @@ -201,24 +208,55 @@ fn create_approval_parameters() -> BTreeMap { ), }, ), + ( + "prefix_rule".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some( + r#"Only specify when sandbox_permissions is `require_escalated`. + Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future. + Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(), + ), + }, + ) ]); - properties.insert( - "prefix_rule".to_string(), - JsonSchema::Array { - items: Box::new(JsonSchema::String { description: None }), - description: Some( - r#"Only specify when sandbox_permissions is `require_escalated`. - Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future. - Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(), - ), - }, - ); + if request_permission_enabled { + properties.insert( + "additional_permissions".to_string(), + JsonSchema::Object { + properties: BTreeMap::from([ + ( + "fs_read".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some( + "Additional filesystem paths to grant read access for this command." + .to_string(), + ), + }, + ), + ( + "fs_write".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some( + "Additional filesystem paths to grant write access for this command." + .to_string(), + ), + }, + ), + ]), + required: None, + additional_properties: Some(false.into()), + }, + ); + } properties } -fn create_exec_command_tool(allow_login_shell: bool) -> ToolSpec { +fn create_exec_command_tool(allow_login_shell: bool, request_permission_enabled: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "cmd".to_string(), @@ -278,7 +316,7 @@ fn create_exec_command_tool(allow_login_shell: bool) -> ToolSpec { }, ); } - properties.extend(create_approval_parameters()); + properties.extend(create_approval_parameters(request_permission_enabled)); ToolSpec::Function(ResponsesApiTool { name: "exec_command".to_string(), @@ -341,7 +379,7 @@ fn create_write_stdin_tool() -> ToolSpec { }) } -fn create_shell_tool() -> ToolSpec { +fn create_shell_tool(request_permission_enabled: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -363,7 +401,7 @@ fn create_shell_tool() -> ToolSpec { }, ), ]); - properties.extend(create_approval_parameters()); + properties.extend(create_approval_parameters(request_permission_enabled)); let description = if cfg!(windows) { r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. @@ -394,7 +432,10 @@ Examples of valid command strings: }) } -fn create_shell_command_tool(allow_login_shell: bool) -> ToolSpec { +fn create_shell_command_tool( + allow_login_shell: bool, + request_permission_enabled: bool, +) -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -428,7 +469,7 @@ fn create_shell_command_tool(allow_login_shell: bool) -> ToolSpec { }, ); } - properties.extend(create_approval_parameters()); + properties.extend(create_approval_parameters(request_permission_enabled)); let description = if cfg!(windows) { r#"Runs a Powershell command (Windows) and returns its output. @@ -1464,17 +1505,21 @@ pub(crate) fn build_specs( let search_tool_handler = Arc::new(SearchToolBm25Handler); let js_repl_handler = Arc::new(JsReplHandler); let js_repl_reset_handler = Arc::new(JsReplResetHandler); + let request_permission_enabled = config.request_permission_enabled; match &config.shell_type { ConfigShellToolType::Default => { - builder.push_spec_with_parallel_support(create_shell_tool(), true); + builder.push_spec_with_parallel_support( + create_shell_tool(request_permission_enabled), + true, + ); } ConfigShellToolType::Local => { builder.push_spec_with_parallel_support(ToolSpec::LocalShell {}, true); } ConfigShellToolType::UnifiedExec => { builder.push_spec_with_parallel_support( - create_exec_command_tool(config.allow_login_shell), + create_exec_command_tool(config.allow_login_shell, request_permission_enabled), true, ); builder.push_spec(create_write_stdin_tool()); @@ -1486,7 +1531,7 @@ pub(crate) fn build_specs( } ConfigShellToolType::ShellCommand => { builder.push_spec_with_parallel_support( - create_shell_command_tool(config.allow_login_shell), + create_shell_command_tool(config.allow_login_shell, request_permission_enabled), true, ); } @@ -1833,7 +1878,7 @@ mod tests { // Build expected from the same helpers used by the builder. let mut expected: BTreeMap = BTreeMap::from([]); for spec in [ - create_exec_command_tool(true), + create_exec_command_tool(true, false), create_write_stdin_tool(), PLAN_TOOL.clone(), create_request_user_input_tool(), @@ -2749,7 +2794,7 @@ mod tests { #[test] fn test_shell_tool() { - let tool = super::create_shell_tool(); + let tool = super::create_shell_tool(false); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool @@ -2777,9 +2822,30 @@ Examples of valid command strings: assert_eq!(description, &expected); } + #[test] + fn shell_tool_with_request_permission_includes_additional_permissions() { + let tool = super::create_shell_tool(true); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { + panic!("expected function tool"); + }; + let JsonSchema::Object { properties, .. } = parameters else { + panic!("expected object parameters"); + }; + + assert!(properties.contains_key("additional_permissions")); + + let Some(JsonSchema::String { + description: Some(description), + }) = properties.get("sandbox_permissions") + else { + panic!("expected sandbox_permissions description"); + }; + assert!(description.contains("with_additional_permissions")); + } + #[test] fn test_shell_command_tool() { - let tool = super::create_shell_command_tool(true); + let tool = super::create_shell_command_tool(true, false); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 62269a96d..1481d3013 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -29,6 +29,7 @@ use std::sync::Weak; use std::time::Duration; use codex_network_proxy::NetworkProxy; +use codex_protocol::models::AdditionalPermissions; use rand::Rng; use rand::rng; use tokio::sync::Mutex; @@ -89,6 +90,7 @@ pub(crate) struct ExecCommandRequest { pub network: Option, pub tty: bool, pub sandbox_permissions: SandboxPermissions, + pub additional_permissions: Option, pub justification: Option, pub prefix_rule: Option>, } @@ -229,6 +231,7 @@ mod tests { network: None, tty: true, sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, justification: None, prefix_rule: None, }, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a3da4ba6b..821abc683 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -590,6 +590,7 @@ impl UnifiedExecProcessManager { network: request.network.clone(), tty: request.tty, sandbox_permissions: request.sandbox_permissions, + additional_permissions: request.additional_permissions.clone(), justification: request.justification.clone(), exec_approval_requirement, }; diff --git a/codex-rs/core/src/zsh_exec_bridge/mod.rs b/codex-rs/core/src/zsh_exec_bridge/mod.rs index 932bdc02b..4d8b51f68 100644 --- a/codex-rs/core/src/zsh_exec_bridge/mod.rs +++ b/codex-rs/core/src/zsh_exec_bridge/mod.rs @@ -366,6 +366,7 @@ impl ZshExecBridge { approval_reason, None, None::, + None, ) .await; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 944479199..cbe178093 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -211,7 +211,7 @@ fn shell_event_with_prefix_rule( "command": command, "timeout_ms": timeout_ms, }); - if sandbox_permissions.requires_escalated_permissions() { + if sandbox_permissions.requires_additional_permissions() { args["sandbox_permissions"] = json!(sandbox_permissions); } if let Some(prefix_rule) = prefix_rule { @@ -234,7 +234,7 @@ fn exec_command_event( if let Some(yield_time_ms) = yield_time_ms { args["yield_time_ms"] = json!(yield_time_ms); } - if sandbox_permissions.requires_escalated_permissions() { + if sandbox_permissions.requires_additional_permissions() { args["sandbox_permissions"] = json!(sandbox_permissions); let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); args["justification"] = json!(reason); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 4bd6badb8..c5abe2eaa 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -99,6 +99,8 @@ mod read_file; mod realtime_conversation; mod remote_models; mod request_compression; +#[cfg(not(target_os = "windows"))] +mod request_permissions; mod request_user_input; mod resume; mod resume_warning; diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 4bbe5f4ab..36c93b379 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -489,6 +489,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { AskForApproval::OnRequest, &Policy::empty(), test.config.cwd.as_path(), + false, ) .into_text(); // Normalize line endings to handle Windows vs Unix differences diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs new file mode 100644 index 000000000..d6003e4b8 --- /dev/null +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -0,0 +1,595 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_core::config::Constrained; +use codex_core::features::Feature; +use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::AdditionalPermissions; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +#[cfg(target_os = "macos")] +use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use regex_lite::Regex; +use serde_json::Value; +use serde_json::json; +use std::fs; + +struct CommandResult { + exit_code: Option, + stdout: String, +} + +fn parse_result(item: &Value) -> CommandResult { + let output_str = item + .get("output") + .and_then(Value::as_str) + .expect("shell output payload"); + match serde_json::from_str::(output_str) { + Ok(parsed) => { + let exit_code = parsed["metadata"]["exit_code"].as_i64(); + let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); + CommandResult { exit_code, stdout } + } + Err(_) => { + let structured = Regex::new(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$").unwrap(); + let regex = + Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap(); + if let Some(captures) = structured.captures(output_str) { + let exit_code = captures.get(1).unwrap().as_str().parse::().unwrap(); + let output = captures.get(2).unwrap().as_str(); + CommandResult { + exit_code: Some(exit_code), + stdout: output.to_string(), + } + } else if let Some(captures) = regex.captures(output_str) { + let exit_code = captures.get(1).unwrap().as_str().parse::().unwrap(); + let output = captures.get(2).unwrap().as_str(); + CommandResult { + exit_code: Some(exit_code), + stdout: output.to_string(), + } + } else { + CommandResult { + exit_code: None, + stdout: output_str.to_string(), + } + } + } + } +} + +fn shell_event_with_request_permissions( + call_id: &str, + command: &str, + additional_permissions: &AdditionalPermissions, +) -> Result { + let args = json!({ + "command": command, + "timeout_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + "additional_permissions": additional_permissions, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "shell_command", &args_str)) +} + +async fn submit_turn( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, +) -> Result<()> { + let session_model = test.session_configured.model.clone(); + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy, + sandbox_policy, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + Ok(()) +} + +async fn wait_for_completion(test: &TestCodex) { + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; +} + +async fn expect_exec_approval( + test: &TestCodex, + expected_command: &str, +) -> ExecApprovalRequestEvent { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + + match event { + EventMsg::ExecApprovalRequest(approval) => { + let last_arg = approval + .command + .last() + .map(String::as_str) + .unwrap_or_default(); + assert_eq!(last_arg, expected_command); + approval + } + EventMsg::TurnComplete(_) => panic!("expected approval request before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +fn workspace_write_excluding_tmp() -> SandboxPolicy { + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(target_os = "macos")] +async fn with_additional_permissions_requires_approval_under_on_request() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.features.enable(Feature::RequestPermissions); + }); + let test = builder.build(&server).await?; + + let requested_write = test.workspace_path("requested-but-unused.txt"); + let _ = fs::remove_file(&requested_write); + let call_id = "request_permissions_skip_approval"; + let command = "touch requested-but-unused.txt"; + let requested_permissions = AdditionalPermissions { + fs_read: vec![], + fs_write: vec![requested_write.clone()], + }; + let event = shell_event_with_request_permissions(call_id, command, &requested_permissions)?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + event, + ev_completed("resp-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + let approval = expect_exec_approval(&test, command).await; + assert_eq!( + approval.additional_permissions, + Some(requested_permissions.clone()) + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert!( + result.exit_code.is_none() || result.exit_code == Some(0), + "unexpected exit code/output: {:?} {}", + result.exit_code, + result.stdout + ); + assert!( + requested_write.exists(), + "touch command should create requested path" + ); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(target_os = "macos")] +async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.features.enable(Feature::RequestPermissions); + }); + let test = builder.build(&server).await?; + + let requested_write = test.workspace_path("requested-only-cwd.txt"); + let unrequested_write = test.workspace_path("unrequested-cwd-write.txt"); + let _ = fs::remove_file(&requested_write); + let _ = fs::remove_file(&unrequested_write); + + let call_id = "request_permissions_cwd_widening"; + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "cwd-widened", unrequested_write, unrequested_write + ); + let requested_permissions = AdditionalPermissions { + fs_read: vec![], + fs_write: vec![requested_write.clone()], + }; + let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-cwd-1"), + event, + ev_completed("resp-cwd-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-cwd-1", "done"), + ev_completed("resp-cwd-2"), + ]), + ) + .await; + + submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + + let approval = expect_exec_approval(&test, &command).await; + assert_eq!( + approval.additional_permissions, + Some(requested_permissions.clone()) + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert!( + result.exit_code.is_none() || result.exit_code == Some(0), + "unexpected exit code/output: {:?} {}", + result.exit_code, + result.stdout + ); + assert!(result.stdout.contains("cwd-widened")); + assert_eq!(fs::read_to_string(&unrequested_write)?, "cwd-widened"); + assert!( + !requested_write.exists(), + "only the unrequested cwd path should have been written" + ); + + let _ = fs::remove_file(unrequested_write); + let _ = fs::remove_file(requested_write); + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(target_os = "macos")] +async fn read_only_with_additional_permissions_widens_to_unrequested_tmp_write() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.features.enable(Feature::RequestPermissions); + }); + let test = builder.build(&server).await?; + + let requested_write = test.workspace_path("requested-only-tmp.txt"); + let tmp_dir = tempfile::tempdir()?; + let tmp_write = tmp_dir.path().join("tmp-widening.txt"); + let _ = fs::remove_file(&requested_write); + let _ = fs::remove_file(&tmp_write); + + let call_id = "request_permissions_tmp_widening"; + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "tmp-widened", tmp_write, tmp_write + ); + let requested_permissions = AdditionalPermissions { + fs_read: vec![], + fs_write: vec![requested_write.clone()], + }; + let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-tmp-1"), + event, + ev_completed("resp-tmp-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-tmp-1", "done"), + ev_completed("resp-tmp-2"), + ]), + ) + .await; + + submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + + let approval = expect_exec_approval(&test, &command).await; + assert_eq!( + approval.additional_permissions, + Some(requested_permissions.clone()) + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert!( + result.exit_code.is_none() || result.exit_code == Some(0), + "unexpected exit code/output: {:?} {}", + result.exit_code, + result.stdout + ); + assert!(result.stdout.contains("tmp-widened")); + assert_eq!(fs::read_to_string(&tmp_write)?, "tmp-widened"); + assert!( + !requested_write.exists(), + "only the unrequested tmp path should have been written" + ); + + let _ = fs::remove_file(tmp_write); + let _ = fs::remove_file(requested_write); + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(target_os = "macos")] +async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.features.enable(Feature::RequestPermissions); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let outside_write = outside_dir.path().join("workspace-write-outside.txt"); + let placeholder = test.workspace_path("workspace-write-placeholder.txt"); + let _ = fs::remove_file(&outside_write); + let _ = fs::remove_file(&placeholder); + + let call_id = "request_permissions_workspace_write_outside"; + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "outside-cwd-ok", outside_write, outside_write + ); + let requested_permissions = AdditionalPermissions { + fs_read: vec![], + fs_write: vec![outside_dir.path().to_path_buf()], + }; + let normalized_requested_permissions = AdditionalPermissions { + fs_read: vec![], + fs_write: vec![outside_dir.path().canonicalize()?], + }; + let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-ww-1"), + event, + ev_completed("resp-ww-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-ww-1", "done"), + ev_completed("resp-ww-2"), + ]), + ) + .await; + + submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + + let approval = expect_exec_approval(&test, &command).await; + assert_eq!( + approval.additional_permissions, + Some(normalized_requested_permissions) + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert!( + result.exit_code.is_none() || result.exit_code == Some(0), + "unexpected exit code/output: {:?} {}", + result.exit_code, + result.stdout + ); + assert!(result.stdout.contains("outside-cwd-ok")); + assert_eq!(fs::read_to_string(&outside_write)?, "outside-cwd-ok"); + assert!( + !placeholder.exists(), + "placeholder path should remain untouched" + ); + + let _ = fs::remove_file(outside_write); + let _ = fs::remove_file(placeholder); + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(unix)] +async fn with_additional_permissions_denied_approval_blocks_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.features.enable(Feature::RequestPermissions); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let outside_write = outside_dir.path().join("workspace-write-denied.txt"); + let _ = fs::remove_file(&outside_write); + + let call_id = "request_permissions_denied"; + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "should-not-write", outside_write, outside_write + ); + let requested_permissions = AdditionalPermissions { + fs_read: vec![], + fs_write: vec![outside_dir.path().to_path_buf()], + }; + let normalized_requested_permissions = AdditionalPermissions { + fs_read: vec![], + fs_write: vec![outside_dir.path().canonicalize()?], + }; + let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-denied-1"), + event, + ev_completed("resp-denied-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-denied-1", "done"), + ev_completed("resp-denied-2"), + ]), + ) + .await; + + submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + + let approval = expect_exec_approval(&test, &command).await; + assert_eq!( + approval.additional_permissions, + Some(normalized_requested_permissions) + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Denied, + }) + .await?; + wait_for_completion(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert_ne!( + result.exit_code, + Some(0), + "denied command should not succeed" + ); + assert!( + result.stdout.contains("rejected by user"), + "unexpected denial output: {}", + result.stdout + ); + assert!( + !outside_write.exists(), + "denied command should not create file" + ); + + let _ = fs::remove_file(outside_write); + Ok(()) +} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 409cc7116..eff8d8e13 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -226,6 +226,7 @@ async fn run_codex_tool_session_inner( proposed_network_policy_amendments: _, parsed_cmd, network_approval_context: _, + additional_permissions: _, } = ev; handle_exec_approval_request( command, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 58b9231a7..e0f0c0625 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::mcp::RequestId; +use crate::models::AdditionalPermissions; use crate::parse_command::ParsedCommand; use crate::protocol::FileChange; use schemars::JsonSchema; @@ -102,6 +103,10 @@ pub struct ExecApprovalRequestEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub proposed_network_policy_amendments: Option>, + /// Optional additional filesystem permissions requested for this command. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub additional_permissions: Option, pub parsed_cmd: Vec, } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 65099541c..c9fea3a29 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::path::Path; +use std::path::PathBuf; use codex_utils_image::load_and_resize_to_fit; use serde::Deserialize; @@ -35,12 +36,34 @@ pub enum SandboxPermissions { UseDefault, /// Request to run outside the sandbox RequireEscalated, + /// Request to run in the sandbox with additional per-command permissions. + WithAdditionalPermissions, } impl SandboxPermissions { + /// True if SandboxPermissions requires full unsandboxed execution (i.e. RequireEscalated) pub fn requires_escalated_permissions(self) -> bool { matches!(self, SandboxPermissions::RequireEscalated) } + + /// True if SandboxPermissions requires permissions beyond UseDefault + pub fn requires_additional_permissions(self) -> bool { + !matches!(self, SandboxPermissions::UseDefault) + } +} + +#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +pub struct AdditionalPermissions { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fs_read: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fs_write: Vec, +} + +impl AdditionalPermissions { + pub fn is_empty(&self) -> bool { + self.fs_read.is_empty() && self.fs_write.is_empty() + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -227,6 +250,8 @@ const APPROVAL_POLICY_ON_FAILURE: &str = include_str!("prompts/permissions/approval_policy/on_failure.md"); const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("prompts/permissions/approval_policy/on_request_rule.md"); +const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = + include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md"); const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = include_str!("prompts/permissions/sandbox_mode/danger_full_access.md"); @@ -239,16 +264,25 @@ impl DeveloperInstructions { Self { text: text.into() } } - pub fn from(approval_policy: AskForApproval, exec_policy: &Policy) -> DeveloperInstructions { + pub fn from( + approval_policy: AskForApproval, + exec_policy: &Policy, + request_permission_enabled: bool, + ) -> DeveloperInstructions { let on_request_instructions = || { + let on_request_rule = if request_permission_enabled { + APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION + } else { + APPROVAL_POLICY_ON_REQUEST_RULE + }; let command_prefixes = format_allow_prefixes(exec_policy.get_allowed_prefixes()); match command_prefixes { Some(prefixes) => { format!( - "{APPROVAL_POLICY_ON_REQUEST_RULE}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}" + "{on_request_rule}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}" ) } - None => APPROVAL_POLICY_ON_REQUEST_RULE.to_string(), + None => on_request_rule.to_string(), } }; let text = match approval_policy { @@ -306,6 +340,7 @@ impl DeveloperInstructions { approval_policy: AskForApproval, exec_policy: &Policy, cwd: &Path, + request_permission_enabled: bool, ) -> Self { let network_access = if sandbox_policy.has_full_network_access() { NetworkAccess::Enabled @@ -329,6 +364,7 @@ impl DeveloperInstructions { approval_policy, exec_policy, writable_roots, + request_permission_enabled, ) } @@ -352,6 +388,7 @@ impl DeveloperInstructions { approval_policy: AskForApproval, exec_policy: &Policy, writable_roots: Option>, + request_permission_enabled: bool, ) -> Self { let start_tag = DeveloperInstructions::new(""); let end_tag = DeveloperInstructions::new(""); @@ -360,7 +397,11 @@ impl DeveloperInstructions { sandbox_mode, network_access, )) - .concat(DeveloperInstructions::from(approval_policy, exec_policy)) + .concat(DeveloperInstructions::from( + approval_policy, + exec_policy, + request_permission_enabled, + )) .concat(DeveloperInstructions::from_writable_roots(writable_roots)) .concat(end_tag) } @@ -757,6 +798,9 @@ pub struct ShellToolCallParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub prefix_rule: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub additional_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -780,6 +824,9 @@ pub struct ShellCommandToolCallParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub prefix_rule: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub additional_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -1205,6 +1252,7 @@ mod tests { AskForApproval::OnRequest, &Policy::empty(), None, + false, ); let text = instructions.into_text(); @@ -1233,6 +1281,7 @@ mod tests { AskForApproval::UnlessTrusted, &Policy::empty(), &PathBuf::from("/tmp"), + false, ); let text = instructions.into_text(); assert!(text.contains("Network access is enabled.")); @@ -1254,6 +1303,7 @@ mod tests { AskForApproval::OnRequest, &exec_policy, None, + false, ); let text = instructions.into_text(); @@ -1262,6 +1312,22 @@ mod tests { assert!(text.contains(r#"["git", "pull"]"#)); } + #[test] + fn includes_request_permission_rule_instructions_for_on_request_when_enabled() { + let instructions = DeveloperInstructions::from_permissions_with_network( + SandboxMode::WorkspaceWrite, + NetworkAccess::Enabled, + AskForApproval::OnRequest, + &Policy::empty(), + None, + true, + ); + + let text = instructions.into_text(); + assert!(text.contains("with_additional_permissions")); + assert!(text.contains("additional_permissions")); + } + #[test] fn render_command_prefix_list_sorts_by_len_then_total_len_then_alphabetical() { let prefixes = vec![ @@ -1572,6 +1638,7 @@ mod tests { timeout_ms: Some(1000), sandbox_permissions: None, prefix_rule: None, + additional_permissions: None, justification: None, }, params diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md new file mode 100644 index 000000000..7a9f71e96 --- /dev/null +++ b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md @@ -0,0 +1,30 @@ +# Permission Requests + +Commands may require user approval before execution. Prefer requesting sandboxed additional permissions instead of asking to run fully outside the sandbox. + +## Preferred request mode + +When you need extra filesystem access for one command, use: + +- `sandbox_permissions: "with_additional_permissions"` +- `additional_permissions` with one or both fields: + - `fs_read`: list of paths that need read access + - `fs_write`: list of paths that need write access + +This keeps execution inside the current sandbox policy, while adding only the requested permissions for that command, unless an exec-policy allow rule applies and authorizes running the command outside the sandbox. + +If the command already matches an exec-policy allow rule, the command can be auto-approved without an extra prompt. In that case, exec-policy allow behavior (including any sandbox bypass) takes precedence. + +## Escalation Requests + +Use full escalation only when sandboxed additional permissions cannot satisfy the task. + +- `sandbox_permissions: "require_escalated"` +- Include `justification` as a short question asking for approval. +- Optionally include `prefix_rule` to suggest a reusable allow rule. + +## Command segmentation reminder + +The command string is split into independent command segments at shell control operators, including pipes (`|`), logical operators (`&&`, `||`), command separators (`;`), and subshell boundaries (`(...)`, `$()`). + +Each segment is evaluated independently for sandbox restrictions and approval requirements. diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index bac964b91..76b1c3d60 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -18,6 +18,7 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use codex_core::features::Features; use codex_protocol::mcp::RequestId; +use codex_protocol::models::AdditionalPermissions; use codex_protocol::protocol::ElicitationAction; use codex_protocol::protocol::ExecPolicyAmendment; use codex_protocol::protocol::FileChange; @@ -45,6 +46,7 @@ pub(crate) enum ApprovalRequest { reason: Option, network_approval_context: Option, proposed_execpolicy_amendment: Option, + additional_permissions: Option, }, ApplyPatch { id: String, @@ -112,11 +114,13 @@ impl ApprovalOverlay { ApprovalVariant::Exec { network_approval_context, proposed_execpolicy_amendment, + additional_permissions, .. } => ( exec_options( proposed_execpolicy_amendment.clone(), network_approval_context.as_ref(), + additional_permissions.as_ref(), ), network_approval_context.as_ref().map_or_else( || "Would you like to run the following command?".to_string(), @@ -358,18 +362,29 @@ impl From for ApprovalRequestState { reason, network_approval_context, proposed_execpolicy_amendment, + additional_permissions, } => { let mut header: Vec> = Vec::new(); if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); header.push(Line::from("")); } + if let Some(ref additional_permissions) = additional_permissions + && let Some(rule_line) = + format_additional_permissions_rule(additional_permissions) + { + header.push(Line::from(vec![ + "Permission rule: ".into(), + rule_line.cyan(), + ])); + header.push(Line::from("")); + } + let full_cmd = strip_bash_lc_and_escape(&command); + let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); + if let Some(first) = full_cmd_lines.first_mut() { + first.spans.insert(0, Span::from("$ ")); + } if network_approval_context.is_none() { - let full_cmd = strip_bash_lc_and_escape(&command); - let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); - if let Some(first) = full_cmd_lines.first_mut() { - first.spans.insert(0, Span::from("$ ")); - } header.extend(full_cmd_lines); } Self { @@ -378,6 +393,7 @@ impl From for ApprovalRequestState { command, network_approval_context, proposed_execpolicy_amendment, + additional_permissions, }, header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), } @@ -434,6 +450,7 @@ enum ApprovalVariant { command: Vec, network_approval_context: Option, proposed_execpolicy_amendment: Option, + additional_permissions: Option, }, ApplyPatch { id: String, @@ -469,6 +486,7 @@ impl ApprovalOption { fn exec_options( proposed_execpolicy_amendment: Option, network_approval_context: Option<&NetworkApprovalContext>, + additional_permissions: Option<&AdditionalPermissions>, ) -> Vec { if network_approval_context.is_some() { return vec![ @@ -493,6 +511,23 @@ fn exec_options( ]; } + if additional_permissions.is_some() { + return vec![ + ApprovalOption { + label: "Yes, proceed".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }, + ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ]; + } + vec![ApprovalOption { label: "Yes, proceed".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Approved), @@ -526,6 +561,36 @@ fn exec_options( .collect() } +fn format_additional_permissions_rule( + additional_permissions: &AdditionalPermissions, +) -> Option { + let mut parts = Vec::new(); + if !additional_permissions.fs_read.is_empty() { + let reads = additional_permissions + .fs_read + .iter() + .map(|path| format!("`{}`", path.display())) + .collect::>() + .join(", "); + parts.push(format!("read {reads}")); + } + if !additional_permissions.fs_write.is_empty() { + let writes = additional_permissions + .fs_write + .iter() + .map(|path| format!("`{}`", path.display())) + .collect::>() + .join(", "); + parts.push(format!("write {writes}")); + } + + if parts.is_empty() { + None + } else { + Some(parts.join("; ")) + } +} + fn patch_options() -> Vec { vec![ ApprovalOption { @@ -577,9 +642,26 @@ mod tests { use super::*; use crate::app_event::AppEvent; use codex_protocol::protocol::NetworkApprovalProtocol; + use insta::assert_snapshot; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; + fn render_overlay_lines(view: &ApprovalOverlay, width: u16) -> String { + let height = view.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + view.render(Rect::new(0, 0, width, height), &mut buf); + (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + } + fn make_exec_request() -> ApprovalRequest { ApprovalRequest::Exec { id: "test".to_string(), @@ -587,6 +669,7 @@ mod tests { reason: Some("reason".to_string()), network_approval_context: None, proposed_execpolicy_amendment: None, + additional_permissions: None, } } @@ -632,6 +715,7 @@ mod tests { proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), + additional_permissions: None, }, tx, Features::with_defaults(), @@ -669,6 +753,7 @@ mod tests { reason: None, network_approval_context: None, proposed_execpolicy_amendment: None, + additional_permissions: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); @@ -699,6 +784,7 @@ mod tests { let options = exec_options( Some(ExecPolicyAmendment::new(vec!["curl".to_string()])), Some(&network_context), + None, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -712,6 +798,83 @@ mod tests { ); } + #[test] + fn additional_permissions_exec_options_hide_execpolicy_amendment() { + let additional_permissions = AdditionalPermissions { + fs_read: vec![PathBuf::from("/tmp/readme.txt")], + fs_write: vec![PathBuf::from("/tmp/out.txt")], + }; + let options = exec_options(None, None, Some(&additional_permissions)); + + let labels: Vec = options.into_iter().map(|option| option.label).collect(); + assert_eq!( + labels, + vec![ + "Yes, proceed".to_string(), + "No, and tell Codex what to do differently".to_string(), + ] + ); + } + + #[test] + fn additional_permissions_prompt_shows_permission_rule_line() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let exec_request = ApprovalRequest::Exec { + id: "test".into(), + command: vec!["cat".into(), "/tmp/readme.txt".into()], + reason: None, + network_approval_context: None, + proposed_execpolicy_amendment: None, + additional_permissions: Some(AdditionalPermissions { + fs_read: vec![PathBuf::from("/tmp/readme.txt")], + fs_write: vec![PathBuf::from("/tmp/out.txt")], + }), + }; + + let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 120, view.desired_height(120))); + view.render(Rect::new(0, 0, 120, view.desired_height(120)), &mut buf); + + let rendered: Vec = (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect() + }) + .collect(); + + assert!( + rendered + .iter() + .any(|line| line.contains("Permission rule:")), + "expected permission-rule line, got {rendered:?}" + ); + } + + #[test] + fn additional_permissions_prompt_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let exec_request = ApprovalRequest::Exec { + id: "test".into(), + command: vec!["cat".into(), "/tmp/readme.txt".into()], + reason: Some("need filesystem access".into()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + additional_permissions: Some(AdditionalPermissions { + fs_read: vec![PathBuf::from("/tmp/readme.txt")], + fs_write: vec![PathBuf::from("/tmp/out.txt")], + }), + }; + + let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + assert_snapshot!( + "approval_overlay_additional_permissions_prompt", + render_overlay_lines(&view, 120) + ); + } + #[test] fn network_exec_prompt_title_includes_host() { let (tx, _rx) = unbounded_channel::(); @@ -725,6 +888,7 @@ mod tests { protocol: NetworkApprovalProtocol::Https, }), proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec!["curl".into()])), + additional_permissions: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 20eb840a2..28cd1ddc7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1084,6 +1084,7 @@ mod tests { reason: None, network_approval_context: None, proposed_execpolicy_amendment: None, + additional_permissions: None, } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap new file mode 100644 index 000000000..5b04b78b1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "render_overlay_lines(&view, 120)" +--- + + Would you like to run the following command? + + Reason: need filesystem access + + Permission rule: read `/tmp/readme.txt`; write `/tmp/out.txt` + + $ cat /tmp/readme.txt + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ab6b5af46..60ac79c56 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2552,6 +2552,7 @@ impl ChatWidget { reason: ev.reason, network_approval_context: ev.network_approval_context, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, + additional_permissions: ev.additional_permissions, }; self.bottom_pane .push_approval_request(request, &self.config.features); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f05f7bd82..8f6bf7176 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2776,6 +2776,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { network_approval_context: None, proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, + additional_permissions: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2823,6 +2824,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { network_approval_context: None, proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, + additional_permissions: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2876,6 +2878,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { network_approval_context: None, proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, + additional_permissions: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -6474,6 +6477,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { "world".into(), ])), proposed_network_policy_amendments: None, + additional_permissions: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -6533,6 +6537,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { "world".into(), ])), proposed_network_policy_amendments: None, + additional_permissions: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -6579,6 +6584,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), proposed_network_policy_amendments: None, + additional_permissions: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -6944,6 +6950,7 @@ async fn status_widget_and_approval_modal_snapshot() { "hello world".into(), ])), proposed_network_policy_amendments: None, + additional_permissions: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event {