mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
## 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
111 lines
4.3 KiB
Rust
111 lines
4.3 KiB
Rust
#![cfg(unix)]
|
|
|
|
//! Exercise the CLI output path used by automation with a synthetic model response.
|
|
|
|
use core_test_support::responses;
|
|
use core_test_support::test_codex_exec::test_codex_exec;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn human_output_to_redirected_stdout_prints_one_answer() -> anyhow::Result<()> {
|
|
core_test_support::skip_if_no_network!(Ok(()));
|
|
let test = test_codex_exec();
|
|
let server = responses::start_mock_server().await;
|
|
let command = test.cmd_with_server(&server);
|
|
let saved = test.cwd_path().join("stdout");
|
|
for alias in ["/dev/stdout", "/dev/fd/1", "/proc/self/fd/1"] {
|
|
responses::mount_sse_once(
|
|
&server,
|
|
responses::sse(vec![
|
|
responses::ev_response_created("r"),
|
|
responses::ev_assistant_message("m", "done"),
|
|
responses::ev_completed("r"),
|
|
]),
|
|
)
|
|
.await;
|
|
let output = std::process::Command::new(command.get_program())
|
|
.args(command.get_args())
|
|
.envs(command.get_envs().filter_map(|(k, v)| v.map(|v| (k, v))))
|
|
.current_dir(test.cwd_path())
|
|
.args(["--skip-git-repo-check", "-o", alias, "reply"])
|
|
.stdout(std::fs::File::create(&saved)?)
|
|
.output()?;
|
|
assert!(output.status.success(), "{output:?}");
|
|
assert_eq!(std::fs::read(&saved)?, b"done\n", "{alias}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn sandbox_link_cannot_redirect_this_or_the_next_invocation() -> anyhow::Result<()> {
|
|
core_test_support::skip_if_no_network!(Ok(()));
|
|
core_test_support::skip_if_sandbox!(Ok(()));
|
|
#[cfg(target_os = "linux")]
|
|
if super::sandbox::linux_sandbox_test_env().await.is_none() {
|
|
return Ok(());
|
|
}
|
|
for mode in [None, Some("--json")] {
|
|
let test = test_codex_exec();
|
|
let outside = tempfile::tempdir()?;
|
|
let target = outside.path().join("victim.txt");
|
|
let output = test.cwd_path().join("codex-output.md");
|
|
std::fs::write(&target, "original victim")?;
|
|
std::fs::write(&output, "original output")?;
|
|
let mut original = std::fs::File::open(&output)?;
|
|
let quoted_target = format!("'{}'", target.display().to_string().replace('\'', "'\\''"));
|
|
let attack = format!(
|
|
"if (printf forbidden > {quoted_target}) 2>/dev/null; then exit 1; fi; ln -sfn -- {quoted_target} codex-output.md"
|
|
);
|
|
let server = responses::start_mock_server().await;
|
|
let args = serde_json::json!({"cmd": attack, "shell": "/bin/sh", "login": false, "yield_time_ms": 10_000});
|
|
responses::mount_function_call_agent_response(
|
|
&server,
|
|
"plant-output",
|
|
&args.to_string(),
|
|
"exec_command",
|
|
)
|
|
.await;
|
|
let command = || {
|
|
let mut cmd = test.cmd_with_server(&server);
|
|
cmd.args(["--skip-git-repo-check", "--output-last-message"])
|
|
.arg(&output);
|
|
cmd.args(mode);
|
|
cmd
|
|
};
|
|
let mut cmd = command();
|
|
cmd.args([
|
|
"--sandbox",
|
|
"workspace-write",
|
|
"-c",
|
|
"approval_policy='never'",
|
|
"-c",
|
|
"sandbox_workspace_write.exclude_slash_tmp=true",
|
|
"-c",
|
|
"sandbox_workspace_write.exclude_tmpdir_env_var=true",
|
|
]);
|
|
cmd.arg("write a response")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicates::str::contains(
|
|
"Failed to write last message file",
|
|
));
|
|
let mut saved = String::new();
|
|
std::io::Read::read_to_string(&mut original, &mut saved)?;
|
|
assert_eq!(saved, "original output");
|
|
assert_eq!(std::fs::read_to_string(&target)?, "original victim");
|
|
assert_eq!(std::fs::read_link(&output)?, target);
|
|
|
|
// The link left by the first invocation must not gain authority on the next one.
|
|
command()
|
|
.arg("write another response")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicates::str::contains(
|
|
"Failed to open last message file",
|
|
));
|
|
|
|
assert_eq!(std::fs::read_to_string(&target)?, "original victim");
|
|
}
|
|
Ok(())
|
|
}
|