Bind final-message output before running sandboxed tools (#58974)

## Why

`codex exec --output-last-message` wrote through a pathname after tools finished, allowing sandbox activity to redirect the final write by replacing that path with a symlink. The related risk is described in https://github.com/openai/codex/pull/25956.

## What changed

- Open the destination before starting the agent and retain its handle until the final write.
- Reject symlinks, Windows reparse points, and multiply linked files; verify that the pathname still identifies the bound file before truncating and after writing.
- Propagate output errors so the command fails when the destination cannot be opened or written safely.
- Preserve inherited Unix output streams, FIFOs, devices, and Windows `NUL` support, while avoiding duplicate final answers when human output targets stdout.

## Testing

Add regression coverage for sandbox-created symlinks across consecutive invocations, redirected stdout, delayed truncation, hard links, replaced paths, and Windows stream binding and filesystem identity checks.

GitOrigin-RevId: 4a31bd28a5eb2d0a9e200cb658eb7d19dc93e933
This commit is contained in:
viyatb-oai
2026-11-02 20:36:39 +00:00
committed by copyberry
parent 9c23d93d5b
commit 636cdcb7ec
17 changed files with 788 additions and 54 deletions
+12 -13
View File
@@ -1,7 +1,9 @@
use anyhow::Context;
use std::path::Path;
use codex_app_server_protocol::ServerNotification;
use codex_core::config::Config;
use codex_exec_server::BoundOutput;
use codex_protocol::protocol::SessionConfiguredEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -25,24 +27,21 @@ pub(crate) trait EventProcessor {
/// Handle a local exec warning that is not represented as an app-server notification.
fn process_warning(&mut self, message: String) -> CodexStatus;
fn print_final_output(&mut self) {}
fn print_final_output(&mut self, output: Option<BoundOutput>) -> anyhow::Result<()>;
}
pub(crate) fn handle_last_message(last_agent_message: Option<&str>, output_file: &Path) {
let message = last_agent_message.unwrap_or_default();
write_last_message_file(message, Some(output_file));
pub(crate) fn handle_last_message(
last_agent_message: Option<&str>,
path: &Path,
file: BoundOutput,
) -> anyhow::Result<()> {
file.write(last_agent_message.unwrap_or_default().as_bytes())
.with_context(|| format!("Failed to write last message file {path:?}"))?;
if last_agent_message.is_none() {
eprintln!(
"Warning: no last agent message; wrote empty content to {}",
output_file.display()
path.display()
);
}
}
fn write_last_message_file(contents: &str, last_message_path: Option<&Path>) {
if let Some(path) = last_message_path
&& let Err(e) = std::fs::write(path, contents)
{
eprintln!("Failed to write last message file {path:?}: {e}");
}
Ok(())
}