Require approval for input to escalated terminals (#40978)

## What changed

- Add the `write_stdin_approval` feature flag, disabled by default, to require a fresh approval before sending non-empty input to an escalated unified-exec terminal.
- Route these reviews through hooks, Guardian, app-server, and the TUI as `writeStdin` approvals while keeping the parent command item unchanged.
- Preserve the terminal environment, launch directory, and escalation state across turns, and revalidate the process identity after approval before writing input.

## Testing

- Cover approval routing, policy decisions, process reuse, terminal identity preservation, app-server events, analytics, and TUI rendering.

GitOrigin-RevId: 4ba29eaae6208e934737ba078c96e589c7c2164d
This commit is contained in:
Dylan Hurd authored and copyberry committed 2026-08-26 21:39:05 +00:00
1 parent 21ff2e802c
commit a57b398351
41 files changed
+861 -169

No files matched your search

@@ -40,6 +40,7 @@ use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use codex_app_server_protocol::AdditionalPermissionProfile;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionApprovalKind;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
@@ -79,6 +80,7 @@ pub(crate) enum ApprovalRequest {
#[derive(Clone, Debug)]
pub(crate) struct ExecApprovalRequest {
pub kind: CommandExecutionApprovalKind,
pub thread_id: ThreadId,
pub thread_label: Option<String>,
pub id: String,
@@ -250,23 +252,35 @@ impl ApprovalOverlay {
list_keymap: &ListKeymap,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match request {
ApprovalRequest::Exec(request) => (
exec_options(
&request.available_decisions,
request.network_approval_context.as_ref(),
request.additional_permissions.as_ref(),
approval_keymap,
),
request.network_approval_context.as_ref().map_or_else(
|| "Would you like to run the following command?".to_string(),
|network_approval_context| {
format!(
"Do you want to approve network access to \"{}\"?",
network_approval_context.host
)
},
),
),
ApprovalRequest::Exec(request) => {
let title = if request.kind == CommandExecutionApprovalKind::WriteStdin {
request.command.get(2).map_or_else(
|| "Would you like to send input to the existing terminal?".to_string(),
|process_id| {
format!("Would you like to send input to terminal {process_id}?")
},
)
} else {
request.network_approval_context.as_ref().map_or_else(
|| "Would you like to run the following command?".to_string(),
|network_approval_context| {
format!(
"Do you want to approve network access to \"{}\"?",
network_approval_context.host
)
},
)
};
(
exec_options(
&request.available_decisions,
request.network_approval_context.as_ref(),
request.additional_permissions.as_ref(),
approval_keymap,
),
title,
)
}
ApprovalRequest::Permissions(_) => (
permissions_options(approval_keymap),
"Would you like to grant these permissions?".to_string(),
@@ -704,13 +718,18 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
]));
header.push(Line::from(""));
}
let full_cmd = strip_bash_lc_and_escape(&request.command);
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
if let Some(first) = full_cmd_lines.first_mut() {
first.spans.insert(0, Span::from("$ "));
}
if request.network_approval_context.is_none() {
header.extend(full_cmd_lines);
if request.kind == CommandExecutionApprovalKind::WriteStdin {
let input = request.command.last().map_or("", String::as_str);
header.push(vec!["Input: ".into(), format!("{input:?}").into()].into());
} else {
let full_cmd = strip_bash_lc_and_escape(&request.command);
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
if let Some(first) = full_cmd_lines.first_mut() {
first.spans.insert(0, Span::from("$ "));
}
if request.network_approval_context.is_none() {
header.extend(full_cmd_lines);
}
}
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
}
@@ -1187,6 +1206,7 @@ mod tests {
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
@@ -1380,6 +1400,7 @@ mod tests {
let tx = AppEventSender::new(tx);
let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
@@ -1424,6 +1445,7 @@ mod tests {
};
let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
@@ -1509,6 +1531,7 @@ mod tests {
let thread_id = ThreadId::new();
let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id,
thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(),
@@ -1544,6 +1567,7 @@ mod tests {
keymap.approval.open_thread = vec![key_hint::plain(KeyCode::Char('x'))];
let mut view = make_overlay_with_keymap(
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id,
thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(),
@@ -1583,6 +1607,7 @@ mod tests {
let tx = AppEventSender::new(tx);
let view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(),
@@ -1612,6 +1637,7 @@ mod tests {
let tx = AppEventSender::new(tx);
let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
@@ -1665,6 +1691,7 @@ mod tests {
let tx = AppEventSender::new(tx);
let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
@@ -1705,6 +1732,7 @@ mod tests {
let tx = AppEventSender::new(tx);
let command = vec!["echo".into(), "hello".into(), "world".into()];
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
@@ -2006,6 +2034,7 @@ mod tests {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
@@ -2063,6 +2092,7 @@ mod tests {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
@@ -2188,6 +2218,7 @@ mod tests {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
@@ -2325,6 +2356,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
+1
View File
@@ -2011,6 +2011,7 @@ mod tests {
fn exec_request() -> ApprovalRequest {
ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: codex_protocol::ThreadId::new(),
thread_label: None,
id: "1".to_string(),