From fcf636a41dbcd8372ad64301b7092621a155747b Mon Sep 17 00:00:00 2001 From: Alec Barber Date: Tue, 4 Aug 2026 13:11:55 +0000 Subject: [PATCH] Redact secrets from app-server command execution items (#36893) ## Why Client-facing command execution items can include recognizable secrets in the rendered command or parsed command actions. ## What changed - Redact secrets in `commandExecution.command` and `commandActions` for live items, completed items, and replayed thread history. - Keep command approval requests backed by the original executable command and parsed actions while using the redacted representation for display items. - Document that execution item commands are redacted display values rather than executable commands. ## Testing - Cover redaction for command conversion, parsed search actions, legacy replay, approval flows, completion notifications, and rejected commands. GitOrigin-RevId: 2ad056f21882bf5166182ad1a4ff0bf6471d9c0e --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-protocol/Cargo.toml | 1 + .../src/protocol/item_builders.rs | 57 ++++++++---- .../src/protocol/item_builders_tests.rs | 12 +++ .../src/protocol/thread_history.rs | 69 ++++++++++++--- .../src/protocol/v2/item.rs | 46 +++++----- .../src/protocol/v2/tests.rs | 19 +++- codex-rs/app-server/README.md | 2 +- .../app-server/src/bespoke_event_handling.rs | 15 ++-- .../app-server/tests/suite/v2/turn_start.rs | 86 ++++++++++++++++--- 10 files changed, 238 insertions(+), 70 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8677251bb5..275349fbef 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2141,6 +2141,7 @@ dependencies = [ "codex-experimental-api-macros", "codex-extension-items", "codex-protocol", + "codex-secrets", "codex-shell-command", "codex-utils-absolute-path", "codex-utils-cargo-bin", diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index debad694ab..e33d4ed660 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -18,6 +18,7 @@ codex-experimental-api-macros = { workspace = true } codex-app-server-protocol-noop-macros = { workspace = true } codex-extension-items = { workspace = true } codex-protocol = { workspace = true } +codex-secrets = { workspace = true } codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 0887cb89f7..c71c65fa92 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -35,6 +35,7 @@ use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::review_format::REVIEW_FALLBACK_MESSAGE; use codex_protocol::review_format::render_review_output_text; +use codex_secrets::redact_secrets; use codex_shell_command::parse_command::parse_command; use codex_shell_command::parse_command::shlex_join; use codex_utils_path_uri::PathUri; @@ -42,6 +43,24 @@ use std::collections::HashMap; use std::path::PathBuf; use tracing::warn; +/// Client-facing command and parsed actions projected from a raw command. +pub struct CommandExecutionPresentation { + /// Shell-formatted command with recognizable secrets redacted. + pub command: String, + /// Parsed command actions with recognizable secrets redacted. + pub command_actions: Vec, +} + +impl CommandExecutionPresentation { + /// Projects a raw command into its client-facing representation. + pub fn from_raw(command: &[String], parsed_cmd: &[ParsedCommand], cwd: &PathUri) -> Self { + Self { + command: redact_secrets(shlex_join(command)), + command_actions: command_actions_for_path_uri(parsed_cmd, cwd), + } + } +} + pub(crate) fn review_output_text(output: Option<&ReviewOutputEvent>) -> String { output .map(render_review_output_text) @@ -75,17 +94,18 @@ pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem { } pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { - let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd); + let presentation = + CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), plugin_id: payload.plugin_id.clone(), script_path: payload.script_path.clone(), - command: shlex_join(&payload.command), + command: presentation.command, cwd: payload.cwd.clone().into(), process_id: payload.process_id.clone(), source: payload.source.into(), status: CommandExecutionStatus::InProgress, - command_actions, + command_actions: presentation.command_actions, aggregated_output: None, exit_code: None, duration_ms: None, @@ -99,28 +119,26 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread Some(payload.aggregated_output.clone()) }; let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX); - let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd); + let presentation = + CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), plugin_id: payload.plugin_id.clone(), script_path: payload.script_path.clone(), - command: shlex_join(&payload.command), + command: presentation.command, cwd: payload.cwd.clone().into(), process_id: payload.process_id.clone(), source: payload.source.into(), status: (&payload.status).into(), - command_actions, + command_actions: presentation.command_actions, aggregated_output, exit_code: Some(payload.exit_code), duration_ms: Some(duration_ms), } } -pub(crate) fn command_actions_for_path_uri( - parsed_cmd: &[ParsedCommand], - cwd: &PathUri, -) -> Vec { +fn command_actions_for_path_uri(parsed_cmd: &[ParsedCommand], cwd: &PathUri) -> Vec { parsed_cmd .iter() .cloned() @@ -131,7 +149,7 @@ pub(crate) fn command_actions_for_path_uri( // genuinely opaque cwd would require executor-native state unavailable here. match cwd.join(path.to_string_lossy().as_ref()) { Ok(path) => Some(CommandAction::Read { - command: cmd, + command: redact_secrets(cmd), name, path: path.into(), }), @@ -147,15 +165,18 @@ pub(crate) fn command_actions_for_path_uri( } } } - ParsedCommand::ListFiles { cmd, path } => { - Some(CommandAction::ListFiles { command: cmd, path }) - } - ParsedCommand::Search { cmd, query, path } => Some(CommandAction::Search { - command: cmd, - query, + ParsedCommand::ListFiles { cmd, path } => Some(CommandAction::ListFiles { + command: redact_secrets(cmd), path, }), - ParsedCommand::Unknown { cmd } => Some(CommandAction::Unknown { command: cmd }), + ParsedCommand::Search { cmd, query, path } => Some(CommandAction::Search { + command: redact_secrets(cmd), + query: query.map(redact_secrets), + path, + }), + ParsedCommand::Unknown { cmd } => Some(CommandAction::Unknown { + command: redact_secrets(cmd), + }), }) .collect() } diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs index d87427a78f..5de4bc3014 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs @@ -4,6 +4,7 @@ use serde_json::json; #[test] fn read_command_actions_preserve_native_and_foreign_paths() { + let api_key = "sk-abcdefghijklmnopqrstuvwxyz123456"; for (cwd_uri, relative_path, expected_path) in [ ( "file:///home/alice/repo", @@ -38,6 +39,11 @@ fn read_command_actions_preserve_native_and_foreign_paths() { cmd: "ls".to_string(), path: Some("subdir".to_string()), }, + ParsedCommand::Search { + cmd: format!("rg {api_key}"), + query: Some(api_key.to_string()), + path: Some("src".to_string()), + }, ParsedCommand::Search { cmd: "rg needle".to_string(), query: Some("needle".to_string()), @@ -60,6 +66,12 @@ fn read_command_actions_preserve_native_and_foreign_paths() { "command": "ls", "path": "subdir", }, + { + "type": "search", + "command": "rg [REDACTED_SECRET]", + "query": "[REDACTED_SECRET]", + "path": "src", + }, { "type": "search", "command": "rg needle", diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 57103f8049..fea950ea0f 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1649,6 +1649,7 @@ mod tests { use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::DynamicToolCallResponseEvent; use codex_protocol::protocol::EnteredReviewModeEvent; + use codex_protocol::protocol::ExecCommandBeginEvent; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExitedReviewModeEvent; @@ -2111,19 +2112,28 @@ mod tests { } #[test] - fn preserves_command_plugin_id_across_legacy_upsert() { + fn preserves_command_plugin_id_and_redacts_secrets_across_legacy_upsert() { let turn_id = "turn-1"; let thread_id = ThreadId::new(); + let command = vec![ + "git".to_string(), + "-c".to_string(), + "http.extraHeader=Authorization: Bearer example_synthetic_bearer_token_123456" + .to_string(), + "push".to_string(), + ]; + let parsed_cmd = vec![ParsedCommand::Unknown { + cmd: "git -c 'http.extraHeader=Authorization: Bearer example_synthetic_bearer_token_123456' push" + .to_string(), + }]; let command_item = CoreTurnItem::CommandExecution(CoreCommandExecutionItem { id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), process_id: Some("pid-1".to_string()), - command: vec!["echo".to_string(), "hello world".to_string()], + command: command.clone(), cwd: test_path_buf("/tmp").abs().into(), - parsed_cmd: vec![ParsedCommand::Unknown { - cmd: "echo hello world".to_string(), - }], + parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::Agent, interaction_input: None, status: CoreCommandExecutionStatus::Completed, @@ -2142,6 +2152,19 @@ mod tests { model_context_window: None, collaboration_mode_kind: Default::default(), }), + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "exec-1".to_string(), + plugin_id: Some("sample@openai-curated".to_string()), + script_path: Some("scripts/run.py".to_string()), + process_id: Some("pid-1".to_string()), + turn_id: turn_id.to_string(), + started_at_ms: 0, + command: command.clone(), + cwd: test_path_buf("/tmp").abs().into(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::Agent, + interaction_input: None, + }), EventMsg::ItemCompleted(ItemCompletedEvent { thread_id, turn_id: turn_id.to_string(), @@ -2156,11 +2179,9 @@ mod tests { process_id: Some("pid-1".to_string()), turn_id: turn_id.to_string(), completed_at_ms: 1_000, - command: vec!["echo".to_string(), "hello world".to_string()], + command, cwd: test_path_buf("/tmp").abs().into(), - parsed_cmd: vec![ParsedCommand::Unknown { - cmd: "echo hello world".to_string(), - }], + parsed_cmd, source: ExecCommandSource::Agent, interaction_input: None, stdout: "hello world\n".to_string(), @@ -2186,6 +2207,29 @@ mod tests { .into_iter() .map(RolloutItem::EventMsg) .collect::>(); + + assert_eq!( + build_turns_from_rollout_items(&items[..2])[0].items, + vec![ThreadItem::CommandExecution { + id: "exec-1".to_string(), + plugin_id: Some("sample@openai-curated".to_string()), + script_path: Some("scripts/run.py".to_string()), + command: "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' push" + .to_string(), + cwd: test_path_buf("/tmp").abs().into(), + process_id: Some("pid-1".to_string()), + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: vec![CommandAction::Unknown { + command: + "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' push" + .to_string(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: None, + }] + ); let turns = build_turns_from_rollout_items(&items); assert_eq!(turns.len(), 1); @@ -2195,13 +2239,16 @@ mod tests { id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), - command: "echo 'hello world'".to_string(), + command: "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' push" + .to_string(), cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-1".to_string()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, command_actions: vec![CommandAction::Unknown { - command: "echo hello world".to_string(), + command: + "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' push" + .to_string(), }], aggregated_output: Some("hello world\n".to_string()), exit_code: Some(0), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index a98ec3f3b2..dab670c34d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -10,7 +10,7 @@ use super::UserInput; use super::shared::v2_enum_from_core; use crate::JsonSchema; use crate::TS; -use crate::protocol::item_builders::command_actions_for_path_uri; +use crate::protocol::item_builders::CommandExecutionPresentation; use crate::protocol::item_builders::convert_patch_changes; use crate::protocol::item_builders::review_output_text; use codex_experimental_api_macros::ExperimentalApi; @@ -43,7 +43,6 @@ use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAutho use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; use codex_protocol::protocol::SubAgentActivityKind as CoreSubAgentActivityKind; -use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::LegacyAppPathString; use serde::Deserialize; @@ -841,24 +840,31 @@ impl From for ThreadItem { summary: reasoning.summary_text, content: reasoning.raw_content, }, - CoreTurnItem::CommandExecution(command) => ThreadItem::CommandExecution { - id: command.id, - plugin_id: command.plugin_id, - script_path: command.script_path, - command: shlex_join(&command.command), - cwd: command.cwd.clone().into(), - process_id: command.process_id, - source: command.source.into(), - status: command.status.into(), - command_actions: command_actions_for_path_uri(&command.parsed_cmd, &command.cwd), - aggregated_output: command - .aggregated_output - .filter(|output| !output.is_empty()), - exit_code: command.exit_code, - duration_ms: command - .duration - .and_then(|duration| i64::try_from(duration.as_millis()).ok()), - }, + CoreTurnItem::CommandExecution(command) => { + let presentation = CommandExecutionPresentation::from_raw( + &command.command, + &command.parsed_cmd, + &command.cwd, + ); + ThreadItem::CommandExecution { + id: command.id, + plugin_id: command.plugin_id, + script_path: command.script_path, + command: presentation.command, + cwd: command.cwd.clone().into(), + process_id: command.process_id, + source: command.source.into(), + status: command.status.into(), + command_actions: presentation.command_actions, + aggregated_output: command + .aggregated_output + .filter(|output| !output.is_empty()), + exit_code: command.exit_code, + duration_ms: command + .duration + .and_then(|duration| i64::try_from(duration.as_millis()).ok()), + } + } CoreTurnItem::DynamicToolCall(call) => ThreadItem::DynamicToolCall { id: call.id, namespace: call.namespace, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 9fe748dfee..d6a18789d9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2927,10 +2927,19 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), process_id: Some("pid-1".to_string()), - command: vec!["echo".to_string(), "done".to_string()], + command: vec![ + "git".to_string(), + "-c".to_string(), + "http.extraHeader=Authorization: Bearer example_synthetic_bearer_token_123456" + .to_string(), + "-c".to_string(), + "http.extraHeader=X-Trace:example".to_string(), + "push".to_string(), + ], cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()), parsed_cmd: vec![codex_protocol::parse_command::ParsedCommand::Unknown { - cmd: "echo done".to_string(), + cmd: "git -c 'http.extraHeader=Authorization: Bearer example_synthetic_bearer_token_123456' -c http.extraHeader=X-Trace:example push" + .to_string(), }], source: CoreExecCommandSource::Agent, interaction_input: None, @@ -2949,13 +2958,15 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { id: "exec-1".to_string(), plugin_id: Some("sample@openai-curated".to_string()), script_path: Some("scripts/run.py".to_string()), - command: "echo done".to_string(), + command: "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' -c 'http.extraHeader=X-Trace:example' push" + .to_string(), cwd: LegacyAppPathString::from_abs_path(&test_path_buf("/tmp").abs()), process_id: Some("pid-1".to_string()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, command_actions: vec![CommandAction::Unknown { - command: "echo done".to_string(), + command: "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' -c http.extraHeader=X-Trace:example push" + .to_string(), }], aggregated_output: Some("done\n".to_string()), exit_code: Some(0), diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c5d16b5661..6d547f9723 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1516,7 +1516,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `agentMessage` — `{id, text}` containing the accumulated agent reply. - `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). -- `commandExecution` — `{id, pluginId?, scriptPath?, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `pluginId` is present only for commands attributed to a trusted first-party plugin, newly attributed items also include `scriptPath` as a safe `/`-separated path relative to the trusted plugin root, older history may omit `scriptPath`, and `status` is `inProgress`, `completed`, `failed`, or `declined`. +- `commandExecution` — `{id, pluginId?, scriptPath?, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `pluginId` is present only for commands attributed to a trusted first-party plugin, newly attributed items also include `scriptPath` as a safe `/`-separated path relative to the trusted plugin root, older history may omit `scriptPath`, and `status` is `inProgress`, `completed`, `failed`, or `declined`. Ordinary execution items and their replay expose `command` and `commandActions` as redacted display values, not executable commands. `cwd` and read `commandActions[].path` use the executor's native path convention, even when the app-server runs on a different operating system. For example, an app-server running on Linux can return `C:\repo\src\main.rs` for a Windows executor; clients must not interpret that path as local to the app-server. - `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. - `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, readOnlyHint, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri, appName, actionName}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, `resourceUri` points to the widget template, `appName` is the connector's display name, and `actionName` is the stable connector `Action.name`. `readOnlyHint` is `true` for read-only tools, `false` for write-capable tools, and `null` when the annotation is unavailable, including older rollout entries. The hint describes tool capability, not whether an invocation succeeded or performed a write; use `status`, `result`, and `error` to determine the execution outcome. `appName` and `actionName` may be null for older rollout entries. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the raw MCP tool. `status` is `inProgress`, `completed`, or `failed`. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ae62eb7018..a14522f62e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermis use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo; use codex_app_server_protocol::CommandAction as V2ParsedCommand; use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionPresentation; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionSource; @@ -620,13 +621,17 @@ pub(crate) async fn apply_bespoke_event_handling( { CommandExecutionApprovalPresentation::Network(network_approval_context) } else { - let command_string = shlex_join(&command); + let command_presentation = CommandExecutionPresentation::from_raw( + &command, + &parsed_cmd, + &cwd.clone().into(), + ); let completion_item = CommandExecutionCompletionItem { plugin_id, script_path, - command: command_string, + command: command_presentation.command, cwd: cwd.clone().into(), - command_actions: command_actions.clone(), + command_actions: command_presentation.command_actions, }; CommandExecutionApprovalPresentation::Command(completion_item) }; @@ -637,9 +642,9 @@ pub(crate) async fn apply_bespoke_event_handling( } CommandExecutionApprovalPresentation::Command(completion_item) => ( None, - Some(completion_item.command.clone()), + Some(shlex_join(&command)), Some(completion_item.cwd.clone()), - Some(completion_item.command_actions.clone()), + Some(command_actions), Some(completion_item), ), }; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 38a9e3d05b..1346b8ba51 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2044,16 +2044,24 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { let tmp = TempDir::new()?; let codex_home = tmp.path().to_path_buf(); + let bearer_token = "example_bearer_token_1234567890"; + let first_shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import sys; print(sys.argv[1].endswith('7890'))".to_string(), + format!("Authorization: Bearer {bearer_token}"), + ]; + let expected_approval_command = format_with_current_shell_display(&shlex::try_join( + first_shell_command.iter().map(String::as_str), + )?); + let expected_display_command = + expected_approval_command.replace(bearer_token, "[REDACTED_SECRET]"); // Mock server: first turn requests a shell call (elicitation), then completes. // Second turn same, but we'll set approval_policy=never to avoid elicitation. let responses = vec![ create_shell_command_sse_response( - vec![ - "python3".to_string(), - "-c".to_string(), - "print(42)".to_string(), - ], + first_shell_command, /*workdir*/ None, Some(5000), "call1", @@ -2124,6 +2132,10 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { params.environment_id.as_deref(), Some(expected_environment_id.as_str()) ); + assert_eq!( + params.command.as_deref(), + Some(expected_approval_command.as_str()) + ); let resolved_request_id = request_id.clone(); // Approve and wait for task completion @@ -2135,12 +2147,32 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { ) .await?; let mut saw_resolved = false; + let mut saw_completed_command = false; loop { let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; let JSONRPCMessage::Notification(notification) = message else { continue; }; match notification.method.as_str() { + "item/completed" => { + let completed: ItemCompletedNotification = + serde_json::from_value(notification.params.expect("item/completed params"))?; + match completed.item { + ThreadItem::CommandExecution { + id, + command, + exit_code, + aggregated_output, + .. + } if id == "call1" => { + assert_eq!(command, expected_display_command); + assert_eq!(exit_code, Some(0)); + assert!(aggregated_output.is_some_and(|output| output.contains("True"))); + saw_completed_command = true; + } + _ => {} + } + } "serverRequest/resolved" => { let resolved: ServerRequestResolvedNotification = serde_json::from_value( notification @@ -2154,6 +2186,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { } "turn/completed" => { assert!(saw_resolved, "serverRequest/resolved should arrive first"); + assert!(saw_completed_command, "expected completed command item"); break; } _ => {} @@ -2227,14 +2260,22 @@ async fn run_turn_start_exec_approval_rejection_v2( let tmp = TempDir::new()?; let codex_home = tmp.path().to_path_buf(); + let bearer_token = "example_bearer_token_1234567890"; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + format!("Authorization: Bearer {bearer_token}"), + ]; + let expected_approval_command = format_with_current_shell_display(&shlex::try_join( + shell_command.iter().map(String::as_str), + )?); + let expected_display_command = + expected_approval_command.replace(bearer_token, "[REDACTED_SECRET]"); let responses = vec![ create_shell_command_sse_response( - vec![ - "python3".to_string(), - "-c".to_string(), - "print(42)".to_string(), - ], + shell_command, /*workdir*/ None, Some(5000), "call-decline", @@ -2282,11 +2323,22 @@ async fn run_turn_start_exec_approval_rejection_v2( } }) .await??; - let ThreadItem::CommandExecution { id, status, .. } = started_command_execution else { + let ThreadItem::CommandExecution { + id, + status, + command, + command_actions, + .. + } = started_command_execution + else { unreachable!("loop ensures we break on command execution items"); }; assert_eq!(id, "call-decline"); assert_eq!(status, CommandExecutionStatus::InProgress); + assert_eq!(command, expected_display_command); + let displayed_actions = serde_json::to_string(&command_actions)?; + assert!(displayed_actions.contains("[REDACTED_SECRET]")); + assert!(!displayed_actions.contains(bearer_token)); let server_req = timeout( DEFAULT_READ_TIMEOUT, @@ -2299,6 +2351,12 @@ async fn run_turn_start_exec_approval_rejection_v2( assert_eq!(params.item_id, "call-decline"); assert_eq!(params.thread_id, thread.id); assert_eq!(params.turn_id, turn.id); + assert_eq!( + params.command.as_deref(), + Some(expected_approval_command.as_str()) + ); + let approval_actions = serde_json::to_string(¶ms.command_actions)?; + assert!(approval_actions.contains(bearer_token)); mcp.send_response(request_id, approval_response).await?; @@ -2315,6 +2373,8 @@ async fn run_turn_start_exec_approval_rejection_v2( let ThreadItem::CommandExecution { id, status, + command, + command_actions, exit_code, aggregated_output, .. @@ -2324,6 +2384,10 @@ async fn run_turn_start_exec_approval_rejection_v2( }; assert_eq!(id, "call-decline"); assert_eq!(status, expected_status); + assert_eq!(command, expected_display_command); + let displayed_actions = serde_json::to_string(&command_actions)?; + assert!(displayed_actions.contains("[REDACTED_SECRET]")); + assert!(!displayed_actions.contains(bearer_token)); assert!(exit_code.is_none()); assert!(aggregated_output.is_none());