feat: update sandbox policy to allow TTY (#7580)
**Change**: Seatbelt now allows file-ioctl on /dev/ttys[0-9]+ even without the sandbox extension so pre-created PTYs remain interactive (Python REPL, shells). **Risk**: A seatbelted process that already holds a PTY fd (including one it shouldn’t) could issue tty ioctls like TIOCSTI or termios changes on that fd. This doesn’t allow opening new PTYs or reading/writing them; it only broadens ioctl capability on existing fds. **Why acceptable**: We already hand the child its PTY for interactive use; restoring ioctls is required for isatty() and prompts to work. The attack requires being given or inheriting a sensitive PTY fd; by design we don’t hand untrusted processes other users’ PTYs (we don't hand them any PTYs actually), so the practical exposure is limited to the PTY intentionally allocated for the session. **Validation**: Running ``` start a python interpreter and keep it running ``` Followed by: * `calculate 1+1 using it` -> works as expected * `Use this Python session to run the command just fix in /Users/jif/code/codex/codex-rs` -> does not work as expected
This commit is contained in:
parent
404a1ea34b
commit
2b5d0b2935
2 changed files with 127 additions and 0 deletions
|
|
@ -102,3 +102,6 @@
|
|||
(require-all
|
||||
(regex #"^/dev/ttys[0-9]+")
|
||||
(extension "com.apple.sandbox.pty")))
|
||||
; PTYs created before entering seatbelt may lack the extension; allow ioctl
|
||||
; on those slave ttys so interactive shells detect a TTY and remain functional.
|
||||
(allow file-ioctl (regex #"^/dev/ttys[0-9]+"))
|
||||
|
|
|
|||
|
|
@ -1943,6 +1943,130 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let python = match which::which("python").or_else(|_| which::which("python3")) {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
eprintln!("python not found in PATH, skipping test.");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config.features.enable(Feature::UnifiedExec);
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let startup_call_id = "uexec-python-seatbelt";
|
||||
let startup_args = serde_json::json!({
|
||||
"cmd": format!("{} -i", python.display()),
|
||||
"yield_time_ms": 750,
|
||||
});
|
||||
|
||||
let exit_call_id = "uexec-python-exit";
|
||||
let exit_args = serde_json::json!({
|
||||
"chars": "exit()\n",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 750,
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
startup_call_id,
|
||||
"exec_command",
|
||||
&serde_json::to_string(&startup_args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_function_call(
|
||||
exit_call_id,
|
||||
"write_stdin",
|
||||
&serde_json::to_string(&exit_args)?,
|
||||
),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "start python under seatbelt".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
|
||||
let bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
let startup_output = outputs
|
||||
.get(startup_call_id)
|
||||
.expect("missing python startup output");
|
||||
|
||||
let output_text = startup_output.output.replace("\r\n", "\n");
|
||||
// This assert that we are in a TTY.
|
||||
assert!(
|
||||
output_text.contains(">>>"),
|
||||
"python prompt missing from seatbelt output: {output_text:?}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
startup_output.process_id.as_deref(),
|
||||
Some("1000"),
|
||||
"python session should stay alive for follow-up input"
|
||||
);
|
||||
|
||||
let exit_output = outputs
|
||||
.get(exit_call_id)
|
||||
.expect("missing python exit output");
|
||||
|
||||
assert_eq!(
|
||||
exit_output.exit_code,
|
||||
Some(0),
|
||||
"python should exit cleanly after exit()"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue