mirror of
https://github.com/openai/codex.git
synced 2026-04-27 08:05:51 +00:00
## Summary - launch Windows sandboxed children on a private desktop instead of `Winsta0\Default` - make private desktop the default while keeping `windows.sandbox_private_desktop=false` as the escape hatch - centralize process launch through the shared `create_process_as_user(...)` path - scope the private desktop ACL to the launching logon SID ## Why Today sandboxed Windows commands run on the visible shared desktop. That leaves an avoidable same-desktop attack surface for window interaction, spoofing, and related UI/input issues. This change moves sandboxed commands onto a dedicated per-launch desktop by default so the sandbox no longer shares `Winsta0\Default` with the user session. The implementation stays conservative on security with no silent fallback back to `Winsta0\Default` If private-desktop setup fails on a machine, users can still opt out explicitly with `windows.sandbox_private_desktop=false`. ## Validation - `cargo build -p codex-cli` - elevated-path `codex exec` desktop-name probe returned `CodexSandboxDesktop-*` - elevated-path `codex exec` smoke sweep for shell commands, nested `pwsh`, jobs, and hidden `notepad` launch - unelevated-path full private-desktop compatibility sweep via `codex exec` with `-c windows.sandbox=unelevated`
326 lines
11 KiB
Rust
326 lines
11 KiB
Rust
#![cfg(target_os = "windows")]
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use codex_windows_sandbox::allow_null_device;
|
|
use codex_windows_sandbox::convert_string_sid_to_sid;
|
|
use codex_windows_sandbox::create_process_as_user;
|
|
use codex_windows_sandbox::create_readonly_token_with_caps_from;
|
|
use codex_windows_sandbox::create_workspace_write_token_with_caps_from;
|
|
use codex_windows_sandbox::get_current_token_for_restriction;
|
|
use codex_windows_sandbox::hide_current_user_profile_dir;
|
|
use codex_windows_sandbox::log_note;
|
|
use codex_windows_sandbox::parse_policy;
|
|
use codex_windows_sandbox::to_wide;
|
|
use codex_windows_sandbox::SandboxPolicy;
|
|
use serde::Deserialize;
|
|
use std::collections::HashMap;
|
|
use std::ffi::c_void;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use windows_sys::Win32::Foundation::CloseHandle;
|
|
use windows_sys::Win32::Foundation::GetLastError;
|
|
use windows_sys::Win32::Foundation::LocalFree;
|
|
use windows_sys::Win32::Foundation::HANDLE;
|
|
use windows_sys::Win32::Foundation::HLOCAL;
|
|
use windows_sys::Win32::Storage::FileSystem::CreateFileW;
|
|
use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ;
|
|
use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE;
|
|
use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING;
|
|
use windows_sys::Win32::System::Diagnostics::Debug::SetErrorMode;
|
|
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
|
|
use windows_sys::Win32::System::JobObjects::CreateJobObjectW;
|
|
use windows_sys::Win32::System::JobObjects::JobObjectExtendedLimitInformation;
|
|
use windows_sys::Win32::System::JobObjects::SetInformationJobObject;
|
|
use windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION;
|
|
use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
use windows_sys::Win32::System::Threading::TerminateProcess;
|
|
use windows_sys::Win32::System::Threading::WaitForSingleObject;
|
|
use windows_sys::Win32::System::Threading::INFINITE;
|
|
|
|
#[path = "cwd_junction.rs"]
|
|
mod cwd_junction;
|
|
|
|
#[allow(dead_code)]
|
|
mod read_acl_mutex;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RunnerRequest {
|
|
policy_json_or_preset: String,
|
|
// Writable location for logs (sandbox user's .codex).
|
|
codex_home: PathBuf,
|
|
// Real user's CODEX_HOME for shared data (caps, config).
|
|
real_codex_home: PathBuf,
|
|
cap_sids: Vec<String>,
|
|
command: Vec<String>,
|
|
cwd: PathBuf,
|
|
env_map: HashMap<String, String>,
|
|
timeout_ms: Option<u64>,
|
|
use_private_desktop: bool,
|
|
stdin_pipe: String,
|
|
stdout_pipe: String,
|
|
stderr_pipe: String,
|
|
}
|
|
|
|
const WAIT_TIMEOUT: u32 = 0x0000_0102;
|
|
|
|
unsafe fn create_job_kill_on_close() -> Result<HANDLE> {
|
|
let h = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
|
|
if h == 0 {
|
|
return Err(anyhow::anyhow!("CreateJobObjectW failed"));
|
|
}
|
|
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
|
|
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
let ok = SetInformationJobObject(
|
|
h,
|
|
JobObjectExtendedLimitInformation,
|
|
&mut limits as *mut _ as *mut _,
|
|
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
);
|
|
if ok == 0 {
|
|
return Err(anyhow::anyhow!("SetInformationJobObject failed"));
|
|
}
|
|
Ok(h)
|
|
}
|
|
|
|
fn read_request_file(req_path: &Path) -> Result<String> {
|
|
let content = std::fs::read_to_string(req_path)
|
|
.with_context(|| format!("read request file {}", req_path.display()));
|
|
let _ = std::fs::remove_file(req_path);
|
|
content
|
|
}
|
|
|
|
pub fn main() -> Result<()> {
|
|
let mut input = String::new();
|
|
let mut args = std::env::args().skip(1);
|
|
if let Some(first) = args.next() {
|
|
if let Some(rest) = first.strip_prefix("--request-file=") {
|
|
let req_path = PathBuf::from(rest);
|
|
input = read_request_file(&req_path)?;
|
|
}
|
|
}
|
|
if input.is_empty() {
|
|
anyhow::bail!("runner: no request-file provided");
|
|
}
|
|
let req: RunnerRequest = serde_json::from_str(&input).context("parse runner request json")?;
|
|
let log_dir = Some(req.codex_home.as_path());
|
|
hide_current_user_profile_dir(req.codex_home.as_path());
|
|
// Suppress Windows error UI from sandboxed child crashes so callers only observe exit codes.
|
|
let _ = unsafe { SetErrorMode(0x0001 | 0x0002) }; // SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX
|
|
log_note(
|
|
&format!(
|
|
"runner start cwd={} cmd={:?} real_codex_home={}",
|
|
req.cwd.display(),
|
|
req.command,
|
|
req.real_codex_home.display()
|
|
),
|
|
Some(&req.codex_home),
|
|
);
|
|
|
|
let policy = parse_policy(&req.policy_json_or_preset).context("parse policy_json_or_preset")?;
|
|
if !policy.has_full_disk_read_access() {
|
|
anyhow::bail!(
|
|
"Restricted read-only access is not yet supported by the Windows sandbox backend"
|
|
);
|
|
}
|
|
let mut cap_psids: Vec<*mut c_void> = Vec::new();
|
|
for sid in &req.cap_sids {
|
|
let Some(psid) = (unsafe { convert_string_sid_to_sid(sid) }) else {
|
|
anyhow::bail!("ConvertStringSidToSidW failed for capability SID");
|
|
};
|
|
cap_psids.push(psid);
|
|
}
|
|
if cap_psids.is_empty() {
|
|
anyhow::bail!("runner: empty capability SID list");
|
|
}
|
|
|
|
// Create restricted token from current process token.
|
|
let base = unsafe { get_current_token_for_restriction()? };
|
|
let token_res: Result<HANDLE> = unsafe {
|
|
match &policy {
|
|
SandboxPolicy::ReadOnly { .. } => {
|
|
create_readonly_token_with_caps_from(base, &cap_psids)
|
|
}
|
|
SandboxPolicy::WorkspaceWrite { .. } => {
|
|
create_workspace_write_token_with_caps_from(base, &cap_psids)
|
|
}
|
|
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
|
|
unreachable!()
|
|
}
|
|
}
|
|
};
|
|
let h_token = token_res?;
|
|
unsafe {
|
|
CloseHandle(base);
|
|
}
|
|
unsafe {
|
|
for psid in &cap_psids {
|
|
allow_null_device(*psid);
|
|
}
|
|
for psid in cap_psids {
|
|
if !psid.is_null() {
|
|
LocalFree(psid as HLOCAL);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Open named pipes for stdio.
|
|
let open_pipe = |name: &str, access: u32| -> Result<HANDLE> {
|
|
let path = to_wide(name);
|
|
let handle = unsafe {
|
|
CreateFileW(
|
|
path.as_ptr(),
|
|
access,
|
|
0,
|
|
std::ptr::null_mut(),
|
|
OPEN_EXISTING,
|
|
0,
|
|
0,
|
|
)
|
|
};
|
|
if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE {
|
|
let err = unsafe { GetLastError() };
|
|
log_note(
|
|
&format!("CreateFileW failed for pipe {name}: {err}"),
|
|
Some(&req.codex_home),
|
|
);
|
|
return Err(anyhow::anyhow!("CreateFileW failed for pipe {name}: {err}"));
|
|
}
|
|
Ok(handle)
|
|
};
|
|
let h_stdin = open_pipe(&req.stdin_pipe, FILE_GENERIC_READ)?;
|
|
let h_stdout = open_pipe(&req.stdout_pipe, FILE_GENERIC_WRITE)?;
|
|
let h_stderr = open_pipe(&req.stderr_pipe, FILE_GENERIC_WRITE)?;
|
|
let stdio = Some((h_stdin, h_stdout, h_stderr));
|
|
|
|
// While the read-ACL helper is running, PowerShell can fail to start in the requested CWD due
|
|
// to unreadable ancestors. Use a junction CWD for that window; once the helper finishes, go
|
|
// back to using the real requested CWD (no probing, no extra state).
|
|
let use_junction = match read_acl_mutex::read_acl_mutex_exists() {
|
|
Ok(exists) => exists,
|
|
Err(err) => {
|
|
// Fail-safe: if we can't determine the state, assume the helper might be running and
|
|
// use the junction path to avoid CWD failures on unreadable ancestors.
|
|
log_note(
|
|
&format!("junction: read_acl_mutex_exists failed: {err}; assuming read ACL helper is running"),
|
|
log_dir,
|
|
);
|
|
true
|
|
}
|
|
};
|
|
if use_junction {
|
|
log_note(
|
|
"junction: read ACL helper running; using junction CWD",
|
|
log_dir,
|
|
);
|
|
}
|
|
let effective_cwd = if use_junction {
|
|
cwd_junction::create_cwd_junction(&req.cwd, log_dir).unwrap_or_else(|| req.cwd.clone())
|
|
} else {
|
|
req.cwd.clone()
|
|
};
|
|
log_note(
|
|
&format!(
|
|
"runner: effective cwd={} (requested {})",
|
|
effective_cwd.display(),
|
|
req.cwd.display()
|
|
),
|
|
log_dir,
|
|
);
|
|
|
|
// Build command and env, spawn with CreateProcessAsUserW.
|
|
let spawn_result = unsafe {
|
|
create_process_as_user(
|
|
h_token,
|
|
&req.command,
|
|
&effective_cwd,
|
|
&req.env_map,
|
|
Some(&req.codex_home),
|
|
stdio,
|
|
req.use_private_desktop,
|
|
)
|
|
};
|
|
let created = match spawn_result {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
log_note(&format!("runner: spawn failed: {e:?}"), log_dir);
|
|
unsafe {
|
|
CloseHandle(h_stdin);
|
|
CloseHandle(h_stdout);
|
|
CloseHandle(h_stderr);
|
|
CloseHandle(h_token);
|
|
}
|
|
return Err(e);
|
|
}
|
|
};
|
|
let proc_info = created.process_info;
|
|
let _desktop = created;
|
|
|
|
// Optional job kill on close.
|
|
let h_job = unsafe { create_job_kill_on_close().ok() };
|
|
if let Some(job) = h_job {
|
|
unsafe {
|
|
let _ = AssignProcessToJobObject(job, proc_info.hProcess);
|
|
}
|
|
}
|
|
|
|
// Wait for process.
|
|
let wait_res = unsafe {
|
|
WaitForSingleObject(
|
|
proc_info.hProcess,
|
|
req.timeout_ms.map(|ms| ms as u32).unwrap_or(INFINITE),
|
|
)
|
|
};
|
|
let timed_out = wait_res == WAIT_TIMEOUT;
|
|
|
|
let exit_code: i32;
|
|
unsafe {
|
|
if timed_out {
|
|
let _ = TerminateProcess(proc_info.hProcess, 1);
|
|
exit_code = 128 + 64;
|
|
} else {
|
|
let mut raw_exit: u32 = 1;
|
|
windows_sys::Win32::System::Threading::GetExitCodeProcess(
|
|
proc_info.hProcess,
|
|
&mut raw_exit,
|
|
);
|
|
exit_code = raw_exit as i32;
|
|
}
|
|
if proc_info.hThread != 0 {
|
|
CloseHandle(proc_info.hThread);
|
|
}
|
|
if proc_info.hProcess != 0 {
|
|
CloseHandle(proc_info.hProcess);
|
|
}
|
|
CloseHandle(h_stdin);
|
|
CloseHandle(h_stdout);
|
|
CloseHandle(h_stderr);
|
|
CloseHandle(h_token);
|
|
if let Some(job) = h_job {
|
|
CloseHandle(job);
|
|
}
|
|
}
|
|
if exit_code != 0 {
|
|
eprintln!("runner child exited with code {}", exit_code);
|
|
}
|
|
std::process::exit(exit_code);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::read_request_file;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
|
|
#[test]
|
|
fn removes_request_file_after_read() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let req_path = dir.path().join("request.json");
|
|
fs::write(&req_path, "{\"ok\":true}").expect("write request");
|
|
|
|
let content = read_request_file(&req_path).expect("read request");
|
|
assert_eq!(content, "{\"ok\":true}");
|
|
assert!(!req_path.exists(), "request file should be removed");
|
|
}
|
|
}
|