Scope command approvals by execution environment (#28738)

## Why

Command approval cache keys included the command and working directory,
but not the execution environment. An approval for `/workspace` locally
could therefore be reused for the same command and path on an executor.

## What changed

- Include the selected environment ID in shell and unified-exec approval
cache keys.
- Carry that ID through the normal command approval request so clients
can show which environment is being approved.
- Expose the environment through app-server as a required nullable
`environmentId` and show it in the inline TUI approval prompt.
- Keep older recorded approval events compatible when the environment is
absent.

For example, `echo ok` in local `/workspace` and `echo ok` in executor
`/workspace` now produce different approval keys and separate prompts.

## Scope

This PR does not change network approvals, Guardian review actions, MCP
elicitation, full-screen TUI rendering, or environment-ID validation.
Remote `shell_command` execution itself remains in #28722; this PR only
makes its approval key environment-aware.
This commit is contained in:
jif
2026-06-17 19:52:43 +02:00
committed by GitHub
parent b947695a98
commit 1391d786bc
40 changed files with 215 additions and 14 deletions
@@ -452,6 +452,7 @@ mod tests {
item_id: "call-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("ls".to_string()),
@@ -791,6 +792,7 @@ mod tests {
item_id: "call-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("ls".to_string()),
@@ -615,6 +615,7 @@ mod tests {
item_id: call_id.to_string(),
started_at_ms: 0,
approval_id: approval_id.map(str::to_string),
environment_id: None,
reason: None,
network_approval_context: None,
command: Some("echo hi".to_string()),
+1
View File
@@ -4690,6 +4690,7 @@ fn exec_approval_request(
item_id: item_id.to_string(),
started_at_ms: 0,
approval_id: approval_id.map(str::to_string),
environment_id: None,
reason: Some("needs approval".to_string()),
network_approval_context: None,
command: Some("echo hello".to_string()),
+1
View File
@@ -488,6 +488,7 @@ mod tests {
item_id: item_id.to_string(),
started_at_ms: 0,
approval_id: approval_id.map(str::to_string),
environment_id: None,
reason: Some("needs approval".to_string()),
network_approval_context: None,
command: Some("echo hello".to_string()),
+1
View File
@@ -226,6 +226,7 @@ impl App {
.approval_id
.clone()
.unwrap_or_else(|| params.item_id.clone()),
environment_id: params.environment_id.clone(),
command: params
.command
.as_deref()
+2
View File
@@ -26,6 +26,8 @@ pub(crate) struct ExecApprovalRequestEvent {
pub(crate) approval_id: Option<String>,
#[serde(default)]
pub(crate) turn_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) environment_id: Option<String>,
pub(crate) command: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) reason: Option<String>,
@@ -74,6 +74,7 @@ pub(crate) enum ApprovalRequest {
thread_id: ThreadId,
thread_label: Option<String>,
id: String,
environment_id: Option<String>,
command: Vec<String>,
reason: Option<String>,
available_decisions: Vec<CommandExecutionApprovalDecision>,
@@ -675,6 +676,7 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
match request {
ApprovalRequest::Exec {
thread_label,
environment_id,
reason,
command,
network_approval_context,
@@ -689,6 +691,13 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
]));
header.push(Line::from(""));
}
if let Some(environment_id) = environment_id {
header.push(Line::from(vec![
"Environment: ".into(),
environment_id.clone().bold(),
]));
header.push(Line::from(""));
}
if let Some(reason) = reason {
header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()]));
header.push(Line::from(""));
@@ -1220,6 +1229,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
environment_id: None,
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
available_decisions: vec![
@@ -1360,6 +1370,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
environment_id: None,
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![
@@ -1403,6 +1414,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
environment_id: None,
command: vec!["curl".to_string(), "https://example.com".to_string()],
reason: None,
available_decisions: vec![
@@ -1477,6 +1489,7 @@ mod tests {
thread_id,
thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(),
environment_id: None,
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![
@@ -1511,6 +1524,7 @@ mod tests {
thread_id,
thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(),
environment_id: None,
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![
@@ -1549,6 +1563,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(),
environment_id: None,
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![
@@ -1577,6 +1592,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
environment_id: None,
command: vec!["echo".to_string()],
reason: None,
available_decisions: vec![
@@ -1629,6 +1645,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
environment_id: None,
command: vec!["curl".to_string(), "https://example.com".to_string()],
reason: None,
available_decisions: vec![
@@ -1668,6 +1685,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
environment_id: None,
command,
reason: None,
available_decisions: vec![
@@ -1966,6 +1984,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
environment_id: None,
command: vec!["cat".into(), "/tmp/readme.txt".into()],
reason: None,
available_decisions: vec![
@@ -2022,6 +2041,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
environment_id: None,
command: vec!["cat".into(), "/tmp/readme.txt".into()],
reason: Some("need filesystem access".into()),
available_decisions: vec![
@@ -2102,6 +2122,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
environment_id: None,
command: vec!["curl".into(), "https://example.com".into()],
reason: Some("network request blocked".into()),
available_decisions: vec![
@@ -2238,6 +2259,7 @@ mod tests {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
environment_id: None,
command: vec![
"network-access".to_string(),
"https://example.com:8443".to_string(),
+1
View File
@@ -1896,6 +1896,7 @@ mod tests {
thread_id: codex_protocol::ThreadId::new(),
thread_label: None,
id: "1".to_string(),
environment_id: None,
command: vec!["echo".into(), "ok".into()],
reason: None,
available_decisions: vec![
+1
View File
@@ -852,6 +852,7 @@ fn exec_approval_request_from_params(
additional_permissions: params.additional_permissions,
turn_id: params.turn_id,
approval_id: params.approval_id,
environment_id: params.environment_id,
proposed_execpolicy_amendment: params.proposed_execpolicy_amendment,
proposed_network_policy_amendments: params.proposed_network_policy_amendments,
available_decisions: params.available_decisions,
@@ -161,6 +161,7 @@ mod tests {
call_id: call_id.to_string(),
approval_id: approval_id.map(str::to_string),
turn_id: "turn".to_string(),
environment_id: None,
command: vec!["true".to_string()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: None,
@@ -13,6 +13,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
call_id: "call-short".into(),
approval_id: Some("call-short".into()),
turn_id: "turn-short".into(),
environment_id: Some("remote".to_string()),
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some(
@@ -57,6 +58,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() {
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(
@@ -98,6 +100,7 @@ fn app_server_exec_approval_request_preserves_permissions_context() {
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: Some(codex_app_server_protocol::NetworkApprovalContext {
host: "example.com".to_string(),
@@ -157,6 +160,7 @@ async fn network_exec_approval_history_describes_session_host_allowance() {
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: Some(codex_app_server_protocol::NetworkApprovalContext {
host: "example.com".to_string(),
@@ -198,6 +202,7 @@ async fn network_exec_approval_history_describes_one_time_host_allowance() {
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: Some(codex_app_server_protocol::NetworkApprovalContext {
host: "example.com".to_string(),
@@ -239,6 +244,7 @@ async fn network_exec_approval_history_describes_canceled_host_request() {
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: Some(codex_app_server_protocol::NetworkApprovalContext {
host: "example.com".to_string(),
@@ -330,6 +336,7 @@ async fn exec_approval_uses_approval_id_when_present() {
call_id: "call-parent".into(),
approval_id: Some("approval-subcommand".into()),
turn_id: "turn-short".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some(
@@ -372,6 +379,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
call_id: "call-multi".into(),
approval_id: Some("call-multi".into()),
turn_id: "turn-multi".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some(
@@ -423,6 +431,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
call_id: "call-long".into(),
approval_id: Some("call-long".into()),
turn_id: "turn-long".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), long],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: None,
@@ -10,6 +10,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
call_id: "call-short".into(),
approval_id: Some("call-short".into()),
turn_id: "turn-short".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some(
@@ -56,6 +57,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() {
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(
@@ -93,6 +95,7 @@ async fn exec_approval_uses_approval_id_when_present() {
call_id: "call-parent".into(),
approval_id: Some("approval-subcommand".into()),
turn_id: "turn-short".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some(
@@ -136,6 +139,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
call_id: "call-multi".into(),
approval_id: Some("call-multi".into()),
turn_id: "turn-multi".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some(
@@ -189,6 +193,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
call_id: "call-long".into(),
approval_id: Some("call-long".into()),
turn_id: "turn-long".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), long],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: None,
@@ -1098,6 +1103,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
call_id: "call-approve-cmd".into(),
approval_id: Some("call-approve-cmd".into()),
turn_id: "turn-approve-cmd".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some(
@@ -1155,6 +1161,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
call_id: "call-approve-cmd-noreason".into(),
approval_id: Some("call-approve-cmd-noreason".into()),
turn_id: "turn-approve-cmd-noreason".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: None,
@@ -1201,6 +1208,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
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(),
environment_id: None,
command: command.clone(),
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: None,
@@ -3,12 +3,14 @@ source: tui/src/chatwidget/tests/approval_requests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 13 },
area: Rect { x: 0, y: 0, width: 80, height: 15 },
content: [
" ",
" ",
" Would you like to run the following command? ",
" ",
" Environment: remote ",
" ",
" Reason: this is a test reason such as one that would be produced by the ",
" model ",
" ",
@@ -23,17 +25,19 @@ Buffer {
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: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 7, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 7, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE,
x: 20, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 15, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 10, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 73, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 7, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 9, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 9, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE,
x: 20, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 11, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 51, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
@@ -1864,6 +1864,7 @@ async fn status_widget_and_approval_modal_snapshot() {
call_id: "call-approve-exec".into(),
approval_id: Some("call-approve-exec".into()),
turn_id: "turn-approve-exec".into(),
environment_id: None,
command: vec!["echo".into(), "hello world".into()],
cwd: test_path_buf("/tmp").abs(),
reason: Some(
@@ -13,6 +13,7 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() {
call_id: "call-action-required".into(),
approval_id: Some("call-action-required".into()),
turn_id: "turn-action-required".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo hello".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some("need confirmation".into()),
@@ -55,6 +56,7 @@ async fn terminal_title_action_required_respects_spinner_setting() {
call_id: "call-no-spinner".into(),
approval_id: Some("call-no-spinner".into()),
turn_id: "turn-no-spinner".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo hello".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some("need confirmation".into()),
@@ -83,6 +85,7 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() {
call_id: "call-blink".into(),
approval_id: Some("call-blink".into()),
turn_id: "turn-blink".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo hello".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some("need confirmation".into()),
@@ -118,6 +121,7 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d
call_id: "call-no-animations".into(),
approval_id: Some("call-no-animations".into()),
turn_id: "turn-no-animations".into(),
environment_id: None,
command: vec!["bash".into(), "-lc".into(), "echo hello".into()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: Some("need confirmation".into()),
@@ -296,6 +296,7 @@ impl ChatWidget {
thread_id: self.thread_id.unwrap_or_default(),
thread_label: None,
id: ev.effective_approval_id(),
environment_id: ev.environment_id,
command: ev.command,
reason: ev.reason,
available_decisions,