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 authored and GitHub committed 2026-06-17 19:52:43 +02:00
1 parent b947695a98
commit 1391d786bc
40 files changed
+215 -14

No files matched your search

@@ -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![