From 2b5d0b2935209dee8c1d07c9ee02f3899705c63b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 4 Dec 2025 17:58:58 +0000 Subject: [PATCH] feat: update sandbox policy to allow TTY (#7580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- codex-rs/core/src/seatbelt_base_policy.sbpl | 3 + codex-rs/core/tests/suite/unified_exec.rs | 124 ++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/codex-rs/core/src/seatbelt_base_policy.sbpl b/codex-rs/core/src/seatbelt_base_policy.sbpl index 8ccfa6e82..236f7a139 100644 --- a/codex-rs/core/src/seatbelt_base_policy.sbpl +++ b/codex-rs/core/src/seatbelt_base_policy.sbpl @@ -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]+")) diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index dc7bdb6b1..5e8f5a8cd 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -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::().expect("request json")) + .collect::>(); + + 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<()> {