diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a171ba9b7..15d0a6502 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1980,6 +1980,7 @@ dependencies = [ "clap", "codex-experimental-api-macros", "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 0749b07e0..c766a6f23 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -17,6 +17,7 @@ anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-experimental-api-macros = { workspace = true } codex-protocol = { workspace = true } +codex-secrets = { workspace = true } codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } schemars = { workspace = true } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 84b7e9851..806e852eb 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4336,11 +4336,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f2d47dc72..b412aa33c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -20455,11 +20455,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/definitions/v2/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 05e534ad7..0ce2997f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -18516,11 +18516,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/definitions/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 5d9df5fec..c2365d2f6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -636,11 +636,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index c65950c0c..ce4c6a66a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -636,11 +636,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index b56d65c74..c2ed87fa2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -784,11 +784,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 092381705..70300e1bd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1269,11 +1269,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 812918345..c31bd2d4a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1096,11 +1096,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 636529150..bbe4f1e29 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1096,11 +1096,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index f54b2d4f9..b5874e786 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1096,11 +1096,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index d6729ab67..1443d930e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1269,11 +1269,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 0e942dd85..dff4f7691 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1096,11 +1096,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index c86e88a06..041ddbf20 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1269,11 +1269,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 17f63bbd0..512098230 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1096,11 +1096,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 27b1a43c7..d8b156d43 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1096,11 +1096,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 95453f78d..64e924742 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -784,11 +784,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 51a903a88..9ac417f8e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -784,11 +784,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 950f9142b..cc01ec052 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -784,11 +784,11 @@ ] }, "command": { - "description": "The command to be executed.", + "description": "Redacted command display value. This is not the executable command.", "type": "string" }, "commandActions": { - "description": "A best-effort parsing of the command to understand the action(s) it will perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", + "description": "A best-effort redacted display parsing of the command to understand the action(s) it\nwill perform.\nThis returns a list of CommandAction objects because a single shell command may\nbe composed of many commands piped together.", "items": { "$ref": "#/$defs/CommandAction" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index a42a39658..107bdf943 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -25,7 +25,7 @@ import type { WebSearchAction } from "./WebSearchAction"; export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, /** - * The command to be executed. + * Redacted command display value. This is not the executable command. */ command: string, /** @@ -37,7 +37,8 @@ cwd: AbsolutePathBuf, */ processId: string | null, source: CommandExecutionSource, status: CommandExecutionStatus, /** - * A best-effort parsing of the command to understand the action(s) it will perform. + * A best-effort redacted display parsing of the command to understand the action(s) it + * will perform. * This returns a list of CommandAction objects because a single shell command may * be composed of many commands piped together. */ 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 17e0f9aef..92b551f48 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -23,6 +23,7 @@ use crate::protocol::v2::PatchApplyStatus; use crate::protocol::v2::PatchChangeKind; use crate::protocol::v2::ThreadItem; use codex_protocol::ThreadId; +use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::ExecCommandBeginEvent; @@ -32,11 +33,80 @@ use codex_protocol::protocol::GuardianAssessmentAction; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; +use codex_secrets::redact_secrets; use codex_shell_command::parse_command::parse_command; use codex_shell_command::parse_command::shlex_join; +use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::path::PathBuf; +/// 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: &AbsolutePathBuf, + ) -> Self { + Self { + command: redact_secrets(shlex_join(command)), + command_actions: command_actions_from_parsed(parsed_cmd, cwd), + } + } +} + +fn command_actions_from_parsed( + parsed_cmd: &[ParsedCommand], + cwd: &AbsolutePathBuf, +) -> Vec { + let actions = parsed_cmd + .iter() + .cloned() + .map(|parsed| CommandAction::from_core_with_cwd(parsed, cwd)) + .collect(); + redact_command_actions(actions) +} + +pub(crate) fn redact_command_actions(actions: Vec) -> Vec { + actions + .into_iter() + .map(|action| match action { + CommandAction::Read { + command, + name, + path, + } => CommandAction::Read { + command: redact_secrets(command), + name, + path, + }, + CommandAction::ListFiles { command, path } => CommandAction::ListFiles { + command: redact_secrets(command), + path, + }, + CommandAction::Search { + command, + query, + path, + } => CommandAction::Search { + command: redact_secrets(command), + query: query.map(redact_secrets), + path, + }, + CommandAction::Unknown { command } => CommandAction::Unknown { + command: redact_secrets(command), + }, + }) + .collect() +} + pub fn build_file_change_approval_request_item( payload: &ApplyPatchApprovalRequestEvent, ) -> ThreadItem { @@ -66,19 +136,16 @@ pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem { pub fn build_command_execution_approval_request_item( payload: &ExecApprovalRequestEvent, ) -> ThreadItem { + let presentation = + CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), - command: shlex_join(&payload.command), + command: presentation.command, cwd: payload.cwd.clone(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions: presentation.command_actions, aggregated_output: None, exit_code: None, duration_ms: None, @@ -86,19 +153,16 @@ pub fn build_command_execution_approval_request_item( } pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { + let presentation = + CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), - command: shlex_join(&payload.command), + command: presentation.command, cwd: payload.cwd.clone(), process_id: payload.process_id.clone(), source: payload.source.into(), status: CommandExecutionStatus::InProgress, - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions: presentation.command_actions, aggregated_output: None, exit_code: None, duration_ms: None, @@ -112,20 +176,17 @@ 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 presentation = + CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), - command: shlex_join(&payload.command), + command: presentation.command, cwd: payload.cwd.clone(), process_id: payload.process_id.clone(), source: payload.source.into(), status: (&payload.status).into(), - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions: presentation.command_actions, aggregated_output, exit_code: Some(payload.exit_code), duration_ms: Some(duration_ms), @@ -143,7 +204,7 @@ pub fn build_item_from_guardian_event( match &assessment.action { GuardianAssessmentAction::Command { command, cwd, .. } => { let id = assessment.target_item_id.as_ref()?; - let command = command.clone(); + let command = redact_secrets(command.clone()); let command_actions = vec![CommandAction::Unknown { command: command.clone(), }]; @@ -171,21 +232,18 @@ pub fn build_item_from_guardian_event( .chain(argv.iter().skip(1).cloned()) .collect::>() }; - let command = shlex_join(&argv); let parsed_cmd = parse_command(&argv); - let command_actions = if parsed_cmd.is_empty() { + let presentation = CommandExecutionPresentation::from_raw(&argv, &parsed_cmd, cwd); + let command_actions = if presentation.command_actions.is_empty() { vec![CommandAction::Unknown { - command: command.clone(), + command: presentation.command.clone(), }] } else { - parsed_cmd - .into_iter() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, cwd)) - .collect() + presentation.command_actions }; Some(ThreadItem::CommandExecution { id: id.clone(), - command, + command: presentation.command, cwd: cwd.clone(), process_id: None, source: CommandExecutionSource::Agent, 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 ae318c48f..350d5bba9 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -4,6 +4,7 @@ use crate::protocol::item_builders::build_file_change_approval_request_item; use crate::protocol::item_builders::build_file_change_begin_item; use crate::protocol::item_builders::build_file_change_end_item; use crate::protocol::item_builders::build_item_from_guardian_event; +use crate::protocol::item_builders::redact_command_actions; use crate::protocol::v2::CollabAgentState; use crate::protocol::v2::CollabAgentTool; use crate::protocol::v2::CollabAgentToolCallStatus; @@ -61,6 +62,7 @@ use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::ViewImageToolCallEvent; use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; +use codex_secrets::redact_secrets; use codex_shell_command::parse_command::parse_command; use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1328,7 +1330,7 @@ fn command_execution_from_shell_command( let command_actions = command_actions_from_shell_command(&command, &cwd); ThreadItem::CommandExecution { id: call_id.to_string(), - command, + command: redact_secrets(command), cwd, process_id: None, source: CommandExecutionSource::Agent, @@ -1346,7 +1348,7 @@ fn command_execution_from_argv( cwd: AbsolutePathBuf, status: CommandExecutionStatus, ) -> ThreadItem { - let command_display = shlex_join(&command); + let command_display = redact_secrets(shlex_join(&command)); let command_actions = command_actions_from_argv(&command, &cwd); ThreadItem::CommandExecution { id: call_id.to_string(), @@ -1383,10 +1385,10 @@ fn command_actions_from_parsed( if command_actions.is_empty() { vec![CommandAction::Unknown { - command: fallback_command, + command: redact_secrets(fallback_command), }] } else { - command_actions + redact_command_actions(command_actions) } } @@ -2374,6 +2376,9 @@ mod tests { #[test] fn reconstructs_command_execution_from_raw_function_call_response_items() { + let credential = "synthetic+bearer/value~1234567890=="; + let authorization = format!("Authorization: Bearer {credential}"); + let command = format!("git -c 'http.extraHeader={authorization}' push"); let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), @@ -2395,7 +2400,7 @@ mod tests { name: "exec_command".into(), namespace: None, arguments: serde_json::json!({ - "cmd": "echo hello", + "cmd": command, "workdir": test_path_buf("/tmp"), }) .to_string(), @@ -2430,13 +2435,17 @@ mod tests { }, ThreadItem::CommandExecution { id: "exec-1".into(), - command: "echo hello".into(), + command: + "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' push" + .into(), cwd: test_path_buf("/tmp").abs(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, command_actions: vec![CommandAction::Unknown { - command: "echo hello".into(), + command: + "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' push" + .into(), }], aggregated_output: Some("hello\n".into()), exit_code: None, @@ -2448,6 +2457,8 @@ mod tests { #[test] fn reconstructs_local_shell_call_from_legacy_id_when_call_id_absent() { + let credential = "synthetic+bearer/value~1234567890=="; + let authorization = format!("Authorization: Bearer {credential}"); let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), @@ -2461,7 +2472,12 @@ mod tests { call_id: None, status: LocalShellStatus::Completed, action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["echo".into(), "hello".into()], + command: vec![ + "git".into(), + "-c".into(), + format!("http.extraHeader={authorization}"), + "push".into(), + ], timeout_ms: None, working_directory: Some("/tmp".into()), env: None, @@ -2477,13 +2493,16 @@ mod tests { turns[0].items, vec![ThreadItem::CommandExecution { id: "legacy-shell-1".into(), - command: "echo hello".into(), + command: "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' push" + .into(), cwd: cwd_from_raw_workdir(Some("/tmp")), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, command_actions: vec![CommandAction::Unknown { - command: "echo hello".into(), + command: + "git -c 'http.extraHeader=Authorization: Bearer [REDACTED_SECRET]' push" + .into(), }], aggregated_output: None, exit_code: None, 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 4644ae05b..21307815b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -251,7 +251,7 @@ pub enum ThreadItem { #[ts(rename_all = "camelCase")] CommandExecution { id: String, - /// The command to be executed. + /// Redacted command display value. This is not the executable command. command: String, /// The command's working directory. cwd: AbsolutePathBuf, @@ -260,7 +260,8 @@ pub enum ThreadItem { #[serde(default)] source: CommandExecutionSource, status: CommandExecutionStatus, - /// A best-effort parsing of the command to understand the action(s) it will perform. + /// A best-effort redacted display parsing of the command to understand the action(s) it + /// will perform. /// This returns a list of CommandAction objects because a single shell command may /// be composed of many commands piped together. command_actions: Vec, 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 6a34f43f4..295570ce2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::protocol::item_builders::build_command_execution_begin_item; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; @@ -21,12 +22,15 @@ use codex_protocol::models::ImageDetail; use codex_protocol::models::MessagePhase; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; +use codex_protocol::parse_command::ParsedCommand; use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; use codex_protocol::protocol::AgentStatus as CoreAgentStatus; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -1742,6 +1746,62 @@ fn command_execution_output_delta_round_trips() { assert_eq!(decoded, notification); } +#[test] +fn command_execution_display_redacts_secrets_and_round_trips() { + let credential = [ + "synthetic", + "+", + "bearer", + "/", + "value", + "~", + "1234567890", + "==", + ] + .concat(); + let authorization = format!("Authorization: Bearer {credential}"); + let safe_trace = "X-Trace:visible"; + let safe_action = "safe-action-marker"; + let item = build_command_execution_begin_item(&ExecCommandBeginEvent { + call_id: "display-redaction".to_string(), + process_id: Some("process-1".to_string()), + turn_id: "turn-1".to_string(), + started_at_ms: 1, + command: vec![ + "git".to_string(), + "-c".to_string(), + format!("http.extraHeader={authorization}"), + "-c".to_string(), + format!("http.extraHeader={safe_trace}"), + "push".to_string(), + ], + cwd: test_absolute_path(), + parsed_cmd: vec![ + ParsedCommand::Search { + cmd: format!("rg '{authorization}'"), + query: Some(authorization), + path: Some("src".to_string()), + }, + ParsedCommand::Unknown { + cmd: format!("echo {safe_action}"), + }, + ], + source: ExecCommandSource::Agent, + interaction_input: None, + }); + + let value = serde_json::to_value(&item).expect("command execution item should serialize"); + let serialized = serde_json::to_string(&value).expect("serialized item should remain valid"); + assert!(!serialized.contains(&credential)); + assert!(serialized.contains("[REDACTED_SECRET]")); + assert!(serialized.contains(safe_trace)); + assert!(serialized.contains(safe_action)); + + let decoded = serde_json::from_value::(value) + .expect("command execution item should deserialize"); + assert_eq!(decoded, item); +} + #[test] fn sandbox_policy_round_trips_external_sandbox_network_access() { let v2_policy = SandboxPolicy::ExternalSandbox { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8626382fa..488760d3f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1978,7 +1978,7 @@ Today both notifications carry an empty `items` array even when item events were - `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, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. +- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `command` and `commandActions` are redacted display values, not executable commands, and `status` is `inProgress`, `completed`, `failed`, or `declined`. - `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, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. - `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`. @@ -2017,7 +2017,7 @@ There are additional item-specific events: #### commandExecution - `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item. - Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded. + Final `commandExecution` items include parsed, redacted `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded. #### fileChange @@ -2057,7 +2057,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap Order of messages: 1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action. -2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. +2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes the original executable `command`, `cwd`, and `commandActions`; the separate `item/started` display item uses redacted values. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. 3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. 4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. 5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 082e6004c..15b876c67 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; @@ -746,11 +747,12 @@ 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); let completion_item = CommandExecutionCompletionItem { - command: command_string, + command: command_presentation.command, cwd: cwd.clone(), - command_actions: command_actions.clone(), + command_actions: command_presentation.command_actions, }; CommandExecutionApprovalPresentation::Command(completion_item) }; @@ -761,9 +763,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 a7958935b..cc731a6d6 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -78,6 +78,7 @@ use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use shlex; use std::collections::BTreeMap; use std::collections::HashMap; use std::io::Write; @@ -2377,16 +2378,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 = "synthetic+bearer/value~1234567890=="; + let first_shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import sys; print(sys.argv[1].endswith('=='))".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", @@ -2459,6 +2468,10 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { panic!("expected CommandExecutionRequestApproval request"); }; assert_eq!(params.item_id, "call1"); + 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 @@ -2470,6 +2483,7 @@ 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 { @@ -2487,8 +2501,31 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { assert_eq!(resolved.request_id, resolved_request_id); saw_resolved = true; } + "item/completed" => { + let completed: ItemCompletedNotification = serde_json::from_value( + notification.params.clone().expect("item/completed params"), + )?; + if let ThreadItem::CommandExecution { + id, + command, + exit_code, + aggregated_output, + .. + } = completed.item + && id == "call1" + { + assert_eq!(command, expected_display_command); + assert_eq!(exit_code, Some(0)); + assert!( + aggregated_output.is_some_and(|output| output.contains("True")), + "approved command should execute the raw bearer-bearing argv" + ); + saw_completed_command = true; + } + } "turn/completed" => { assert!(saw_resolved, "serverRequest/resolved should arrive first"); + assert!(saw_completed_command, "expected completed command item"); break; } _ => {} @@ -2536,14 +2573,22 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { let codex_home = tmp.path().to_path_buf(); let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; + let bearer_token = "synthetic+bearer/value~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", @@ -2606,11 +2651,22 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { } }) .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, @@ -2623,6 +2679,12 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { 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, @@ -2652,6 +2714,8 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { let ThreadItem::CommandExecution { id, status, + command, + command_actions, exit_code, aggregated_output, .. @@ -2661,6 +2725,10 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { }; assert_eq!(id, "call-decline"); assert_eq!(status, CommandExecutionStatus::Declined); + 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()); diff --git a/codex-rs/secrets/src/sanitizer.rs b/codex-rs/secrets/src/sanitizer.rs index 1fa10ec0d..01f7da115 100644 --- a/codex-rs/secrets/src/sanitizer.rs +++ b/codex-rs/secrets/src/sanitizer.rs @@ -11,8 +11,13 @@ static AWS_SECRET_ACCESS_KEY_REGEX: LazyLock = LazyLock::new(|| { r#"_access_key\b(\s*[:=]\s*)(["']?)([^\s"']{20,})"# )) }); -static BEARER_TOKEN_REGEX: LazyLock = - LazyLock::new(|| compile_regex(concat!(r"(?i)\bBearer\s+", r"[A-Za-z0-9._\-]{16,}\b"))); +static BEARER_TOKEN_REGEX: LazyLock = LazyLock::new(|| { + compile_regex(concat!( + r"(?i:\bBearer)", + r"[ \t]+", + r"[A-Za-z0-9._~+/-]{16,}=*" + )) +}); static GITHUB_TOKEN_REGEX: LazyLock = LazyLock::new(|| { compile_regex(r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{30,})\b") }); @@ -55,7 +60,8 @@ static SECRET_ASSIGNMENT_REGEX: LazyLock = LazyLock::new(|| { /// well-known REGEX. pub fn redact_secrets(input: String) -> String { let input = redact_omission_boundary_secret_fragments(input); - let redacted = OPENAI_KEY_REGEX.replace_all(&input, "[REDACTED_SECRET]"); + let redacted = BEARER_TOKEN_REGEX.replace_all(&input, "Bearer [REDACTED_SECRET]"); + let redacted = OPENAI_KEY_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); let redacted = AWS_ACCESS_KEY_ID_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); let redacted = AWS_SECRET_ACCESS_KEY_REGEX.replace_all(&redacted, |captures: ®ex::Captures<'_>| { @@ -67,7 +73,6 @@ pub fn redact_secrets(input: String) -> String { redact_assignment_match_value(value) ) }); - let redacted = BEARER_TOKEN_REGEX.replace_all(&redacted, "Bearer [REDACTED_SECRET]"); let redacted = GITHUB_TOKEN_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); let redacted = GOOGLE_API_KEY_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); let redacted = ANTHROPIC_KEY_REGEX.replace_all(&redacted, "[REDACTED_SECRET]"); @@ -218,6 +223,7 @@ fn compile_regex(pattern: &str) -> Regex { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn load_regex() { @@ -225,6 +231,47 @@ mod tests { let _ = redact_secrets("secret".to_string()); } + #[test] + fn redacts_supported_bearer_tokens() { + let base64_style = ["abcde", "+", "fghijklmnopqrstuvwxyz012345", "/", "~", "=="].concat(); + let prefixed = ["sk-", "abcdefghijklmnop", "+secret_suffix"].concat(); + let cases = [ + ( + format!("Bearer {base64_style}"), + "Bearer [REDACTED_SECRET]".to_string(), + ), + ( + format!("Bearer\t{base64_style}; echo done"), + "Bearer [REDACTED_SECRET]; echo done".to_string(), + ), + ( + format!("Bearer {prefixed}"), + "Bearer [REDACTED_SECRET]".to_string(), + ), + ]; + + for (input, expected) in cases { + assert_eq!(redact_secrets(input), expected); + } + } + + #[test] + fn avoids_bearer_false_positives() { + let cases = [ + "Bearer of good news", + "Bearer abcdefghijklmno", + "NotABearer abcdefghijklmnop", + "Bearerabcdefghijklmnop", + "Bearer\nabcdefghijklmnop", + "Bearer\u{a0}abcdefghijklmnop", + "Bearer abcdefghijklmno\u{212a}", + ]; + + for input in cases { + assert_eq!(redact_secrets(input.to_string()), input); + } + } + #[test] fn redacts_common_local_state_secret_shapes() { let openai = format!("{}{}", "sk-proj-", "a".repeat(32));