From a57b398351a803c9ec94e38042bc82f527bed2a4 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 26 Aug 2026 21:16:11 +0000 Subject: [PATCH] Require approval for input to escalated terminals (#40978) ## What changed - Add the `write_stdin_approval` feature flag, disabled by default, to require a fresh approval before sending non-empty input to an escalated unified-exec terminal. - Route these reviews through hooks, Guardian, app-server, and the TUI as `writeStdin` approvals while keeping the parent command item unchanged. - Preserve the terminal environment, launch directory, and escalation state across turns, and revalidate the process identity after approval before writing input. ## Testing - Cover approval routing, policy decisions, process reuse, terminal identity preservation, app-server events, analytics, and TUI rendering. GitOrigin-RevId: 4ba29eaae6208e934737ba078c96e589c7c2164d --- .../analytics/src/analytics_client_tests.rs | 129 ++++++++----- codex-rs/analytics/src/reducer.rs | 17 +- .../app-server-exports-stable.json.zst | Bin 142587 -> 142623 bytes codex-rs/app-server/README.md | 2 + .../app-server/src/bespoke_event_handling.rs | 43 +++-- codex-rs/core/config.schema.json | 6 + .../core/src/guardian/approval_request.rs | 11 +- codex-rs/core/src/guardian/prompt.rs | 8 +- codex-rs/core/src/guardian/tests.rs | 2 + .../src/session/elicitation_holders_tests.rs | 4 +- codex-rs/core/src/session/mod.rs | 16 +- codex-rs/core/src/session/tests.rs | 2 +- codex-rs/core/src/tools/approvals.rs | 115 +++++++++++- codex-rs/core/src/tools/approvals_tests.rs | 1 - .../handlers/unified_exec/write_stdin.rs | 44 +++-- .../core/src/tools/runtimes/unified_exec.rs | 3 + codex-rs/core/src/tools/sandboxing.rs | 11 ++ codex-rs/core/src/tools/sandboxing_tests.rs | 8 + codex-rs/core/src/unified_exec/errors.rs | 2 + codex-rs/core/src/unified_exec/mod.rs | 4 + codex-rs/core/src/unified_exec/mod_tests.rs | 170 ++++++++++++++++-- .../core/src/unified_exec/process_manager.rs | 55 +++++- .../src/unified_exec/process_manager_tests.rs | 2 + codex-rs/core/tests/suite/network_approval.rs | 4 +- codex-rs/core/tests/suite/unified_exec.rs | 125 +++++++++++-- .../suite/unified_exec_zsh_fork_approvals.rs | 60 +++++-- codex-rs/features/src/lib.rs | 8 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 +- codex-rs/protocol/src/approvals.rs | 6 +- codex-rs/tui/src/app/thread_routing.rs | 1 + codex-rs/tui/src/approval_events.rs | 3 + .../tui/src/bottom_pane/approval_overlay.rs | 80 ++++++--- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/interrupts.rs | 1 + .../src/chatwidget/tests/approval_requests.rs | 40 +++++ .../tui/src/chatwidget/tests/exec_flow.rs | 7 + ..._requests__write_stdin_approval_modal.snap | 30 ++++ .../src/chatwidget/tests/status_and_layout.rs | 1 + .../src/chatwidget/tests/terminal_title.rs | 4 + codex-rs/tui/src/chatwidget/tool_requests.rs | 1 + 41 files changed, 861 insertions(+), 169 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__write_stdin_approval_modal.snap diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e6f91f1661..fa85905bd7 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -2808,58 +2808,89 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() { #[tokio::test] async fn command_execution_approval_response_publishes_user_review_event() { - let mut reducer = AnalyticsReducer::default(); - let mut events = Vec::new(); + for (kind, approval_id, subject, trigger) in [ + (None, None, "command_execution", "initial"), + ( + None, + Some("execve-approval"), + "command_execution", + "execve_intercept", + ), + ( + Some("writeStdin"), + Some("stdin-approval"), + "write_stdin", + "initial", + ), + ] { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); - ingest_review_prerequisites(&mut reducer, &mut events).await; - reducer - .ingest( - AnalyticsFact::ServerRequest { - connection_id: 7, - request: Box::new(sample_command_approval_request( - /*request_id*/ 41, /*approval_id*/ None, - )), - }, - &mut events, - ) - .await; - assert!(events.is_empty()); + ingest_review_prerequisites(&mut reducer, &mut events).await; + let mut request = serde_json::to_value(sample_command_approval_request( + /*request_id*/ 41, + approval_id, + )) + .expect("serialize approval request"); + // Missing kind models requests from older app-servers. + if let Some(kind) = kind { + request["params"]["kind"] = json!(kind); + } else { + request["params"].as_object_mut().unwrap().remove("kind"); + } + reducer + .ingest( + AnalyticsFact::ServerRequest { + connection_id: 7, + request: Box::new( + serde_json::from_value(request).expect("deserialize approval request"), + ), + }, + &mut events, + ) + .await; + assert!(events.is_empty()); - reducer - .ingest( - AnalyticsFact::ServerResponse { - completed_at_ms: 1_042, - response: Box::new(sample_command_approval_response( - /*request_id*/ 41, - CommandExecutionApprovalDecision::Accept, - )), - }, - &mut events, - ) - .await; + reducer + .ingest( + AnalyticsFact::ServerResponse { + completed_at_ms: 1_042, + response: Box::new(sample_command_approval_response( + /*request_id*/ 41, + CommandExecutionApprovalDecision::Accept, + )), + }, + &mut events, + ) + .await; - let payload = serde_json::to_value(&events).expect("serialize events"); - assert_eq!(payload.as_array().expect("events array").len(), 1); - assert_eq!(payload[0]["event_type"], "codex_review_event"); - assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); - assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1"); - assert_eq!(payload[0]["event_params"]["item_id"], "item-1"); - assert_eq!(payload[0]["event_params"]["review_id"], "user:41"); - assert_eq!(payload[0]["event_params"]["thread_source"], "user"); - assert_eq!( - payload[0]["event_params"]["subject_kind"], - "command_execution" - ); - assert_eq!( - payload[0]["event_params"]["subject_name"], - "command_execution" - ); - assert_eq!(payload[0]["event_params"]["reviewer"], "user"); - assert_eq!(payload[0]["event_params"]["trigger"], "initial"); - assert_eq!(payload[0]["event_params"]["status"], "approved"); - assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000); - assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_042); - assert_eq!(payload[0]["event_params"]["duration_ms"], 42); + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_review_event"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1"); + assert_eq!(payload[0]["event_params"]["item_id"], "item-1"); + assert_eq!(payload[0]["event_params"]["review_id"], "user:41"); + assert_eq!(payload[0]["event_params"]["thread_source"], "user"); + assert_eq!(payload[0]["event_params"]["subject_kind"], subject); + assert_eq!(payload[0]["event_params"]["subject_name"], subject); + assert_eq!(payload[0]["event_params"]["reviewer"], "user"); + assert_eq!(payload[0]["event_params"]["trigger"], trigger); + assert_eq!(payload[0]["event_params"]["status"], "approved"); + assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000); + assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_042); + assert_eq!(payload[0]["event_params"]["duration_ms"], 42); + + // Stdin reviews must not count toward the parent command's approval summary. + events.clear(); + ingest_completed_command_execution_item(&mut reducer, &mut events, "thread-1", "item-1") + .await; + let item = serde_json::to_value(&events[0]).expect("serialize tool item event"); + assert_eq!( + item["event_params"]["review_count"], + u64::from(kind.is_none()) + ); + } } async fn ingest_code_mode_facts( diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 2b4f7cd669..43c8918551 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -126,6 +126,7 @@ use codex_app_server_protocol::CollabAgentTool; use codex_app_server_protocol::CollabAgentToolCallStatus; use codex_app_server_protocol::CommandAction; use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionApprovalKind; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::DynamicToolCallOutputContentItem; @@ -1498,6 +1499,10 @@ impl AnalyticsReducer { fn ingest_server_request(&mut self, _connection_id: u64, request: ServerRequest) { match request { ServerRequest::CommandExecutionRequestApproval { request_id, params } => { + let is_stdin_review = match params.kind { + CommandExecutionApprovalKind::WriteStdin => true, + CommandExecutionApprovalKind::Command => false, + }; let is_network_access_review = params.network_approval_context.is_some(); let requested_network_access = is_network_access_review || params @@ -1511,7 +1516,9 @@ impl AnalyticsReducer { .and_then(|network| network.enabled) .unwrap_or(false); let requested_additional_permissions = params.additional_permissions.is_some(); - let trigger = if params.approval_id.is_some() { + let trigger = if is_stdin_review { + ReviewTrigger::Initial + } else if params.approval_id.is_some() { ReviewTrigger::ExecveIntercept } else if requested_network_access { ReviewTrigger::NetworkPolicyDenial @@ -1530,12 +1537,16 @@ impl AnalyticsReducer { turn_id: params.turn_id, item_id: Some(params.item_id), review_id: user_review_id(&request_id), - subject_kind: if is_network_access_review { + subject_kind: if is_stdin_review { + ReviewSubjectKind::WriteStdin + } else if is_network_access_review { ReviewSubjectKind::NetworkAccess } else { ReviewSubjectKind::CommandExecution }, - subject_name: if is_network_access_review { + subject_name: if is_stdin_review { + "write_stdin".to_string() + } else if is_network_access_review { "network_access".to_string() } else { "command_execution".to_string() diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 3680793d499aa133c0c5fb6e075bc8be15c7feb3..a78ce21eb9926c44505829b9b5af79097098390e 100644 GIT binary patch delta 15064 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 delta 15042 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 diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6a4c3c8fb4..07c9df0acf 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1822,6 +1822,8 @@ Order of messages: `kind` distinguishes command approvals from writes to an existing terminal. Requests from older servers without `kind` retain `command` semantics; `approvalId` alone does not distinguish stdin writes from execve interception. +When stdin approvals are enabled, a `write_stdin` approval sets `kind: "writeStdin"`, references the original terminal command's `itemId`, and has its own `approvalId`. The request belongs to the current turn, which may differ from the turn that opened the terminal. With `approvalsReviewer: "auto_review"`, the `item/autoApprovalReview/*` notifications likewise target the original command item and carry an action of type `writeStdin` with `approvalId`, `processId`, `stdin`, and `cwd`. For stdin approvals, `cwd` is the terminal’s launch directory, not its current working directory. Approving or denying a stdin write does not start, complete, or change the status of the parent command-execution item. + ### File change approvals Order of messages: diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f14d4792ed..b05d6f4d6e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -117,6 +117,7 @@ use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; @@ -630,26 +631,46 @@ pub(crate) async fn apply_bespoke_event_handling( parsed_cmd, .. } = ev; - let command_actions = parsed_cmd - .iter() - .cloned() - .map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &cwd)) - .collect::>(); + let cwd_uri = match PathUri::try_from(cwd.clone()) { + Ok(cwd) => cwd, + Err(err) => { + error!(%err, "invalid command approval cwd"); + if let Err(err) = conversation + .submit(Op::ExecApproval { + id: approval_id.unwrap_or(call_id), + turn_id: Some(turn_id), + decision: ReviewDecision::denied("invalid command approval cwd"), + }) + .await + { + error!(%err, "failed to reject invalid command approval"); + } + return; + } + }; + let command_presentation = + CommandExecutionPresentation::from_raw(&command, &parsed_cmd, &cwd_uri); + // Approval requests retain the exact command; only history is redacted. + let command_actions = match cwd_uri.to_abs_path() { + Ok(native_cwd) => parsed_cmd + .iter() + .cloned() + .map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &native_cwd)) + .collect(), + Err(_) => vec![V2ParsedCommand::Unknown { + command: shlex_join(&command), + }], + }; let presentation = if let Some(network_approval_context) = network_approval_context.map(V2NetworkApprovalContext::from) { CommandExecutionApprovalPresentation::Network(network_approval_context) } else { - let command_presentation = CommandExecutionPresentation::from_raw( - &command, - &parsed_cmd, - &cwd.clone().into(), - ); let completion_item = CommandExecutionCompletionItem { plugin_id, script_path, command: command_presentation.command, - cwd: cwd.clone().into(), + cwd: cwd.clone(), command_actions: command_presentation.command_actions, }; CommandExecutionApprovalPresentation::Command(completion_item) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index eca98d5cb1..0af689ee60 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -993,6 +993,9 @@ }, "workspace_owner_usage_nudge": { "type": "boolean" + }, + "write_stdin_approval": { + "type": "boolean" } }, "type": "object" @@ -6143,6 +6146,9 @@ }, "workspace_owner_usage_nudge": { "type": "boolean" + }, + "write_stdin_approval": { + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 37f67cb719..390bc25942 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -26,16 +26,10 @@ pub(crate) enum GuardianApprovalRequest { justification: Option, tty: bool, }, - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "Constructed by the follow-up stdin approval routing change" - ) - )] WriteStdin { id: String, approval_id: String, + environment_id: String, process_id: i32, input: String, cwd: PathUri, @@ -129,6 +123,7 @@ struct CommandApprovalAction<'a> { #[derive(Serialize)] struct WriteStdinApprovalAction<'a> { tool: &'static str, + environment_id: &'a str, session_id: i32, chars: &'a str, cwd: LegacyAppPathString, @@ -300,6 +295,7 @@ pub(crate) fn guardian_approval_request_to_json( Some(*tty), ), GuardianApprovalRequest::WriteStdin { + environment_id, process_id, input, cwd, @@ -307,6 +303,7 @@ pub(crate) fn guardian_approval_request_to_json( .. } => serialize_guardian_action(WriteStdinApprovalAction { tool: "write_stdin", + environment_id, session_id: *process_id, chars: input, cwd: cwd.clone().into(), diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 5e97f258f1..125228c619 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -313,10 +313,12 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn( push_text("Retry reason:\n".to_string()); push_text(format!("{reason}\n\n")); } - push_text( + let action_scope = if matches!(&request, GuardianApprovalRequest::WriteStdin { .. }) { + "Assess input to the existing terminal, not a fresh command. The `cwd` field is its launch directory; the terminal's current directory and state may have changed. Use the retained transcript and read-only checks when that state matters.\n" + } else { "Assess the exact planned action below. Use read-only tool checks when local state matters.\n" - .to_string(), - ); + }; + push_text(action_scope.to_string()); push_text("Planned action JSON:\n".to_string()); } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 34e019fcc6..f5694761c3 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1371,6 +1371,7 @@ fn guardian_write_stdin_preserves_input_and_foreign_cwd() -> serde_json::Result< let action = GuardianApprovalRequest::WriteStdin { id: "terminal-open".to_string(), approval_id: "terminal-write".to_string(), + environment_id: "windows-executor".to_string(), process_id: 1000, input: input.to_string(), cwd: cwd.clone(), @@ -1381,6 +1382,7 @@ fn guardian_write_stdin_preserves_input_and_foreign_cwd() -> serde_json::Result< guardian_approval_request_to_json(&action)?, serde_json::json!({ "tool": "write_stdin", + "environment_id": "windows-executor", "session_id": 1000, "chars": input, "cwd": r"C:\workspace", diff --git a/codex-rs/core/src/session/elicitation_holders_tests.rs b/codex-rs/core/src/session/elicitation_holders_tests.rs index 3d0b09cbf7..2f85db1eec 100644 --- a/codex-rs/core/src/session/elicitation_holders_tests.rs +++ b/codex-rs/core/src/session/elicitation_holders_tests.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use codex_core_plugins::PluginCommandAttribution; use codex_plugin::PluginId; +use codex_protocol::approvals::ExecApprovalKind; use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -50,11 +51,12 @@ async fn command_approval_holds_an_elicitation_until_response() { session .request_command_approval( turn_context.as_ref(), + ExecApprovalKind::Command, "call-1".to_string(), /*approval_id*/ None, /*environment_id*/ None, vec!["echo".to_string()], - cwd, + cwd.into(), /*reason*/ None, /*network_approval_context*/ None, /*proposed_execpolicy_amendment*/ None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d898ff969d..c48c016b61 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2475,11 +2475,12 @@ impl Session { pub async fn request_command_approval( &self, turn_context: &TurnContext, + kind: ExecApprovalKind, call_id: String, approval_id: Option, environment_id: Option, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, reason: Option, network_approval_context: Option, proposed_execpolicy_amendment: Option, @@ -2489,7 +2490,7 @@ impl Session { ) -> ReviewDecision { let _elicitation = self.services.elicitations.register(); // command-level approvals use `call_id`. - // `approval_id` is only present for subcommand callbacks (execve intercept) + // `approval_id` identifies subcommand callbacks and stdin writes. let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone()); // Add the tx_approve callback to the map before sending the request. let (tx_approve, rx_approve) = oneshot::channel(); @@ -2528,14 +2529,17 @@ impl Session { additional_permissions.as_ref(), ) }); - let plugin_attribution = plugin_attribution_override - .or_else(|| turn_context.plugin_attribution_for_command(&command, &cwd)); + let plugin_attribution = plugin_attribution_override.or_else(|| { + cwd.to_abs_path() + .ok() + .and_then(|cwd| turn_context.plugin_attribution_for_command(&command, &cwd)) + }); let (plugin_id, script_path) = plugin_attribution .as_ref() .map(PluginCommandAttribution::serialized_fields) .unzip(); let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - kind: ExecApprovalKind::Command, + kind, call_id, plugin_id, script_path, @@ -2544,7 +2548,7 @@ impl Session { environment_id, started_at_ms: now_unix_timestamp_ms(), command, - cwd, + cwd: cwd.into(), reason, network_approval_context, proposed_execpolicy_amendment, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b51db867c0..88ddab0c66 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8280,7 +8280,7 @@ async fn shutdown_and_wait_shuts_down_tracked_ephemeral_guardian_review() { .expect("ephemeral guardian review should receive a shutdown op"); } -async fn make_session_and_context_with_auth_and_config_and_rx( +pub(crate) async fn make_session_and_context_with_auth_and_config_and_rx( auth: CodexAuth, dynamic_tools: Vec, configure_config: F, diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index 91a90cb6ca..8fb75d7360 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -1,6 +1,7 @@ //! Central approval policy-stage execution and reviewer routing. use crate::command_canonicalization::canonicalize_command_for_approval; +use crate::exec_policy::prompt_is_rejected_by_policy; use crate::guardian::GuardianNetworkAccessTrigger; use crate::guardian::GuardianReviewContext; use crate::guardian::GuardianReviewOptions; @@ -25,6 +26,7 @@ use codex_analytics::GuardianApprovalRequestSource; use codex_config::types::AppToolApproval; use codex_hooks::PermissionRequestDecision; use codex_otel::ToolDecisionSource; +use codex_protocol::approvals::ExecApprovalKind; use codex_protocol::approvals::ExecPolicyAmendment; #[cfg(unix)] use codex_protocol::approvals::GuardianCommandSource; @@ -75,6 +77,15 @@ pub(crate) enum ApprovalAction { tty: bool, proposed_execpolicy_amendment: Option, }, + WriteStdin { + id: String, + approval_id: String, + environment_id: String, + process_id: i32, + input: String, + cwd: PathUri, + tty: bool, + }, #[cfg(unix)] Execve { id: String, @@ -152,6 +163,26 @@ impl ApprovalAction { justification, .. } => PermissionRequestPayload::bash(hook_command.clone(), justification.clone()), + Self::WriteStdin { + id, + approval_id, + environment_id, + process_id, + input, + cwd, + tty, + } => PermissionRequestPayload { + tool_name: HookToolName::new("write_stdin"), + tool_input: serde_json::json!({ + "session_id": process_id, + "chars": input, + "parent_call_id": id, + "approval_id": approval_id, + "environment_id": environment_id, + "cwd": cwd, + "tty": tty, + }), + }, #[cfg(unix)] Self::Execve { command, .. } => PermissionRequestPayload::bash( codex_shell_command::parse_command::shlex_join(command), @@ -216,7 +247,8 @@ impl ApprovalAction { Self::Execve { .. } => Vec::new(), Self::McpToolCall { .. } | Self::NetworkAccess { .. } - | Self::RequestPermissions { .. } => Vec::new(), + | Self::RequestPermissions { .. } + | Self::WriteStdin { .. } => Vec::new(), Self::ApplyPatch { environment_id, files, @@ -255,6 +287,23 @@ impl ApprovalAction { justification, tty, }, + Self::WriteStdin { + id, + approval_id, + environment_id, + process_id, + input, + cwd, + tty, + } => crate::guardian::GuardianApprovalRequest::WriteStdin { + id, + approval_id, + environment_id, + process_id, + input, + cwd, + tty, + }, #[cfg(unix)] Self::Execve { id, @@ -436,6 +485,16 @@ impl Session { action: ApprovalAction, ctx: ApprovalContext, ) -> Result { + // Stdin is a fresh sandbox approval, not an exec-policy cache hit. Strict + // review can route Never to Guardian, but granular restrictions still apply. + let policy = ctx.review_context.turn().approval_policy(); + if matches!(&action, ApprovalAction::WriteStdin { .. }) + && !(ctx.strict_auto_review && matches!(policy, AskForApproval::Never)) + && let Some(reason) = + prompt_is_rejected_by_policy(policy, /*prompt_is_rule*/ false) + { + return Err(ToolError::Rejected(reason.to_string())); + } let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. }); let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. }); let permission_request_run_id = match &action { @@ -532,6 +591,20 @@ impl Session { action: ApprovalAction, ctx: &ApprovalContext, ) -> ReviewDecision { + // Guardian inherits only the current turn's ready environments. A retained + // terminal handle may outlive its selection, but must not be reviewed in + // a different environment that happens to have the same launch directory. + if let ApprovalAction::WriteStdin { environment_id, .. } = &action + && !ctx + .review_context + .environments() + .turn_environments() + .any(|environment| environment.selection.environment_id == *environment_id) + { + return ReviewDecision::denied( + "automatic approval review cannot access the terminal's environment; select it before retrying", + ); + } let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. }); let review_id = new_guardian_review_id(); let action = match action.into_guardian_request() { @@ -650,11 +723,12 @@ impl Session { with_cached_approval(&self.services, tool_name, cache_keys, || async { self.request_command_approval( ctx.review_context.turn(), + ExecApprovalKind::Command, ctx.call_id.clone(), /*approval_id*/ None, Some(environment_id.clone()), command.clone(), - cwd, + cwd.into(), reason, ctx.network_approval_context.clone(), proposed_execpolicy_amendment.clone(), @@ -666,6 +740,37 @@ impl Session { }) .await } + ApprovalAction::WriteStdin { + id, + approval_id, + environment_id, + process_id, + input, + cwd, + .. + } => { + self.request_command_approval( + ctx.review_context.turn(), + ExecApprovalKind::WriteStdin, + id.clone(), + Some(approval_id.clone()), + Some(environment_id.clone()), + vec![ + "write_stdin".to_string(), + "--session-id".to_string(), + process_id.to_string(), + input.clone(), + ], + cwd.clone(), + ctx.approval_reason.clone(), + /*network_approval_context*/ None, + /*proposed_execpolicy_amendment*/ None, + /*additional_permissions*/ None, + Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]), + /*plugin_attribution_override*/ None, + ) + .await + } #[cfg(unix)] ApprovalAction::Execve { approval_id, @@ -677,11 +782,12 @@ impl Session { } => { self.request_command_approval( ctx.review_context.turn(), + ExecApprovalKind::Command, ctx.call_id.clone(), Some(approval_id.clone()), Some(environment_id.clone()), command.clone(), - cwd.clone(), + cwd.clone().into(), /*reason*/ None, /*network_approval_context*/ None, /*proposed_execpolicy_amendment*/ None, @@ -748,11 +854,12 @@ impl Session { } => { self.request_command_approval( ctx.review_context.turn(), + ExecApprovalKind::Command, ctx.call_id.clone(), /*approval_id*/ None, Some(environment_id.clone()), command.clone(), - cwd.clone(), + cwd.clone().into(), ctx.approval_reason.clone(), ctx.network_approval_context.clone(), /*proposed_execpolicy_amendment*/ None, diff --git a/codex-rs/core/src/tools/approvals_tests.rs b/codex-rs/core/src/tools/approvals_tests.rs index d5800e7846..10f05820f8 100644 --- a/codex-rs/core/src/tools/approvals_tests.rs +++ b/codex-rs/core/src/tools/approvals_tests.rs @@ -95,7 +95,6 @@ fn guardian_cwd_rejects_foreign_remote_path() { assert!(guardian_cwd(codex_exec_server::REMOTE_ENVIRONMENT_ID, cwd).is_err()); } - #[tokio::test] async fn explicit_mcp_reviewer_override_takes_precedence_over_action_context() { let (session, turn, events) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index 23be88be68..2a8d3921f0 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -7,6 +7,9 @@ use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; +use crate::tools::sandboxing::ToolError; +use crate::unified_exec::UnifiedExecContext; +use crate::unified_exec::UnifiedExecError; use crate::unified_exec::WriteStdinInteractionEvent; use crate::unified_exec::WriteStdinRequest; use codex_tools::ToolName; @@ -56,6 +59,9 @@ impl WriteStdinHandler { let ToolInvocation { session, turn, + step_context, + cancellation_token, + call_id, payload, .. } = invocation; @@ -70,23 +76,37 @@ impl WriteStdinHandler { }; let args: WriteStdinArgs = parse_arguments(&arguments)?; + let context = + UnifiedExecContext::new(session.clone(), step_context, cancellation_token, call_id); let response = session .services .unified_exec_manager - .write_stdin(WriteStdinRequest { - process_id: args.session_id, - input: &args.chars, - yield_time_ms: args.yield_time_ms, - max_output_tokens: args.max_output_tokens, - truncation_policy: turn.model_info().truncation_policy.into(), - interaction_event: Some(WriteStdinInteractionEvent { - session: &session, - turn: &turn, - }), - }) + .write_stdin( + &context, + WriteStdinRequest { + process_id: args.session_id, + input: &args.chars, + yield_time_ms: args.yield_time_ms, + max_output_tokens: args.max_output_tokens, + truncation_policy: turn.model_info().truncation_policy.into(), + interaction_event: Some(WriteStdinInteractionEvent { + session: &session, + turn: &turn, + }), + }, + ) .await .map_err(|err| { - FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) + let message = match err { + UnifiedExecError::StdinApproval(ToolError::Rejected(reason)) => { + format!("write_stdin rejected: {reason}") + } + UnifiedExecError::StdinApproval(ToolError::Codex(err)) => { + format!("write_stdin approval failed: {err}") + } + err => format!("write_stdin failed: {err}"), + }; + FunctionCallError::RespondToModel(message) })?; Ok(boxed_tool_output(response)) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index c1e1be80ab..1c89697568 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -106,6 +106,7 @@ pub struct UnifiedExecRuntime<'a> { pub(crate) struct UnifiedExecAttempt { pub(crate) process: UnifiedExecProcess, pub(crate) metrics_sidecar: Option, + pub(crate) escalated: bool, } fn unified_exec_options( @@ -475,6 +476,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt return Ok(UnifiedExecAttempt { process, metrics_sidecar, + escalated: attempt.is_escalated(), }); } None => { @@ -519,6 +521,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt Ok(UnifiedExecAttempt { process, metrics_sidecar, + escalated: attempt.is_escalated(), }) } } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 274497009d..195cf54e96 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -418,6 +418,17 @@ pub(crate) fn executor_windows_sandbox_level( } impl<'a> SandboxAttempt<'a> { + /// Whether this attempt bypasses sandboxing required by its ambient policy. + /// Use the requested policy, not the controller's wrapper, for remote executors. + pub(crate) fn is_escalated(&self) -> bool { + !self.sandbox_requested + && self.manager.should_sandbox( + self.permissions, + SandboxablePreference::Auto, + self.enforce_managed_network, + ) + } + pub(crate) fn network_proxy<'b>( &'b self, fallback: Option<&'b NetworkProxy>, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 5aff20e706..ba63e270eb 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -352,6 +352,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { let request = attempt .env_for_exec_server(command(), options()) .expect("prepare remote exec request"); + assert!(!attempt.is_escalated()); assert_eq!( request.command, @@ -390,8 +391,15 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { let request = attempt .env_for_exec_server(command(), options()) .expect("prepare unsandboxed remote exec request"); + assert!(attempt.is_escalated()); assert_eq!(request.exec_server_sandbox, None); assert!(!request.exec_server_enforce_managed_network); assert_eq!(request.exec_server_managed_network, Some(managed_network)); + + let full_access = codex_protocol::models::PermissionProfile::Disabled; + attempt.permissions = &full_access; + attempt.exec_server_permissions = &full_access; + attempt.enforce_managed_network = false; + assert!(!attempt.is_escalated(), "full access is not an escalation"); } diff --git a/codex-rs/core/src/unified_exec/errors.rs b/codex-rs/core/src/unified_exec/errors.rs index dd401046ae..8fa2ef7857 100644 --- a/codex-rs/core/src/unified_exec/errors.rs +++ b/codex-rs/core/src/unified_exec/errors.rs @@ -12,6 +12,8 @@ pub(crate) enum UnifiedExecError { // The model is trained on `session_id`, but internally we track a `process_id`. #[error("Unknown process id {process_id}")] UnknownProcessId { process_id: i32 }, + #[error("stdin approval failed: {0:?}")] + StdinApproval(crate::tools::sandboxing::ToolError), #[error("failed to write to stdin")] WriteToStdin, #[error( diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 3762b7f437..4f50e2dc4a 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -184,6 +184,10 @@ struct ProcessEntry { initial_exec_command_active: Arc, hook_command: String, tty: bool, + environment_id: String, + // The successful launch bypassed sandboxing required by its ambient policy. + // Preserve this across turns so subsequent stdin writes can require approval. + escalated: bool, network_approval: Option, session: Weak, last_used: tokio::time::Instant, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index b4f352d015..f70caaa059 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -141,6 +141,8 @@ async fn exec_command_with_tty( initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), hook_command: cmd.to_string(), tty, + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + escalated: false, network_approval: None, session: Arc::downgrade(session), last_used: started_at, @@ -304,6 +306,7 @@ async fn blocking_terminate_unified_process( async fn write_stdin( session: &Arc, + turn: &Arc, process_id: i32, input: &str, yield_time_ms: u64, @@ -311,14 +314,22 @@ async fn write_stdin( session .services .unified_exec_manager - .write_stdin(WriteStdinRequest { - process_id, - input, - yield_time_ms, - max_output_tokens: None, - truncation_policy: TruncationPolicy::Tokens(10_000), - interaction_event: None, - }) + .write_stdin( + &UnifiedExecContext::new( + Arc::clone(session), + crate::session::step_context::StepContext::for_test(Arc::clone(turn)), + tokio_util::sync::CancellationToken::new(), + "write".to_string(), + ), + WriteStdinRequest { + process_id, + input, + yield_time_ms, + max_output_tokens: None, + truncation_policy: TruncationPolicy::Tokens(10_000), + interaction_event: None, + }, + ) .await } @@ -347,6 +358,7 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { write_stdin( &session, + &turn, process_id, "export CODEX_INTERACTIVE_SHELL_VAR=codex\n", /*yield_time_ms*/ 2_500, @@ -355,6 +367,7 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { let out_2 = write_stdin( &session, + &turn, process_id, "echo $CODEX_INTERACTIVE_SHELL_VAR\n", /*yield_time_ms*/ 2_500, @@ -388,6 +401,7 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> { write_stdin( &session, + &turn, session_a, "export CODEX_INTERACTIVE_SHELL_VAR=codex\n", /*yield_time_ms*/ 2_500, @@ -416,6 +430,7 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> { let out_3 = write_stdin( &session, + &turn, shell_a.process_id.expect("expected process id"), "echo $CODEX_INTERACTIVE_SHELL_VAR\n", /*yield_time_ms*/ 2_500, @@ -447,6 +462,7 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> { write_stdin( &session, + &turn, process_id, format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(), /*yield_time_ms*/ 2_500, @@ -455,6 +471,7 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> { let out_2 = write_stdin( &session, + &turn, process_id, "sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n", /*yield_time_ms*/ 10, @@ -469,7 +486,7 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> { tokio::time::sleep(Duration::from_secs(7)).await; - let out_3 = write_stdin(&session, process_id, "", /*yield_time_ms*/ 100).await?; + let out_3 = write_stdin(&session, &turn, process_id, "", /*yield_time_ms*/ 100).await?; assert!( out_3 @@ -533,11 +550,14 @@ async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<( .await?; let process_id = open_shell.process_id.expect("expected process id"); - write_stdin(&session, process_id, "exit\n", /*yield_time_ms*/ 2_500).await?; + write_stdin( + &session, &turn, process_id, "exit\n", /*yield_time_ms*/ 2_500, + ) + .await?; tokio::time::sleep(Duration::from_millis(200)).await; - let err = write_stdin(&session, process_id, "", /*yield_time_ms*/ 100) + let err = write_stdin(&session, &turn, process_id, "", /*yield_time_ms*/ 100) .await .expect_err("expected unknown process error"); @@ -588,6 +608,8 @@ async fn terminating_initial_exec_command_rechecks_initial_response_state() -> a initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), hook_command: "sleep 60".to_string(), tty: true, + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + escalated: false, network_approval: None, session: Arc::downgrade(&session), last_used: Instant::now(), @@ -662,6 +684,8 @@ async fn terminating_during_stdin_poll_returns_exited_response() -> anyhow::Resu initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), hook_command: "sleep 60".to_string(), tty: true, + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + escalated: false, network_approval: None, session: Arc::downgrade(&session), last_used, @@ -670,8 +694,12 @@ async fn terminating_during_stdin_poll_returns_exited_response() -> anyhow::Resu let poll_task = tokio::spawn({ let session = Arc::clone(&session); + let turn = Arc::clone(&turn); async move { - write_stdin(&session, process_id, "", /*yield_time_ms*/ 60_000).await + write_stdin( + &session, &turn, process_id, "", /*yield_time_ms*/ 60_000, + ) + .await } }); tokio::time::timeout(Duration::from_secs(2), async { @@ -835,3 +863,121 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() ); Ok(()) } + +#[tokio::test] +async fn stdin_approval_preserves_the_reviewed_terminal() -> anyhow::Result<()> { + use crate::session::tests::make_session_and_context_with_auth_and_config_and_rx; + use crate::state::ActiveTurn; + use crate::tools::sandboxing::ToolError; + use codex_features::Feature; + use codex_protocol::config_types::ApprovalsReviewer; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::ReviewDecision; + + skip_if_sandbox!(Ok(())); + let (session, mut turn, events) = make_session_and_context_with_auth_and_config_and_rx( + codex_login::CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| { + config.features.enable(Feature::WriteStdinApproval).unwrap(); + config.permissions.approval_policy = + crate::config::Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + }, + ) + .await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let manager = &session.services.unified_exec_manager; + let command = "while IFS= read -r line; do printf 'received:%s\\n' \"$line\"; done"; + let opened = exec_command( + &session, &turn, command, /*yield_time_ms*/ 250, /*workdir*/ None, + ) + .await?; + let process_id = opened.process_id.expect("running terminal"); + let cwd = PathUri::parse("file:///C:/workspace")?; + let original = { + let mut store = manager.process_store.lock().await; + let entry = store.processes.get_mut(&process_id).unwrap(); + entry.escalated = true; + entry.environment_id = "unselected-executor".to_string(); + entry.cwd = cwd.clone(); + Arc::clone(&entry.process) + }; + // Empty polling must complete without an approval response. + tokio::time::timeout( + Duration::from_secs(/*secs*/ 5), + write_stdin(&session, &turn, process_id, "", /*yield_time_ms*/ 250), + ) + .await??; + let input = "rejected\n"; + let denied = write_stdin( + &session, &turn, process_id, input, /*yield_time_ms*/ 250, + ) + .await; + assert!( + matches!(denied, Err(UnifiedExecError::StdinApproval(ToolError::Rejected(reason))) + if reason.contains("select it before retrying")) + ); + Arc::make_mut(&mut Arc::get_mut(&mut turn).unwrap().config).approvals_reviewer = + ApprovalsReviewer::User; + for (input, decision) in [ + ("rejected\n", ReviewDecision::denied("test denial")), + ("accepted\n", ReviewDecision::Approved), + ("replace\n", ReviewDecision::Approved), + ] { + let review = async { + loop { + let EventMsg::ExecApprovalRequest(approval) = events.recv().await?.msg else { + continue; + }; + assert_eq!( + ( + approval.call_id.as_str(), + approval.approval_id.as_deref(), + approval.cwd + ), + ("call", Some("write"), cwd.clone().into()) + ); + assert!(original.interaction_lock().try_lock_owned().is_err()); + if input == "replace\n" { + let replacement = super::process_tests::remote_process( + WriteStatus::Accepted, + /*terminate_error*/ None, + SandboxType::None, + ) + .await; + let mut store = manager.process_store.lock().await; + store.processes.get_mut(&process_id).unwrap().process = Arc::new(replacement); + } + session.notify_approval("write", decision).await; + return anyhow::Ok(()); + } + }; + let (result, reviewed) = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + tokio::join!( + write_stdin( + &session, &turn, process_id, input, /*yield_time_ms*/ 250 + ), + review + ) + }) + .await?; + reviewed?; + match input { + "rejected\n" => assert!(matches!(result, Err(UnifiedExecError::StdinApproval(_)))), + "accepted\n" => { + let output = result?.truncated_output(DEFAULT_MAX_OUTPUT_TOKENS); + assert!(output.contains("received:accepted"), "{output}"); + assert!(!output.contains("received:rejected"), "{output}"); + } + _ => assert!( + matches!(result, Err(UnifiedExecError::UnknownProcessId { process_id: id }) if id == process_id) + ), + } + assert!(original.interaction_lock().try_lock_owned().is_ok()); + } + original.terminate(); + assert!(session.terminate_background_terminal(process_id).await); + Ok(()) +} diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 1bbd73b7a2..2558833b01 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -20,10 +20,12 @@ use crate::exec_env::inject_apply_patch_env; use crate::exec_env::inject_permission_profile_env; use crate::exec_env::inject_session_id_env; use crate::exec_policy::ExecApprovalRequest; +use crate::guardian::GuardianReviewContext; use crate::plugins::metrics::finish_and_track_measurements; use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::ExecServerEnvConfig; +use crate::tools::ApprovalContext; use crate::tools::context::ExecCommandToolOutput; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; @@ -35,6 +37,7 @@ use crate::tools::runtimes::is_managed_proxy_env_var; use crate::tools::runtimes::unified_exec::UnifiedExecAttempt; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; +use crate::tools::sandboxing::ApprovalAction; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; @@ -66,6 +69,7 @@ use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR; use codex_core_plugins::PluginCommandAttribution; use codex_core_plugins::PluginMetricsSidecar; use codex_core_plugins::strip_output_env; +use codex_features::Feature; use codex_network_proxy::NetworkPolicyDecider; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::ShellEnvironmentPolicy; @@ -485,6 +489,7 @@ impl UnifiedExecProcessManager { let UnifiedExecAttempt { process, metrics_sidecar, + escalated, } = attempt; let process = Arc::new(process); let network_denial_monitor = deferred_network_approval.as_ref().map(|deferred| { @@ -543,6 +548,8 @@ impl UnifiedExecProcessManager { &request.command, request.hook_command.clone(), cwd.clone(), + request.turn_environment.selection.environment_id.clone(), + escalated, plugin_attribution.clone(), start, request.process_id, @@ -757,6 +764,7 @@ impl UnifiedExecProcessManager { pub(crate) async fn write_stdin( &self, + context: &UnifiedExecContext, request: WriteStdinRequest<'_>, ) -> Result { let process_id = request.process_id; @@ -764,16 +772,55 @@ impl UnifiedExecProcessManager { // Different terminal sessions can be polled concurrently, but reads and // writes against one terminal must not overlap because they share a // draining output buffer and process lifecycle. - let locked_process = { + let (locked_process, approval) = { let store = self.process_store.lock().await; let entry = store .processes .get(&process_id) .ok_or(UnifiedExecError::UnknownProcessId { process_id })?; - Arc::clone(&entry.process) + let approval = (!request.input.is_empty() + && (entry.tty || request.input != INTERRUPT) + && entry.escalated + && context + .session + .features() + .enabled(Feature::WriteStdinApproval)) + .then(|| ApprovalAction::WriteStdin { + id: entry.call_id.clone(), + approval_id: context.call_id.clone(), + environment_id: entry.environment_id.clone(), + process_id, + input: request.input.to_string(), + cwd: entry.cwd.clone(), + tty: entry.tty, + }); + (Arc::clone(&entry.process), approval) }; let _interaction_guard = locked_process.interaction_lock().lock_owned().await; + if let Some(approval) = approval { + let strict_auto_review = context + .session + .active_turn_context_and_strict_auto_review() + .await + .is_some_and(|(_, _, strict)| strict); + let approval_context = ApprovalContext { + review_context: GuardianReviewContext::from(&context.step_context), + cancellation_token: Some(context.cancellation_token.clone()), + call_id: context.call_id.clone(), + tool_name: ToolName::plain("write_stdin"), + strict_auto_review, + approval_reason: Some("Send input to an existing escalated terminal. The cwd is its launch directory; the terminal's current directory and state may have changed.".to_string()), + retry_reason: None, + network_approval_context: None, + }; + context + .session + .request_approval(approval, approval_context) + .await + .map_err(UnifiedExecError::StdinApproval)?; + } + // Revalidate the identity after approval: a removed process ID can be reused. let PreparedProcessHandles { process, output, @@ -1003,6 +1050,8 @@ impl UnifiedExecProcessManager { command: &[String], hook_command: String, cwd: PathUri, + environment_id: String, + escalated: bool, plugin_attribution: Option, started_at: Instant, process_id: i32, @@ -1024,6 +1073,8 @@ impl UnifiedExecProcessManager { initial_exec_command_active, hook_command, tty, + environment_id, + escalated, network_approval, session: Arc::downgrade(&context.session), last_used: started_at, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index a73db81887..5db327ecea 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -589,6 +589,8 @@ async fn pruning_does_not_evict_live_process_while_exited_process_is_finalizing( initial_exec_command_active: Arc::new(AtomicBool::new(false)), hook_command: format!("command-{process_id}"), tty: false, + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + escalated: false, network_approval: None, session: std::sync::Weak::new(), last_used: if is_exited { diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 8414303c88..5651c6f825 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -887,7 +887,7 @@ async fn user_network_approval_once_session_and_denial_semantics() -> Result<()> assert_eq!(approval.approval_id.as_deref(), None); let first_approval_call_id = approval.call_id.clone(); assert!(!approval.turn_id.is_empty()); - assert_eq!(approval.cwd, test.config.cwd); + assert_eq!(approval.cwd, test.config.cwd.clone().into()); assert_eq!( approval.reason.as_deref(), Some("codex-network-test.invalid is not in the allowed_domains") @@ -1387,7 +1387,7 @@ async fn unattributed_network_request_uses_active_turn_environment_fallback() -> let proxy_request = tokio::spawn(raw_http_proxy_request(proxy_addr, NETWORK_TEST_HOST)); let approval = expect_network_approval(&test, LOCAL_ENVIRONMENT_ID).await?; assert_eq!(approval.command, ["network-access", NETWORK_TEST_TARGET]); - assert_eq!(approval.cwd, test.config.cwd); + assert_eq!(approval.cwd, test.config.cwd.clone().into()); test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 0466fd18aa..eb32aa9794 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1,6 +1,10 @@ use codex_core::EnvironmentConfig; use codex_core::TurnInputRequest; use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_features::Feature; +use codex_protocol::approvals::ExecApprovalKind; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; use core_test_support::test_codex::local_selections; use std::collections::HashMap; use std::ffi::OsStr; @@ -1347,8 +1351,12 @@ async fn wait_for_unified_exec_end( (end_event, turn_completed) } +#[test_case::test_case(false; "without_stdin_approval")] +#[test_case::test_case(true; "with_stdin_approval")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> { +async fn unified_exec_emits_terminal_interaction_for_write_stdin( + stdin_approval: bool, +) -> Result<()> { // TODO(anp): Remove after unified-exec interactive fixtures support Windows/ConPTY. skip_if_target_windows!(Ok(()), "uses POSIX interactive-process and EOF semantics"); skip_if_no_network!(Ok(())); @@ -1356,15 +1364,25 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> let server = start_mock_server().await; - let mut builder = test_codex(); + let mut builder = test_codex().with_config(move |config| { + if stdin_approval { + config + .features + .enable(Feature::WriteStdinApproval) + .expect("enable stdin approvals"); + } + }); let test = builder.build_with_auto_env(&server).await?; let open_call_id = "uexec-open"; - let open_args = json!({ + let mut open_args = json!({ "cmd": "/bin/bash -i", "yield_time_ms": 200, "tty": true, }); + if stdin_approval { + open_args["sandbox_permissions"] = json!("require_escalated"); + } let stdin_call_id = "uexec-stdin-delta"; let stdin_args = json!({ @@ -1400,13 +1418,43 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "stdin delta", PermissionProfile::Disabled).await?; + if stdin_approval { + // Start without waiting for completion so the loop below can answer approvals. + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "stdin delta".to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::OnRequest), + sandbox_policy: Some(SandboxPolicy::ReadOnly { + network_access: false, + }), + ..Default::default() + }), + ) + .await?; + } else { + submit_unified_exec_turn(&test, "stdin delta", PermissionProfile::Disabled).await?; + } let mut terminal_interaction = None; + let mut approvals = Vec::new(); loop { let msg = wait_for_event(&test.codex, |_| true).await; match msg { + EventMsg::ExecApprovalRequest(approval) => { + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: Some(approval.turn_id), + decision: ReviewDecision::Approved, + }) + .await?; + approvals.push((approval.kind, approval.call_id, approval.approval_id)); + } EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { terminal_interaction = Some(ev); } @@ -1415,6 +1463,21 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> } } + assert_eq!( + approvals, + if stdin_approval { + vec![ + (ExecApprovalKind::Command, open_call_id.to_string(), None), + ( + ExecApprovalKind::WriteStdin, + open_call_id.to_string(), + Some(stdin_call_id.to_string()), + ), + ] + } else { + Vec::new() + } + ); let delta = terminal_interaction.expect("expected TerminalInteraction event"); assert_eq!(delta.process_id, "1000"); let expected_stdin = stdin_args @@ -2370,7 +2433,12 @@ async fn assert_write_stdin_ctrl_c_interrupts_non_tty_session( let server = start_mock_server().await; - let mut builder = test_codex(); + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::WriteStdinApproval) + .expect("enable stdin approvals"); + }); let test = builder.build_with_auto_env(&server).await?; let start_call_id = format!("uexec-non-tty-interrupt-{test_name}-start"); @@ -2380,6 +2448,7 @@ async fn assert_write_stdin_ctrl_c_interrupts_non_tty_session( "cmd": command, "yield_time_ms": 250, "tty": false, + "sandbox_permissions": "require_escalated", }); let interrupt_args = serde_json::json!({ "chars": "\u{3}", @@ -2413,17 +2482,43 @@ async fn assert_write_stdin_ctrl_c_interrupts_non_tty_session( ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "interrupt non-tty unified exec", - PermissionProfile::Disabled, - ) - .await?; + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "interrupt non-tty unified exec".to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::OnRequest), + sandbox_policy: Some(SandboxPolicy::ReadOnly { + network_access: false, + }), + ..Default::default() + }), + ) + .await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; + let mut approval_count = 0; + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::ExecApprovalRequest(approval) => { + approval_count += 1; + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: Some(approval.turn_id), + decision: ReviewDecision::Approved, + }) + .await?; + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + assert_eq!( + approval_count, 1, + "only process launch should need approval" + ); let requests = request_log.requests(); assert!(!requests.is_empty(), "expected at least one POST request"); diff --git a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs index 218e228093..cbef67fbb0 100644 --- a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs +++ b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs @@ -5,6 +5,7 @@ use codex_config::permissions_toml::PermissionProfileToml; use codex_config::types::ApprovalsReviewer; use codex_core::TurnInputRequest; use codex_core::sandboxing::SandboxPermissions; +use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; @@ -40,9 +41,9 @@ use core_test_support::test_codex::local_selections; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; -use core_test_support::zsh_fork::build_zsh_fork_test; use core_test_support::zsh_fork::restrictive_workspace_write_profile; use core_test_support::zsh_fork::zsh_fork_runtime; +use core_test_support::zsh_fork::zsh_fork_test_builder; use pretty_assertions::assert_eq; use regex_lite::Regex; use serde_json::Value; @@ -517,9 +518,14 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t ev_completed("resp-cross-turn-write"), ]), sse(vec![ - ev_response_created("resp-cross-turn-guardian"), - ev_assistant_message("msg-cross-turn-guardian", r#"{"outcome":"allow"}"#), - ev_completed("resp-cross-turn-guardian"), + ev_response_created("resp-cross-turn-write-guardian"), + ev_assistant_message("msg-cross-turn-write-guardian", r#"{"outcome":"allow"}"#), + ev_completed("resp-cross-turn-write-guardian"), + ]), + sse(vec![ + ev_response_created("resp-cross-turn-execve-guardian"), + ev_assistant_message("msg-cross-turn-execve-guardian", r#"{"outcome":"allow"}"#), + ev_completed("resp-cross-turn-execve-guardian"), ]), sse(vec![ ev_response_created("resp-cross-turn-second-done"), @@ -555,6 +561,7 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t .await?; let mut current_turn_id = None; + let mut stdin_assessment = None; let mut intercepted_assessment = None; loop { let event = tokio::time::timeout(Duration::from_secs(30), test.codex.next_event()) @@ -562,6 +569,16 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t .context("timed out waiting for current-turn intercepted execve Guardian review")??; match event.msg { EventMsg::TurnStarted(started) => current_turn_id = Some(started.turn_id), + EventMsg::GuardianAssessment(assessment) + if assessment.status == GuardianAssessmentStatus::Approved + && matches!( + &assessment.action, + GuardianAssessmentAction::WriteStdin { approval_id, .. } + if approval_id == write_call_id + ) => + { + stdin_assessment = Some(assessment); + } EventMsg::GuardianAssessment(assessment) if assessment.status == GuardianAssessmentStatus::Approved && matches!(assessment.action, GuardianAssessmentAction::Execve { .. }) => @@ -578,6 +595,15 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t let current_turn_id = current_turn_id.context("expected the second turn to start")?; assert_ne!(current_turn_id, first_completion.turn_id); + let stdin_assessment = + stdin_assessment.context("expected an approved Guardian assessment for stdin")?; + assert_eq!( + ( + stdin_assessment.turn_id.as_str(), + stdin_assessment.target_item_id.as_deref(), + ), + (current_turn_id.as_str(), Some(open_call_id)), + ); let assessment = intercepted_assessment .context("expected an approved Guardian assessment for the persistent terminal")?; assert_eq!(assessment.turn_id, current_turn_id); @@ -594,8 +620,13 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian") }) .collect::>(); - assert_eq!(guardian_requests.len(), 1); + assert_eq!(guardian_requests.len(), 2); assert!(guardian_requests[0].body_contains_text(&outside_path.to_string_lossy())); + let environment_id = &test.executor_environment().selection().environment_id; + let environment = format!(r#""environment_id": "{environment_id}""#); + assert!(guardian_requests[0].body_contains_text(&environment)); + assert!(guardian_requests[0].body_contains_text("The `cwd` field is its launch directory")); + assert!(guardian_requests[1].body_contains_text(&outside_path.to_string_lossy())); Ok(()) } @@ -619,14 +650,17 @@ where }; let server = start_mock_server().await; - let test = build_zsh_fork_test( - &server, - runtime, - approval_policy, - permission_profile, - pre_build_hook, - ) - .await?; + let test = zsh_fork_test_builder(runtime, approval_policy) + .with_pre_build_hook(pre_build_hook) + .with_config(move |config| { + config + .permissions + .set_permission_profile(permission_profile) + .expect("set permission profile"); + assert!(config.features.enable(Feature::WriteStdinApproval).is_ok()); + }) + .build(&server) + .await?; Ok(Some((server, test))) } diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 80cc9fac25..3a520f0198 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -137,6 +137,8 @@ pub enum Feature { ApplyPatchPreserveLineEndings, /// Allow exec tools to request additional permissions while staying sandboxed. ExecPermissionApprovals, + /// Require approval before writing input to escalated unified-exec terminals. + WriteStdinApproval, /// Expose the built-in request_permissions tool. RequestPermissionsTool, /// Allow the model to request web searches that fetch live content. @@ -1079,6 +1081,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::WriteStdinApproval, + key: "write_stdin_approval", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::CodexHooks, key: "hooks", diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index a097b7800e..e96526deb5 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -217,7 +217,7 @@ async fn run_codex_tool_session_inner( } = ev; handle_exec_approval_request( command, - cwd.to_path_buf(), + std::path::PathBuf::from(cwd.into_string()), outgoing.clone(), thread.clone(), request_id.clone(), diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 0dd621ff41..e4dedf5347 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -6,6 +6,7 @@ use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::request_permissions::RequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; @@ -152,6 +153,7 @@ pub enum GuardianAssessmentAction { approval_id: String, process_id: String, stdin: String, + /// Launch directory of the existing terminal, not its current working directory. cwd: PathUri, }, ApplyPatch { @@ -279,8 +281,8 @@ pub struct ExecApprovalRequestEvent { pub started_at_ms: i64, /// The command to be executed. pub command: Vec, - /// The command's working directory. - pub cwd: AbsolutePathBuf, + /// The command's working directory, or the launch directory for terminal input. + pub cwd: LegacyAppPathString, /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index e253ce8ef5..8b5174f0d7 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -220,6 +220,7 @@ impl App { let proposed_network_policy_amendments = params.proposed_network_policy_amendments.clone(); let approval = ExecApprovalRequest { + kind: params.kind, thread_id, thread_label, id: params diff --git a/codex-rs/tui/src/approval_events.rs b/codex-rs/tui/src/approval_events.rs index ba183225b8..ea8146b90f 100644 --- a/codex-rs/tui/src/approval_events.rs +++ b/codex-rs/tui/src/approval_events.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use crate::diff_model::FileChange; use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionApprovalKind; use codex_app_server_protocol::ExecPolicyAmendment; use codex_app_server_protocol::NetworkApprovalContext; use codex_app_server_protocol::NetworkPolicyAmendment; @@ -21,6 +22,8 @@ use serde::Serialize; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ExecApprovalRequestEvent { + #[serde(default)] + pub(crate) kind: CommandExecutionApprovalKind, pub(crate) call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) approval_id: Option, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index c561dde317..836cd1dba5 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -40,6 +40,7 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionApprovalKind; use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileSystemAccessMode; use codex_app_server_protocol::FileSystemPath; @@ -79,6 +80,7 @@ pub(crate) enum ApprovalRequest { #[derive(Clone, Debug)] pub(crate) struct ExecApprovalRequest { + pub kind: CommandExecutionApprovalKind, pub thread_id: ThreadId, pub thread_label: Option, pub id: String, @@ -250,23 +252,35 @@ impl ApprovalOverlay { list_keymap: &ListKeymap, ) -> (Vec, SelectionViewParams) { let (options, title) = match request { - ApprovalRequest::Exec(request) => ( - exec_options( - &request.available_decisions, - request.network_approval_context.as_ref(), - request.additional_permissions.as_ref(), - approval_keymap, - ), - request.network_approval_context.as_ref().map_or_else( - || "Would you like to run the following command?".to_string(), - |network_approval_context| { - format!( - "Do you want to approve network access to \"{}\"?", - network_approval_context.host - ) - }, - ), - ), + ApprovalRequest::Exec(request) => { + let title = if request.kind == CommandExecutionApprovalKind::WriteStdin { + request.command.get(2).map_or_else( + || "Would you like to send input to the existing terminal?".to_string(), + |process_id| { + format!("Would you like to send input to terminal {process_id}?") + }, + ) + } else { + request.network_approval_context.as_ref().map_or_else( + || "Would you like to run the following command?".to_string(), + |network_approval_context| { + format!( + "Do you want to approve network access to \"{}\"?", + network_approval_context.host + ) + }, + ) + }; + ( + exec_options( + &request.available_decisions, + request.network_approval_context.as_ref(), + request.additional_permissions.as_ref(), + approval_keymap, + ), + title, + ) + } ApprovalRequest::Permissions(_) => ( permissions_options(approval_keymap), "Would you like to grant these permissions?".to_string(), @@ -704,13 +718,18 @@ fn build_header(request: &ApprovalRequest) -> Box { ])); header.push(Line::from("")); } - let full_cmd = strip_bash_lc_and_escape(&request.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 request.network_approval_context.is_none() { - header.extend(full_cmd_lines); + if request.kind == CommandExecutionApprovalKind::WriteStdin { + let input = request.command.last().map_or("", String::as_str); + header.push(vec!["Input: ".into(), format!("{input:?}").into()].into()); + } else { + let full_cmd = strip_bash_lc_and_escape(&request.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 request.network_approval_context.is_none() { + header.extend(full_cmd_lines); + } } Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) } @@ -1187,6 +1206,7 @@ mod tests { fn make_exec_request() -> ApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), @@ -1380,6 +1400,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), @@ -1424,6 +1445,7 @@ mod tests { }; let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), @@ -1509,6 +1531,7 @@ mod tests { let thread_id = ThreadId::new(); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id, thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), @@ -1544,6 +1567,7 @@ mod tests { keymap.approval.open_thread = vec![key_hint::plain(KeyCode::Char('x'))]; let mut view = make_overlay_with_keymap( ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id, thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), @@ -1583,6 +1607,7 @@ mod tests { let tx = AppEventSender::new(tx); let view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), @@ -1612,6 +1637,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), @@ -1665,6 +1691,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), @@ -1705,6 +1732,7 @@ mod tests { let tx = AppEventSender::new(tx); let command = vec!["echo".into(), "hello".into(), "world".into()]; let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), @@ -2006,6 +2034,7 @@ mod tests { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), @@ -2063,6 +2092,7 @@ mod tests { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), @@ -2188,6 +2218,7 @@ mod tests { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), @@ -2325,6 +2356,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ded10d319a..592c835e20 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -2011,6 +2011,7 @@ mod tests { fn exec_request() -> ApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest { + kind: Default::default(), thread_id: codex_protocol::ThreadId::new(), thread_label: None, id: "1".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4fee2ad703..b5215116e6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -869,6 +869,7 @@ fn exec_approval_request_from_params( .and_then(|cwd| cwd.to_inferred_abs_path()) .unwrap_or_else(|| fallback_cwd.clone()); ExecApprovalRequestEvent { + kind: params.kind, call_id: params.item_id, command: params .command diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 35a9cb56d9..951077dd9b 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -176,6 +176,7 @@ mod tests { fn exec_approval(call_id: &str, approval_id: Option<&str>) -> ExecApprovalRequestEvent { ExecApprovalRequestEvent { + kind: Default::default(), call_id: call_id.to_string(), approval_id: approval_id.map(str::to_string), turn_id: "turn".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index 6e6aa7d16b..2d33e50c74 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -10,6 +10,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { // Trigger an exec approval request with a short, single-line command. let ev = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-short".into(), approval_id: Some("call-short".into()), turn_id: "turn-short".into(), @@ -86,6 +87,42 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { ); } +#[tokio::test] +async fn app_server_write_stdin_approval_renders_terminal_input() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let event = exec_approval_request_from_params( + AppServerCommandExecutionRequestApprovalParams { + kind: codex_app_server_protocol::CommandExecutionApprovalKind::WriteStdin, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + started_at_ms: 0, + approval_id: Some("approval-1".to_string()), + environment_id: None, + reason: None, + network_approval_context: None, + command: Some( + shlex::try_join(["write_stdin", "--session-id", "42", "confirm\n"]) + .expect("round-trippable write_stdin input"), + ), + cwd: Some(test_path_buf("/tmp").abs().into()), + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + &test_path_buf("/tmp").abs(), + ); + + handle_exec_approval_request(&mut chat, "approval-1", event); + + let area = Rect::new(0, 0, 80, chat.desired_height(/*width*/ 80)); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + assert_snapshot!("write_stdin_approval_modal", format!("{buf:?}")); +} + #[test] fn app_server_exec_approval_request_preserves_permissions_context() { let read_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/read-only"))) @@ -338,6 +375,7 @@ async fn exec_approval_uses_approval_id_when_present() { &mut chat, "sub-short", ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-parent".into(), approval_id: Some("approval-subcommand".into()), turn_id: "turn-short".into(), @@ -381,6 +419,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let ev_multi = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-multi".into(), approval_id: Some("call-multi".into()), turn_id: "turn-multi".into(), @@ -433,6 +472,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { let long = format!("echo {}", "a".repeat(200)); let ev_long = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-long".into(), approval_id: Some("call-long".into()), turn_id: "turn-long".into(), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 662bd76bab..4b8f1b5258 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -430,6 +430,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { // Trigger an exec approval request with a short, single-line command let ev = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-short".into(), approval_id: Some("call-short".into()), turn_id: "turn-short".into(), @@ -522,6 +523,7 @@ async fn exec_approval_uses_approval_id_when_present() { &mut chat, "sub-short", ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-parent".into(), approval_id: Some("approval-subcommand".into()), turn_id: "turn-short".into(), @@ -566,6 +568,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { // Multiline command: modal should show full command, history records decision only let ev_multi = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-multi".into(), approval_id: Some("call-multi".into()), turn_id: "turn-multi".into(), @@ -620,6 +623,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { // Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ... let long = format!("echo {}", "a".repeat(200)); let ev_long = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-long".into(), approval_id: Some("call-long".into()), turn_id: "turn-long".into(), @@ -1552,6 +1556,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { .set(AskForApproval::OnRequest.to_core())?; // Inject an exec approval request to display the approval modal. let ev = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-approve-cmd".into(), approval_id: Some("call-approve-cmd".into()), turn_id: "turn-approve-cmd".into(), @@ -1610,6 +1615,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { .set(AskForApproval::OnRequest.to_core())?; let ev = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-approve-cmd-noreason".into(), approval_id: Some("call-approve-cmd-noreason".into()), turn_id: "turn-approve-cmd-noreason".into(), @@ -1656,6 +1662,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() let script = "python - <<'PY'\nprint('hello')\nPY".to_string(); let command = vec!["bash".into(), "-lc".into(), script]; let ev = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-approve-cmd-multiline-trunc".into(), approval_id: Some("call-approve-cmd-multiline-trunc".into()), turn_id: "turn-approve-cmd-multiline-trunc".into(), diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__write_stdin_approval_modal.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__write_stdin_approval_modal.snap new file mode 100644 index 0000000000..e0108c8d80 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__write_stdin_approval_modal.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/chatwidget/tests/approval_requests.rs +assertion_line: 123 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 10 }, + content: [ + " ", + " ", + " Would you like to send input to terminal 42? ", + " ", + " Input: "confirm\n" ", + " ", + "› 1. Yes, proceed (y) ", + " 2. No, and tell Codex what to do differently (esc) ", + " ", + " Press enter to confirm or esc to cancel ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index bb65b85b56..a283433b1f 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2640,6 +2640,7 @@ async fn status_widget_and_approval_modal_snapshot() { // Now show an approval modal (e.g. exec approval). let ev = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-approve-exec".into(), approval_id: Some("call-approve-exec".into()), turn_id: "turn-approve-exec".into(), diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index 8a7a438a6c..0d7d089001 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -81,6 +81,7 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { ); let request = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-action-required".into(), approval_id: Some("call-action-required".into()), turn_id: "turn-action-required".into(), @@ -146,6 +147,7 @@ async fn terminal_title_action_required_respects_spinner_setting() { chat.refresh_terminal_title(); let request = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-no-spinner".into(), approval_id: Some("call-no-spinner".into()), turn_id: "turn-no-spinner".into(), @@ -174,6 +176,7 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() { chat.refresh_terminal_title(); let request = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-blink".into(), approval_id: Some("call-blink".into()), turn_id: "turn-blink".into(), @@ -212,6 +215,7 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d assert!(!chat.should_animate_terminal_title_spinner()); let request = ExecApprovalRequestEvent { + kind: Default::default(), call_id: "call-no-animations".into(), approval_id: Some("call-no-animations".into()), turn_id: "turn-no-animations".into(), diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index eb549a4154..cf01c394ff 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -289,6 +289,7 @@ impl ChatWidget { let available_decisions = ev.effective_available_decisions(); let request = ApprovalRequest::Exec(ExecApprovalRequest { + kind: ev.kind, thread_id: self.thread_id.unwrap_or_default(), thread_label: None, id: ev.effective_approval_id(),