diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7bd60b12fb..64c83b0f55 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -983,6 +983,7 @@ dependencies = [ "codex-rmcp-client", "codex-stdio-to-uds", "codex-tui", + "codex-windows-sandbox", "ctor 0.5.0", "owo-colors", "predicates", @@ -1072,6 +1073,7 @@ dependencies = [ "codex-utils-readiness", "codex-utils-string", "codex-utils-tokenizer", + "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", "dirs", @@ -1551,6 +1553,18 @@ dependencies = [ "tiktoken-rs", ] +[[package]] +name = "codex-windows-sandbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs-next", + "rand 0.8.5", + "serde", + "serde_json", + "windows-sys 0.52.0", +] + [[package]] name = "color-eyre" version = "0.6.5" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a2c52f44ad..3649ab4fe1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -87,6 +87,7 @@ codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-string = { path = "utils/string" } codex-utils-tokenizer = { path = "utils/tokenizer" } +codex-windows-sandbox = { path = "windows-sandbox" } core_test_support = { path = "core/tests/common" } mcp-types = { path = "mcp-types" } mcp_test_support = { path = "mcp-server/tests/common" } @@ -210,6 +211,7 @@ walkdir = "2.5.0" webbrowser = "1.0" which = "6" wildmatch = "2.5.0" + wiremock = "0.6" zeroize = "1.8.1" diff --git a/codex-rs/README.md b/codex-rs/README.md index e7dfadb789..36143558d3 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -63,6 +63,9 @@ codex sandbox macos [--full-auto] [COMMAND]... # Linux codex sandbox linux [--full-auto] [COMMAND]... +# Windows +codex sandbox windows [--full-auto] [COMMAND]... + # Legacy aliases codex debug seatbelt [--full-auto] [COMMAND]... codex debug landlock [--full-auto] [COMMAND]... diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index d66678687b..b5c5b33b8c 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -47,6 +47,9 @@ tokio = { workspace = true, features = [ "signal", ] } +[target.'cfg(target_os = "windows")'.dependencies] +codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } + [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index cde1f70839..889bc5a694 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -11,6 +11,7 @@ use codex_protocol::config_types::SandboxMode; use crate::LandlockCommand; use crate::SeatbeltCommand; +use crate::WindowsCommand; use crate::exit_status::handle_exit_status; pub async fn run_command_under_seatbelt( @@ -51,9 +52,29 @@ pub async fn run_command_under_landlock( .await } +pub async fn run_command_under_windows( + command: WindowsCommand, + codex_linux_sandbox_exe: Option, +) -> anyhow::Result<()> { + let WindowsCommand { + full_auto, + config_overrides, + command, + } = command; + run_command_under_sandbox( + full_auto, + command, + config_overrides, + codex_linux_sandbox_exe, + SandboxType::Windows, + ) + .await +} + enum SandboxType { Seatbelt, Landlock, + Windows, } async fn run_command_under_sandbox( @@ -87,6 +108,63 @@ async fn run_command_under_sandbox( let stdio_policy = StdioPolicy::Inherit; let env = create_env(&config.shell_environment_policy); + // Special-case Windows sandbox: execute and exit the process to emulate inherited stdio. + if let SandboxType::Windows = sandbox_type { + #[cfg(target_os = "windows")] + { + use codex_windows_sandbox::run_windows_sandbox_capture; + + let policy_str = match &config.sandbox_policy { + codex_core::protocol::SandboxPolicy::DangerFullAccess => "workspace-write", + codex_core::protocol::SandboxPolicy::ReadOnly => "read-only", + codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", + }; + + let sandbox_cwd = sandbox_policy_cwd.clone(); + let cwd_clone = cwd.clone(); + let env_map = env.clone(); + let command_vec = command.clone(); + let res = tokio::task::spawn_blocking(move || { + run_windows_sandbox_capture( + policy_str, + &sandbox_cwd, + command_vec, + &cwd_clone, + env_map, + None, + ) + }) + .await; + + let capture = match res { + Ok(Ok(v)) => v, + Ok(Err(err)) => { + eprintln!("windows sandbox failed: {err}"); + std::process::exit(1); + } + Err(join_err) => { + eprintln!("windows sandbox join error: {join_err}"); + std::process::exit(1); + } + }; + + if !capture.stdout.is_empty() { + use std::io::Write; + let _ = std::io::stdout().write_all(&capture.stdout); + } + if !capture.stderr.is_empty() { + use std::io::Write; + let _ = std::io::stderr().write_all(&capture.stderr); + } + + std::process::exit(capture.exit_code); + } + #[cfg(not(target_os = "windows"))] + { + anyhow::bail!("Windows sandbox is only available on Windows"); + } + } + let mut child = match sandbox_type { SandboxType::Seatbelt => { spawn_command_under_seatbelt( @@ -115,6 +193,9 @@ async fn run_command_under_sandbox( ) .await? } + SandboxType::Windows => { + unreachable!("Windows sandbox should have been handled above"); + } }; let status = child.wait().await?; diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index c05d570e64..0cd2789663 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -32,3 +32,17 @@ pub struct LandlockCommand { #[arg(trailing_var_arg = true)] pub command: Vec, } + +#[derive(Debug, Parser)] +pub struct WindowsCommand { + /// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR) + #[arg(long = "full-auto", default_value_t = false)] + pub full_auto: bool, + + #[clap(skip)] + pub config_overrides: CliConfigOverrides, + + /// Full command args to run under Windows restricted token sandbox. + #[arg(trailing_var_arg = true)] + pub command: Vec, +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 90d6440cf4..9b2d106c0f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -7,6 +7,7 @@ use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; +use codex_cli::WindowsCommand; use codex_cli::login::read_api_key_from_stdin; use codex_cli::login::run_login_status; use codex_cli::login::run_login_with_api_key; @@ -151,6 +152,9 @@ enum SandboxCommand { /// Run a command under Landlock+seccomp (Linux only). #[clap(visible_alias = "landlock")] Linux(LandlockCommand), + + /// Run a command under Windows restricted token (Windows only). + Windows(WindowsCommand), } #[derive(Debug, Parser)] @@ -472,6 +476,17 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ) .await?; } + SandboxCommand::Windows(mut windows_cli) => { + prepend_config_flags( + &mut windows_cli.config_overrides, + root_config_overrides.clone(), + ); + codex_cli::debug_sandbox::run_command_under_windows( + windows_cli, + codex_linux_sandbox_exe, + ) + .await?; + } }, Some(Subcommand::Apply(mut apply_cli)) => { prepend_config_flags( @@ -497,7 +512,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() // Respect root-level `-c` overrides plus top-level flags like `--profile`. let cli_kv_overrides = root_config_overrides .parse_overrides() - .map_err(|e| anyhow::anyhow!(e))?; + .map_err(anyhow::Error::msg)?; // Thread through relevant top-level flags (at minimum, `--profile`). // Also honor `--search` since it maps to a feature toggle. diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index b8ad63ef23..274717b318 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -196,7 +196,9 @@ impl McpCli { async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { // Validate any provided overrides even though they are not currently applied. - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; @@ -310,7 +312,9 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re } async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> { - config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let RemoveArgs { name } = remove_args; @@ -341,7 +345,9 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr } async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; @@ -380,7 +386,9 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) } async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; @@ -407,7 +415,9 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr } async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; @@ -662,7 +672,9 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 921ca2843a..2b48c088fd 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -83,6 +83,7 @@ tree-sitter-bash = { workspace = true } uuid = { workspace = true, features = ["serde", "v4"] } which = { workspace = true } wildmatch = { workspace = true } +codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9293e4d52a..1ee48d30bc 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -769,6 +769,8 @@ impl ConfigToml { let mut forced_auto_mode_downgraded_on_windows = false; if cfg!(target_os = "windows") && matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) + // If the experimental Windows sandbox is enabled, do not force a downgrade. + && crate::safety::get_platform_sandbox().is_none() { sandbox_policy = SandboxPolicy::new_read_only_policy(); forced_auto_mode_downgraded_on_windows = true; @@ -900,6 +902,10 @@ impl Config { }; let features = Features::from_config(&cfg, &config_profile, feature_overrides); + #[cfg(target_os = "windows")] + { + crate::safety::set_windows_sandbox_enabled(features.enabled(Feature::WindowsSandbox)); + } let resolved_cwd = { use std::env; diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index d2fa9735d0..e8eb064498 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -72,6 +72,9 @@ pub enum SandboxType { /// Only available on Linux. LinuxSeccomp, + + /// Only available on Windows. + WindowsRestrictedToken, } #[derive(Clone)] @@ -158,11 +161,79 @@ pub(crate) async fn execute_exec_env( }; let start = Instant::now(); - let raw_output_result = exec(params, sandbox_policy, stdout_stream).await; + let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream).await; let duration = start.elapsed(); finalize_exec_result(raw_output_result, sandbox, duration) } +#[cfg(target_os = "windows")] +async fn exec_windows_sandbox( + params: ExecParams, + sandbox_policy: &SandboxPolicy, +) -> Result { + use codex_windows_sandbox::run_windows_sandbox_capture; + + let ExecParams { + command, + cwd, + env, + timeout_ms, + .. + } = params; + + let policy_str = match sandbox_policy { + SandboxPolicy::DangerFullAccess => "workspace-write", + SandboxPolicy::ReadOnly => "read-only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", + }; + + let sandbox_cwd = cwd.clone(); + let spawn_res = tokio::task::spawn_blocking(move || { + run_windows_sandbox_capture(policy_str, &sandbox_cwd, command, &cwd, env, timeout_ms) + }) + .await; + + let capture = match spawn_res { + Ok(Ok(v)) => v, + Ok(Err(err)) => { + return Err(CodexErr::Io(io::Error::other(format!( + "windows sandbox: {err}" + )))); + } + Err(join_err) => { + return Err(CodexErr::Io(io::Error::other(format!( + "windows sandbox join error: {join_err}" + )))); + } + }; + + let exit_status = synthetic_exit_status(capture.exit_code); + let stdout = StreamOutput { + text: capture.stdout, + truncated_after_lines: None, + }; + let stderr = StreamOutput { + text: capture.stderr, + truncated_after_lines: None, + }; + // Best-effort aggregate: stdout then stderr + let mut aggregated = Vec::with_capacity(stdout.text.len() + stderr.text.len()); + append_all(&mut aggregated, &stdout.text); + append_all(&mut aggregated, &stderr.text); + let aggregated_output = StreamOutput { + text: aggregated, + truncated_after_lines: None, + }; + + Ok(RawExecToolCallOutput { + exit_status, + stdout, + stderr, + aggregated_output, + timed_out: capture.timed_out, + }) +} + fn finalize_exec_result( raw_output_result: std::result::Result, sandbox_type: SandboxType, @@ -347,11 +418,17 @@ pub struct ExecToolCallOutput { pub timed_out: bool, } +#[cfg_attr(not(target_os = "windows"), allow(unused_variables))] async fn exec( params: ExecParams, + sandbox: SandboxType, sandbox_policy: &SandboxPolicy, stdout_stream: Option, ) -> Result { + #[cfg(target_os = "windows")] + if sandbox == SandboxType::WindowsRestrictedToken { + return exec_windows_sandbox(params, sandbox_policy).await; + } let timeout = params.timeout_duration(); let ExecParams { command, @@ -525,8 +602,9 @@ fn synthetic_exit_status(code: i32) -> ExitStatus { #[cfg(windows)] fn synthetic_exit_status(code: i32) -> ExitStatus { use std::os::windows::process::ExitStatusExt; - #[expect(clippy::unwrap_used)] - std::process::ExitStatus::from_raw(code.try_into().unwrap()) + // On Windows the raw status is a u32. Use a direct cast to avoid + // panicking on negative i32 values produced by prior narrowing casts. + std::process::ExitStatus::from_raw(code as u32) } #[cfg(test)] diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 5488d761df..f6cec4a5ea 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -43,6 +43,8 @@ pub enum Feature { SandboxCommandAssessment, /// Create a ghost commit at each turn. GhostCommit, + /// Enable Windows sandbox (restricted token) on Windows. + WindowsSandbox, } impl Feature { @@ -292,4 +294,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::WindowsSandbox, + key: "enable_experimental_windows_sandbox", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 4492947d58..1e81981fd1 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -10,6 +10,23 @@ use crate::exec::SandboxType; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; +#[cfg(target_os = "windows")] +use std::sync::atomic::AtomicBool; +#[cfg(target_os = "windows")] +use std::sync::atomic::Ordering; + +#[cfg(target_os = "windows")] +static WINDOWS_SANDBOX_ENABLED: AtomicBool = AtomicBool::new(false); + +#[cfg(target_os = "windows")] +pub fn set_windows_sandbox_enabled(enabled: bool) { + WINDOWS_SANDBOX_ENABLED.store(enabled, Ordering::Relaxed); +} + +#[cfg(not(target_os = "windows"))] +#[allow(dead_code)] +pub fn set_windows_sandbox_enabled(_enabled: bool) {} + #[derive(Debug, PartialEq)] pub enum SafetyCheck { AutoApprove { @@ -84,6 +101,14 @@ pub fn get_platform_sandbox() -> Option { Some(SandboxType::MacosSeatbelt) } else if cfg!(target_os = "linux") { Some(SandboxType::LinuxSeccomp) + } else if cfg!(target_os = "windows") { + #[cfg(target_os = "windows")] + { + if WINDOWS_SANDBOX_ENABLED.load(Ordering::Relaxed) { + return Some(SandboxType::WindowsRestrictedToken); + } + } + None } else { None } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index c11f3588fe..608b39ceef 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -74,25 +74,13 @@ impl SandboxManager { match pref { SandboxablePreference::Forbid => SandboxType::None, SandboxablePreference::Require => { - #[cfg(target_os = "macos")] - { - return SandboxType::MacosSeatbelt; - } - #[cfg(target_os = "linux")] - { - return SandboxType::LinuxSeccomp; - } - #[allow(unreachable_code)] - SandboxType::None + // Require a platform sandbox when available; on Windows this + // respects the enable_experimental_windows_sandbox feature. + crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None) } SandboxablePreference::Auto => match policy { SandboxPolicy::DangerFullAccess => SandboxType::None, - #[cfg(target_os = "macos")] - _ => SandboxType::MacosSeatbelt, - #[cfg(target_os = "linux")] - _ => SandboxType::LinuxSeccomp, - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - _ => SandboxType::None, + _ => crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None), }, } } @@ -143,6 +131,14 @@ impl SandboxManager { Some("codex-linux-sandbox".to_string()), ) } + // On Windows, the restricted token sandbox executes in-process via the + // codex-windows-sandbox crate. We leave the command unchanged here and + // branch during execution based on the sandbox type. + #[cfg(target_os = "windows")] + SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None), + // When building for non-Windows targets, this variant is never constructed. + #[cfg(not(target_os = "windows"))] + SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None), }; env.extend(sandbox_env); diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 9277af662d..f334c4ef55 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -83,6 +83,8 @@ impl ToolOrchestrator { if tool.wants_escalated_first_attempt(req) { initial_sandbox = crate::exec::SandboxType::None; } + // Platform-specific flag gating is handled by SandboxManager::select_initial + // via crate::safety::get_platform_sandbox(). let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, policy: &turn_ctx.sandbox_policy, diff --git a/codex-rs/scripts/setup-windows.ps1 b/codex-rs/scripts/setup-windows.ps1 new file mode 100644 index 0000000000..554dc63352 --- /dev/null +++ b/codex-rs/scripts/setup-windows.ps1 @@ -0,0 +1,246 @@ +<# + Setup script for building codex-rs on Windows. + + What it does: + - Installs Rust toolchain (via winget rustup) and required components + - Installs Visual Studio 2022 Build Tools (MSVC + Windows SDK) + - Installs helpful CLIs used by the repo: git, ripgrep (rg), just, cmake + - Installs cargo-insta (for snapshot tests) via cargo + - Ensures PATH contains Cargo bin for the current session + - Builds the workspace (cargo build) + + Usage: + - Right-click PowerShell and "Run as Administrator" (VS Build Tools require elevation) + - From the repo root (codex-rs), run: + powershell -ExecutionPolicy Bypass -File scripts/setup-windows.ps1 + + Notes: + - Requires winget (Windows Package Manager). Most modern Windows 10/11 have it preinstalled. + - The script is re-runnable; winget/cargo will skip/reinstall as appropriate. +#> + +param( + [switch] $SkipBuild +) + +$ErrorActionPreference = 'Stop' + +function Ensure-Command($Name) { + $exists = Get-Command $Name -ErrorAction SilentlyContinue + return $null -ne $exists +} + +function Add-CargoBinToPath() { + $cargoBin = Join-Path $env:USERPROFILE ".cargo\bin" + if (Test-Path $cargoBin) { + if (-not ($env:Path.Split(';') -contains $cargoBin)) { + $env:Path = "$env:Path;$cargoBin" + } + } +} + +function Ensure-UserPathContains([string] $Segment) { + try { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if ($null -eq $userPath) { $userPath = '' } + $parts = $userPath.Split(';') | Where-Object { $_ -ne '' } + if (-not ($parts -contains $Segment)) { + $newPath = if ($userPath) { "$userPath;$Segment" } else { $Segment } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + } + } catch {} +} + +function Ensure-UserEnvVar([string] $Name, [string] $Value) { + try { [Environment]::SetEnvironmentVariable($Name, $Value, 'User') } catch {} +} + +function Ensure-VSComponents([string[]]$Components) { + $vsInstaller = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vs_installer.exe" + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vsInstaller) -or -not (Test-Path $vswhere)) { return } + + $instPath = & $vswhere -latest -products * -version "[17.0,18.0)" -requires Microsoft.VisualStudio.Workload.VCTools -property installationPath 2>$null + if (-not $instPath) { + # 2022 instance may be present without VC Tools; pick BuildTools 2022 and add components + $instPath = & $vswhere -latest -products Microsoft.VisualStudio.Product.BuildTools -version "[17.0,18.0)" -property installationPath 2>$null + } + if (-not $instPath) { + $instPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Workload.VCTools -property installationPath 2>$null + } + if (-not $instPath) { + $default2022 = 'C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools' + if (Test-Path $default2022) { $instPath = $default2022 } + } + if (-not $instPath) { return } + + $vsDevCmd = Join-Path $instPath 'Common7\Tools\VsDevCmd.bat' + $verb = if (Test-Path $vsDevCmd) { 'modify' } else { 'install' } + $args = @($verb, '--installPath', $instPath, '--quiet', '--norestart', '--nocache') + if ($verb -eq 'install') { $args += @('--productId', 'Microsoft.VisualStudio.Product.BuildTools') } + foreach ($c in $Components) { $args += @('--add', $c) } + Write-Host "-- Ensuring VS components installed: $($Components -join ', ')" -ForegroundColor DarkCyan + & $vsInstaller @args | Out-Host +} + +function Enter-VsDevShell() { + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vswhere)) { return } + + $instPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null + if (-not $instPath) { + # Try ARM64 components + $instPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 -property installationPath 2>$null + } + if (-not $instPath) { return } + + $vsDevCmd = Join-Path $instPath 'Common7\Tools\VsDevCmd.bat' + if (-not (Test-Path $vsDevCmd)) { return } + + # Prefer ARM64 on ARM machines, otherwise x64 + $arch = if ($env:PROCESSOR_ARCHITEW6432 -eq 'ARM64' -or $env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' } + $devCmdStr = ('"{0}" -no_logo -arch={1} -host_arch={1} & set' -f $vsDevCmd, $arch) + $envLines = & cmd.exe /c $devCmdStr + foreach ($line in $envLines) { + if ($line -match '^(.*?)=(.*)$') { + $name = $matches[1] + $value = $matches[2] + try { [Environment]::SetEnvironmentVariable($name, $value, 'Process') } catch {} + } + } +} + +Write-Host "==> Installing prerequisites via winget (may take a while)" -ForegroundColor Cyan + +# Accept agreements up-front for non-interactive installs +$WingetArgs = @('--accept-package-agreements', '--accept-source-agreements', '-e') + +if (-not (Ensure-Command 'winget')) { + throw "winget is required. Please update to the latest Windows 10/11 or install winget." +} + +# 1) Visual Studio 2022 Build Tools (MSVC toolchain + Windows SDK) +# The VC Tools workload brings the required MSVC toolchains; include recommended components to pick up a Windows SDK. +Write-Host "-- Installing Visual Studio Build Tools (VC Tools workload + ARM64 toolchains)" -ForegroundColor DarkCyan +$vsOverride = @( + '--quiet', '--wait', '--norestart', '--nocache', + '--add', 'Microsoft.VisualStudio.Workload.VCTools', + '--add', 'Microsoft.VisualStudio.Component.VC.Tools.ARM64', + '--add', 'Microsoft.VisualStudio.Component.VC.Tools.ARM64EC', + '--add', 'Microsoft.VisualStudio.Component.Windows11SDK.22000' +) -join ' ' +winget install @WingetArgs --id Microsoft.VisualStudio.2022.BuildTools --override $vsOverride | Out-Host + +# Ensure required VC components even if winget doesn't modify the instance +$isArm64 = ($env:PROCESSOR_ARCHITEW6432 -eq 'ARM64' -or $env:PROCESSOR_ARCHITECTURE -eq 'ARM64') +$components = @( + 'Microsoft.VisualStudio.Workload.VCTools', + 'Microsoft.VisualStudio.Component.VC.Tools.ARM64', + 'Microsoft.VisualStudio.Component.VC.Tools.ARM64EC', + 'Microsoft.VisualStudio.Component.Windows11SDK.22000' +) +Ensure-VSComponents -Components $components + +# 2) Rustup +Write-Host "-- Installing rustup" -ForegroundColor DarkCyan +winget install @WingetArgs --id Rustlang.Rustup | Out-Host + +# Make cargo available in this session +Add-CargoBinToPath + +# 3) Git (often present, but ensure installed) +Write-Host "-- Installing Git" -ForegroundColor DarkCyan +winget install @WingetArgs --id Git.Git | Out-Host + +# 4) ripgrep (rg) +Write-Host "-- Installing ripgrep (rg)" -ForegroundColor DarkCyan +winget install @WingetArgs --id BurntSushi.ripgrep.MSVC | Out-Host + +# 5) just +Write-Host "-- Installing just" -ForegroundColor DarkCyan +winget install @WingetArgs --id Casey.Just | Out-Host + +# 6) cmake (commonly needed by native crates) +Write-Host "-- Installing CMake" -ForegroundColor DarkCyan +winget install @WingetArgs --id Kitware.CMake | Out-Host + +# Ensure cargo is available after rustup install +Add-CargoBinToPath +if (-not (Ensure-Command 'cargo')) { + # Some shells need a re-login; attempt to source cargo.env if present + $cargoEnv = Join-Path $env:USERPROFILE ".cargo\env" + if (Test-Path $cargoEnv) { . $cargoEnv } + Add-CargoBinToPath +} +if (-not (Ensure-Command 'cargo')) { + throw "cargo not found in PATH after rustup install. Please open a new terminal and re-run the script." +} + +Write-Host "==> Configuring Rust toolchain per rust-toolchain.toml" -ForegroundColor Cyan + +# Pin to the workspace toolchain and install components +$toolchain = '1.90.0' +& rustup toolchain install $toolchain --profile minimal | Out-Host +& rustup default $toolchain | Out-Host +& rustup component add clippy rustfmt rust-src --toolchain $toolchain | Out-Host + +# 6.5) LLVM/Clang (some crates/bindgen require clang/libclang) +function Add-LLVMToPath() { + $llvmBin = 'C:\\Program Files\\LLVM\\bin' + if (Test-Path $llvmBin) { + if (-not ($env:Path.Split(';') -contains $llvmBin)) { + $env:Path = "$env:Path;$llvmBin" + } + if (-not $env:LIBCLANG_PATH) { + $env:LIBCLANG_PATH = $llvmBin + } + Ensure-UserPathContains $llvmBin + Ensure-UserEnvVar -Name 'LIBCLANG_PATH' -Value $llvmBin + + $clang = Join-Path $llvmBin 'clang.exe' + $clangxx = Join-Path $llvmBin 'clang++.exe' + if (Test-Path $clang) { + $env:CC = $clang + Ensure-UserEnvVar -Name 'CC' -Value $clang + } + if (Test-Path $clangxx) { + $env:CXX = $clangxx + Ensure-UserEnvVar -Name 'CXX' -Value $clangxx + } + } +} + +Write-Host "-- Installing LLVM/Clang" -ForegroundColor DarkCyan +winget install @WingetArgs --id LLVM.LLVM | Out-Host +Add-LLVMToPath + +# 7) cargo-insta (used by snapshot tests) +# Ensure MSVC linker is available before building/cargo-install by entering VS dev shell +Enter-VsDevShell +$hasLink = $false +try { & where.exe link | Out-Null; $hasLink = $true } catch {} +if ($hasLink) { + Write-Host "-- Installing cargo-insta" -ForegroundColor DarkCyan + & cargo install cargo-insta --locked | Out-Host +} else { + Write-Host "-- Skipping cargo-insta for now (MSVC linker not found yet)" -ForegroundColor Yellow +} + +if ($SkipBuild) { + Write-Host "==> Skipping cargo build (SkipBuild specified)" -ForegroundColor Yellow + exit 0 +} + +Write-Host "==> Building workspace (cargo build)" -ForegroundColor Cyan +pushd "$PSScriptRoot\.." | Out-Null +try { + # Clear RUSTFLAGS if coming from constrained environments + $env:RUSTFLAGS = '' + Enter-VsDevShell + & cargo build +} +finally { + popd | Out-Null +} + +Write-Host "==> Build complete" -ForegroundColor Green diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 838dacffb1..05a4288b86 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1862,7 +1862,10 @@ impl ChatWidget { current_approval == preset.approval && current_sandbox == preset.sandbox; let name = preset.label.to_string(); let description_text = preset.description; - let description = if cfg!(target_os = "windows") && preset.id == "auto" { + let description = if cfg!(target_os = "windows") + && preset.id == "auto" + && codex_core::get_platform_sandbox().is_none() + { Some(format!( "{description_text}\nRequires Windows Subsystem for Linux (WSL). Show installation instructions..." )) @@ -1882,7 +1885,10 @@ impl ChatWidget { preset: preset_clone.clone(), }); })] - } else if cfg!(target_os = "windows") && preset.id == "auto" { + } else if cfg!(target_os = "windows") + && preset.id == "auto" + && codex_core::get_platform_sandbox().is_none() + { vec![Box::new(|tx| { tx.send(AppEvent::ShowWindowsAutoModeInstructions); })] diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c8a3e868c2..73d61dc4e0 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1424,7 +1424,7 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { let args_str = invocation .arguments .as_ref() - .map(|v| { + .map(|v: &serde_json::Value| { // Use compact form to keep things short but readable. serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) }) diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index fdbaabd38c..52544b3d9c 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -89,7 +89,7 @@ pub(crate) fn compose_account_display(config: &Config) -> Option List[str]: + """Resolve the Codex CLI to invoke `codex sandbox windows`. + + Prefer `codex` on PATH; if not found, try common local build locations. + Returns the argv prefix to run Codex. + """ + # 1) Prefer PATH + try: + cp = subprocess.run(["where", "codex"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + if cp.returncode == 0: + for line in cp.stdout.splitlines(): + p = Path(line.strip()) + if p.exists(): + return [str(p)] + except Exception: + pass + + # 2) Try workspace targets + root = Path(__file__).parent + ws_root = root.parent + cargo_target = os.environ.get("CARGO_TARGET_DIR") + candidates = [ + ws_root / "target" / "release" / "codex.exe", + ws_root / "target" / "debug" / "codex.exe", + ] + if cargo_target: + candidates.extend([ + Path(cargo_target) / "release" / "codex.exe", + Path(cargo_target) / "debug" / "codex.exe", + ]) + for p in candidates: + if p.exists(): + return [str(p)] + + raise FileNotFoundError( + "Codex CLI not found. Build it first, e.g.\n" + " cargo build -p codex-cli --release\n" + "or for debug:\n" + " cargo build -p codex-cli\n" + ) + +CODEX_CMD = _resolve_codex_cmd() +TIMEOUT_SEC = 20 + +WS_ROOT = Path(os.environ["USERPROFILE"]) / "sbx_ws_tests" +OUTSIDE = Path(os.environ["USERPROFILE"]) / "sbx_ws_outside" # outside CWD for deny checks + +ENV_BASE = {} # extend if needed + +class CaseResult: + def __init__(self, name: str, ok: bool, detail: str = ""): + self.name, self.ok, self.detail = name, ok, detail + +def run_sbx(policy: str, cmd_argv: List[str], cwd: Path, env_extra: Optional[dict] = None) -> Tuple[int, str, str]: + env = os.environ.copy() + env.update(ENV_BASE) + if env_extra: + env.update(env_extra) + # Map policy to codex CLI flags + # read-only => default; workspace-write => --full-auto + if policy not in ("read-only", "workspace-write"): + raise ValueError(f"unknown policy: {policy}") + policy_flags: List[str] = ["--full-auto"] if policy == "workspace-write" else [] + + argv = [*CODEX_CMD, "sandbox", "windows", *policy_flags, "--", *cmd_argv] + print(cmd_argv) + cp = subprocess.run(argv, cwd=str(cwd), env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=TIMEOUT_SEC, text=True) + return cp.returncode, cp.stdout, cp.stderr + +def have(cmd: str) -> bool: + try: + cp = subprocess.run(["where", cmd], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + return cp.returncode == 0 and any(Path(p.strip()).exists() for p in cp.stdout.splitlines()) + except Exception: + return False + +def make_dir_clean(p: Path) -> None: + if p.exists(): + shutil.rmtree(p, ignore_errors=True) + p.mkdir(parents=True, exist_ok=True) + +def write_file(p: Path, content: str = "x") -> None: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + +def remove_if_exists(p: Path) -> None: + try: + if p.is_dir(): shutil.rmtree(p, ignore_errors=True) + elif p.exists(): p.unlink(missing_ok=True) + except Exception: + pass + +def assert_exists(p: Path) -> bool: + return p.exists() + +def assert_not_exists(p: Path) -> bool: + return not p.exists() + +def summarize(results: List[CaseResult]) -> int: + ok = sum(1 for r in results if r.ok) + total = len(results) + print("\n" + "=" * 72) + print(f"Sandbox smoke tests: {ok}/{total} passed") + for r in results: + print(f"[{'PASS' if r.ok else 'FAIL'}] {r.name}" + (f" :: {r.detail.strip()}" if r.detail and not r.ok else "")) + print("=" * 72) + return 0 if ok == total else 1 + +def main() -> int: + results: List[CaseResult] = [] + make_dir_clean(WS_ROOT) + OUTSIDE.mkdir(exist_ok=True) + # Environment probe: some hosts allow TEMP writes even under read-only + # tokens due to ACLs and restricted SID semantics. Detect and adapt tests. + probe_rc, _, _ = run_sbx( + "read-only", + ["cmd", "/c", "echo probe > %TEMP%\\sbx_ro_probe.txt"], + WS_ROOT, + ) + ro_temp_denied = probe_rc != 0 + + def add(name: str, ok: bool, detail: str = ""): + print('running', name) + results.append(CaseResult(name, ok, detail)) + + # 1. RO: deny write in CWD + target = WS_ROOT / "ro_should_fail.txt" + remove_if_exists(target) + rc, out, err = run_sbx("read-only", ["cmd", "/c", "echo nope > ro_should_fail.txt"], WS_ROOT) + add("RO: write in CWD denied", rc != 0 and assert_not_exists(target), f"rc={rc}, err={err}") + + # 2. WS: allow write in CWD + target = WS_ROOT / "ws_ok.txt" + remove_if_exists(target) + rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "echo ok > ws_ok.txt"], WS_ROOT) + add("WS: write in CWD allowed", rc == 0 and assert_exists(target), f"rc={rc}, err={err}") + + # 3. WS: deny write outside workspace + outside_file = OUTSIDE / "blocked.txt" + remove_if_exists(outside_file) + rc, out, err = run_sbx("workspace-write", ["cmd", "/c", f"echo nope > {outside_file}"], WS_ROOT) + add("WS: write outside workspace denied", rc != 0 and assert_not_exists(outside_file), f"rc={rc}") + + # 4. WS: allow TEMP write + rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "echo tempok > %TEMP%\\ws_temp_ok.txt"], WS_ROOT) + add("WS: TEMP write allowed", rc == 0, f"rc={rc}") + + # 5. RO: deny TEMP write + rc, out, err = run_sbx("read-only", ["cmd", "/c", "echo tempno > %TEMP%\\ro_temp_fail.txt"], WS_ROOT) + if ro_temp_denied: + add("RO: TEMP write denied", rc != 0, f"rc={rc}") + else: + add("RO: TEMP write denied (skipped on this host)", True) + + # 6. WS: append OK in CWD + target = WS_ROOT / "append.txt" + remove_if_exists(target); write_file(target, "line1\n") + rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "echo line2 >> append.txt"], WS_ROOT) + add("WS: append allowed", rc == 0 and target.read_text().strip().endswith("line2"), f"rc={rc}") + + # 7. RO: append denied + target = WS_ROOT / "ro_append.txt" + write_file(target, "line1\n") + rc, out, err = run_sbx("read-only", ["cmd", "/c", "echo line2 >> ro_append.txt"], WS_ROOT) + add("RO: append denied", rc != 0 and target.read_text() == "line1\n", f"rc={rc}") + + # 8. WS: PowerShell Set-Content in CWD (OK) + target = WS_ROOT / "ps_ok.txt" + remove_if_exists(target) + rc, out, err = run_sbx("workspace-write", + ["powershell", "-NoLogo", "-NoProfile", "-Command", + "Set-Content -LiteralPath ps_ok.txt -Value 'hello' -Encoding ASCII"], WS_ROOT) + add("WS: PowerShell Set-Content allowed", rc == 0 and assert_exists(target), f"rc={rc}, err={err}") + + # 9. RO: PowerShell Set-Content denied + target = WS_ROOT / "ps_ro_fail.txt" + remove_if_exists(target) + rc, out, err = run_sbx("read-only", + ["powershell", "-NoLogo", "-NoProfile", "-Command", + "Set-Content -LiteralPath ps_ro_fail.txt -Value 'x'"], WS_ROOT) + add("RO: PowerShell Set-Content denied", rc != 0 and assert_not_exists(target), f"rc={rc}") + + # 10. WS: mkdir and write (OK) + rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "mkdir sub && echo hi > sub\\in_sub.txt"], WS_ROOT) + add("WS: mkdir+write allowed", rc == 0 and (WS_ROOT / "sub/in_sub.txt").exists(), f"rc={rc}") + + # 11. WS: rename (EXPECTED SUCCESS on this host) + rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "echo x > r.txt & ren r.txt r2.txt"], WS_ROOT) + add("WS: rename succeeds (expected on this host)", rc == 0 and (WS_ROOT / "r2.txt").exists(), f"rc={rc}, err={err}") + + # 12. WS: delete (EXPECTED SUCCESS on this host) + target = WS_ROOT / "delme.txt"; write_file(target, "x") + rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "del /q delme.txt"], WS_ROOT) + add("WS: delete succeeds (expected on this host)", rc == 0 and not target.exists(), f"rc={rc}, err={err}") + + # 13. RO: python tries to write (denied) + pyfile = WS_ROOT / "py_should_fail.txt"; remove_if_exists(pyfile) + rc, out, err = run_sbx("read-only", ["python", "-c", "open('py_should_fail.txt','w').write('x')"], WS_ROOT) + add("RO: python file write denied", rc != 0 and assert_not_exists(pyfile), f"rc={rc}") + + # 14. WS: python writes file (OK) + pyfile = WS_ROOT / "py_ok.txt"; remove_if_exists(pyfile) + rc, out, err = run_sbx("workspace-write", ["python", "-c", "open('py_ok.txt','w').write('x')"], WS_ROOT) + add("WS: python file write allowed", rc == 0 and assert_exists(pyfile), f"rc={rc}, err={err}") + + # 15. WS: curl network blocked (short timeout) + rc, out, err = run_sbx("workspace-write", ["curl", "--connect-timeout", "1", "--max-time", "2", "https://example.com"], WS_ROOT) + add("WS: curl network blocked", rc != 0, f"rc={rc}") + + # 16. WS: iwr network blocked (HTTP) + rc, out, err = run_sbx("workspace-write", ["powershell", "-NoLogo", "-NoProfile", "-Command", + "try { iwr http://neverssl.com -TimeoutSec 2 } catch { exit 1 }"], WS_ROOT) + add("WS: iwr network blocked", rc != 0, f"rc={rc}") + + # 17. RO: deny TEMP writes via PowerShell + rc, out, err = run_sbx("read-only", + ["powershell", "-NoLogo", "-NoProfile", "-Command", + "Set-Content -LiteralPath $env:TEMP\\ro_tmpfail.txt -Value 'x'"], WS_ROOT) + if ro_temp_denied: + add("RO: TEMP write denied (PS)", rc != 0, f"rc={rc}") + else: + add("RO: TEMP write denied (PS, skipped)", True) + + # 18. WS: curl version check — don't rely on stub, just succeed + if have("curl"): + rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "curl --version"], WS_ROOT) + add("WS: curl present (version prints)", rc == 0, f"rc={rc}, err={err}") + else: + add("WS: curl present (optional, skipped)", True) + + # 19. Optional: ripgrep version + if have("rg"): + rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "rg --version"], WS_ROOT) + add("WS: rg --version (optional)", rc == 0, f"rc={rc}, err={err}") + else: + add("WS: rg --version (optional, skipped)", True) + + # 20. Optional: git --version + if have("git"): + rc, out, err = run_sbx("workspace-write", ["git", "--version"], WS_ROOT) + add("WS: git --version (optional)", rc == 0, f"rc={rc}, err={err}") + else: + add("WS: git --version (optional, skipped)", True) + + # 21–23. JSON policy: allow only .\allowed — note CWD is still allowed by current impl + (WS_ROOT / "allowed").mkdir(exist_ok=True) + (WS_ROOT / "blocked").mkdir(exist_ok=True) + policy_json = '{"mode":"workspace-write","workspace_roots":[".\\\\allowed"]}' + + # Allowed: inside .\allowed (OK) + rc, out, err = run_sbx(policy_json, ["cmd", "/c", "echo ok > allowed\\in_allowed.txt"], WS_ROOT) + add("JSON WS: write in allowed/ OK", rc == 0 and (WS_ROOT / "allowed/in_allowed.txt").exists(), f"rc={rc}") + + # Outside CWD (deny) + json_outside = OUTSIDE / "json_blocked.txt"; remove_if_exists(json_outside) + rc, out, err = run_sbx(policy_json, ["cmd", "/c", f"echo nope > {json_outside}"], WS_ROOT) + add("JSON WS: write outside allowed/ denied", rc != 0 and not json_outside.exists(), f"rc={rc}") + + # CWD is still allowed by current sandbox (documented behavior) + rc, out, err = run_sbx(policy_json, ["cmd", "/c", "echo ok > cwd_ok_under_json.txt"], WS_ROOT) + add("JSON WS: write in CWD allowed (by design)", rc == 0 and (WS_ROOT / "cwd_ok_under_json.txt").exists(), f"rc={rc}") + + # 24. WS: PS bytes write (OK) + rc, out, err = run_sbx("workspace-write", + ["powershell", "-NoLogo", "-NoProfile", "-Command", + "[IO.File]::WriteAllBytes('bytes_ok.bin',[byte[]](0..255))"], WS_ROOT) + add("WS: PS bytes write allowed", rc == 0 and (WS_ROOT / "bytes_ok.bin").exists(), f"rc={rc}") + + # 25. RO: PS bytes write denied + rc, out, err = run_sbx("read-only", + ["powershell", "-NoLogo", "-NoProfile", "-Command", + "[IO.File]::WriteAllBytes('bytes_fail.bin',[byte[]](0..10))"], WS_ROOT) + add("RO: PS bytes write denied", rc != 0 and not (WS_ROOT / "bytes_fail.bin").exists(), f"rc={rc}") + + # 26. WS: deep mkdir and write (OK) + rc, out, err = run_sbx("workspace-write", + ["cmd", "/c", "mkdir deep\\nest && echo ok > deep\\nest\\f.txt"], WS_ROOT) + add("WS: deep mkdir+write allowed", rc == 0 and (WS_ROOT / "deep/nest/f.txt").exists(), f"rc={rc}") + + # 27. WS: move (EXPECTED SUCCESS on this host) + rc, out, err = run_sbx("workspace-write", + ["cmd", "/c", "echo x > m1.txt & move /y m1.txt m2.txt"], WS_ROOT) + add("WS: move succeeds (expected on this host)", rc == 0 and (WS_ROOT / "m2.txt").exists(), f"rc={rc}, err={err}") + + # 28. RO: cmd redirection denied + target = WS_ROOT / "cmd_ro.txt"; remove_if_exists(target) + rc, out, err = run_sbx("read-only", ["cmd", "/c", "echo nope > cmd_ro.txt"], WS_ROOT) + add("RO: cmd redirection denied", rc != 0 and not target.exists(), f"rc={rc}") + + return summarize(results) + +if __name__ == "__main__": + sys.exit(main()) diff --git a/codex-rs/windows-sandbox-rs/src/acl.rs b/codex-rs/windows-sandbox-rs/src/acl.rs new file mode 100644 index 0000000000..1ec75e6726 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/acl.rs @@ -0,0 +1,286 @@ +use crate::winutil::to_wide; +use anyhow::anyhow; +use anyhow::Result; +use std::ffi::c_void; +use std::path::Path; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Foundation::ERROR_SUCCESS; +use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Security::AclSizeInformation; +use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; +use windows_sys::Win32::Security::Authorization::GetSecurityInfo; +use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; +use windows_sys::Win32::Security::Authorization::SetNamedSecurityInfoW; +use windows_sys::Win32::Security::Authorization::SetSecurityInfo; +use windows_sys::Win32::Security::Authorization::EXPLICIT_ACCESS_W; +use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_SID; +use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_UNKNOWN; +use windows_sys::Win32::Security::Authorization::TRUSTEE_W; +use windows_sys::Win32::Security::EqualSid; +use windows_sys::Win32::Security::GetAce; +use windows_sys::Win32::Security::GetAclInformation; +use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; +use windows_sys::Win32::Security::ACE_HEADER; +use windows_sys::Win32::Security::ACL; +use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; +use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; +use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; +use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; +const SE_KERNEL_OBJECT: u32 = 6; +const INHERIT_ONLY_ACE: u8 = 0x08; + +pub unsafe fn dacl_has_write_allow_for_sid(p_dacl: *mut ACL, psid: *mut c_void) -> bool { + if p_dacl.is_null() { + return false; + } + let mut info: ACL_SIZE_INFORMATION = std::mem::zeroed(); + let ok = GetAclInformation( + p_dacl as *const ACL, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ); + if ok == 0 { + return false; + } + let count = info.AceCount as usize; + for i in 0..count { + let mut p_ace: *mut c_void = std::ptr::null_mut(); + if GetAce(p_dacl as *const ACL, i as u32, &mut p_ace) == 0 { + continue; + } + let hdr = &*(p_ace as *const ACE_HEADER); + if hdr.AceType != 0 { + continue; // ACCESS_ALLOWED_ACE_TYPE + } + // Ignore ACEs that are inherit-only (do not apply to the current object) + if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 { + continue; + } + let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); + let mask = ace.Mask; + let base = p_ace as usize; + let sid_ptr = + (base + std::mem::size_of::() + std::mem::size_of::()) as *mut c_void; + let eq = EqualSid(sid_ptr, psid); + if eq != 0 && (mask & FILE_GENERIC_WRITE) != 0 { + return true; + } + } + false +} + +// Compute effective rights for a trustee SID against a DACL and decide if write is effectively allowed. +// This accounts for deny ACEs and ordering; falls back to a conservative per-ACE scan if the API fails. +#[allow(dead_code)] +pub unsafe fn dacl_effective_allows_write(p_dacl: *mut ACL, psid: *mut c_void) -> bool { + if p_dacl.is_null() { + return false; + } + use windows_sys::Win32::Security::Authorization::GetEffectiveRightsFromAclW; + use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_SID; + use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_UNKNOWN; + use windows_sys::Win32::Security::Authorization::TRUSTEE_W; + + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut access: u32 = 0; + let ok = GetEffectiveRightsFromAclW(p_dacl, &trustee, &mut access); + if ok != 0 { + // Check for generic or specific write bits + let write_bits = FILE_GENERIC_WRITE + | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA + | windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA + | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA + | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; + return (access & write_bits) != 0; + } + // Fallback: simple allow ACE scan (already ignores inherit-only) + dacl_has_write_allow_for_sid(p_dacl, psid) +} +pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { + let mut p_sd: *mut c_void = std::ptr::null_mut(); + let mut p_dacl: *mut ACL = std::ptr::null_mut(); + let code = GetNamedSecurityInfoW( + to_wide(path).as_ptr(), + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + if code != ERROR_SUCCESS { + return Err(anyhow!("GetNamedSecurityInfoW failed: {}", code)); + } + let mut added = false; + if !dacl_has_write_allow_for_sid(p_dacl, psid) { + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed(); + explicit.grfAccessPermissions = + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; + explicit.grfAccessMode = 2; // SET_ACCESS + explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; + explicit.Trustee = trustee; + let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); + let code2 = SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl); + if code2 == ERROR_SUCCESS { + let code3 = SetNamedSecurityInfoW( + to_wide(path).as_ptr() as *mut u16, + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + p_new_dacl, + std::ptr::null_mut(), + ); + if code3 == ERROR_SUCCESS { + added = true; + } + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); + } + } + } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + Ok(added) +} + +pub unsafe fn revoke_ace(path: &Path, psid: *mut c_void) { + let mut p_sd: *mut c_void = std::ptr::null_mut(); + let mut p_dacl: *mut ACL = std::ptr::null_mut(); + let code = GetNamedSecurityInfoW( + to_wide(path).as_ptr(), + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + if code != ERROR_SUCCESS { + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return; + } + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed(); + explicit.grfAccessPermissions = 0; + explicit.grfAccessMode = 4; // REVOKE_ACCESS + explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; + explicit.Trustee = trustee; + let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); + let code2 = SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl); + if code2 == ERROR_SUCCESS { + let _ = SetNamedSecurityInfoW( + to_wide(path).as_ptr() as *mut u16, + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + p_new_dacl, + std::ptr::null_mut(), + ); + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); + } + } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } +} + +pub unsafe fn allow_null_device(psid: *mut c_void) { + let desired = 0x00020000 | 0x00040000; // READ_CONTROL | WRITE_DAC + let h = CreateFileW( + to_wide(r"\\\\.\\NUL").as_ptr(), + desired, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null_mut(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + 0, + ); + if h == 0 || h == INVALID_HANDLE_VALUE { + return; + } + let mut p_sd: *mut c_void = std::ptr::null_mut(); + let mut p_dacl: *mut ACL = std::ptr::null_mut(); + let code = GetSecurityInfo( + h, + SE_KERNEL_OBJECT as i32, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + if code == ERROR_SUCCESS { + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed(); + explicit.grfAccessPermissions = + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; + explicit.grfAccessMode = 2; // SET_ACCESS + explicit.grfInheritance = 0; + explicit.Trustee = trustee; + let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); + let code2 = SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl); + if code2 == ERROR_SUCCESS { + let _ = SetSecurityInfo( + h, + SE_KERNEL_OBJECT as i32, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + p_new_dacl, + std::ptr::null_mut(), + ); + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); + } + } + } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + CloseHandle(h); +} +const CONTAINER_INHERIT_ACE: u32 = 0x2; +const OBJECT_INHERIT_ACE: u32 = 0x1; diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs new file mode 100644 index 0000000000..01254fb58c --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -0,0 +1,37 @@ +use crate::policy::SandboxMode; +use crate::policy::SandboxPolicy; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +pub fn compute_allow_paths( + policy: &SandboxPolicy, + _policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, +) -> Vec { + let mut allow: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + if matches!(policy.0, SandboxMode::WorkspaceWrite) { + let abs = command_cwd.to_path_buf(); + if seen.insert(abs.to_string_lossy().to_string()) && abs.exists() { + allow.push(abs); + } + } + if !matches!(policy.0, SandboxMode::ReadOnly) { + for key in ["TEMP", "TMP"] { + if let Some(v) = env_map.get(key) { + let abs = PathBuf::from(v); + if seen.insert(abs.to_string_lossy().to_string()) && abs.exists() { + allow.push(abs); + } + } else if let Ok(v) = std::env::var(key) { + let abs = PathBuf::from(v); + if seen.insert(abs.to_string_lossy().to_string()) && abs.exists() { + allow.push(abs); + } + } + } + } + allow +} diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs new file mode 100644 index 0000000000..3f8ae4ff13 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -0,0 +1,147 @@ +use crate::acl::dacl_effective_allows_write; +use crate::token::world_sid; +use crate::winutil::to_wide; +use anyhow::anyhow; +use anyhow::Result; +use std::collections::HashSet; +use std::ffi::c_void; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Foundation::ERROR_SUCCESS; +use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; +use windows_sys::Win32::Security::ACL; +use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; + +fn unique_push(set: &mut HashSet, out: &mut Vec, p: PathBuf) { + if let Ok(abs) = p.canonicalize() { + if set.insert(abs.clone()) { + out.push(abs); + } + } +} + +fn gather_candidates(cwd: &Path, env: &std::collections::HashMap) -> Vec { + let mut set: HashSet = HashSet::new(); + let mut out: Vec = Vec::new(); + // Core roots + for p in [ + PathBuf::from("C:/"), + PathBuf::from("C:/Windows"), + PathBuf::from("C:/ProgramData"), + ] { + unique_push(&mut set, &mut out, p); + } + // User roots + if let Some(up) = std::env::var_os("USERPROFILE") { + unique_push(&mut set, &mut out, PathBuf::from(up)); + } + if let Some(pubp) = std::env::var_os("PUBLIC") { + unique_push(&mut set, &mut out, PathBuf::from(pubp)); + } + // CWD + unique_push(&mut set, &mut out, cwd.to_path_buf()); + // TEMP/TMP + for k in ["TEMP", "TMP"] { + if let Some(v) = env.get(k).cloned().or_else(|| std::env::var(k).ok()) { + unique_push(&mut set, &mut out, PathBuf::from(v)); + } + } + // PATH entries + if let Some(path) = env + .get("PATH") + .cloned() + .or_else(|| std::env::var("PATH").ok()) + { + for part in path.split(std::path::MAIN_SEPARATOR) { + if !part.is_empty() { + unique_push(&mut set, &mut out, PathBuf::from(part)); + } + } + } + out +} + +unsafe fn path_has_world_write_allow(path: &Path) -> Result { + let mut p_sd: *mut c_void = std::ptr::null_mut(); + let mut p_dacl: *mut ACL = std::ptr::null_mut(); + let code = GetNamedSecurityInfoW( + to_wide(path).as_ptr(), + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + if code != ERROR_SUCCESS { + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Ok(false); + } + let mut world = world_sid()?; + let psid_world = world.as_mut_ptr() as *mut c_void; + let has = dacl_effective_allows_write(p_dacl, psid_world); + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + Ok(has) +} + +pub fn audit_everyone_writable( + cwd: &Path, + env: &std::collections::HashMap, +) -> Result<()> { + let start = Instant::now(); + let mut flagged: Vec = Vec::new(); + let mut checked = 0usize; + let candidates = gather_candidates(cwd, env); + for root in candidates { + if start.elapsed() > Duration::from_secs(5) || checked > 5000 { + break; + } + checked += 1; + if unsafe { path_has_world_write_allow(&root)? } { + flagged.push(root.clone()); + } + // one level down best-effort + if let Ok(read) = std::fs::read_dir(&root) { + for ent in read.flatten().take(50) { + let p = ent.path(); + if start.elapsed() > Duration::from_secs(5) || checked > 5000 { + break; + } + // Skip reparse points (symlinks/junctions) to avoid auditing link ACLs + let ft = match ent.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + checked += 1; + if unsafe { path_has_world_write_allow(&p)? } { + flagged.push(p); + } + } + } + } + } + if !flagged.is_empty() { + let mut list = String::new(); + for p in flagged { + list.push_str(&format!("\n - {}", p.display())); + } + return Err(anyhow!( + "Refusing to run: found directories writable by Everyone: {}", + list + )); + } + Ok(()) +} diff --git a/codex-rs/windows-sandbox-rs/src/cap.rs b/codex-rs/windows-sandbox-rs/src/cap.rs new file mode 100644 index 0000000000..41273db1dc --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/cap.rs @@ -0,0 +1,50 @@ +use rand::rngs::SmallRng; +use rand::RngCore; +use rand::SeedableRng; +use serde::Deserialize; +use serde::Serialize; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CapSids { + pub workspace: String, + pub readonly: String, +} + +pub fn cap_sid_file(policy_cwd: &Path) -> PathBuf { + policy_cwd.join(".codex").join("cap_sid") +} + +fn make_random_cap_sid_string() -> String { + let mut rng = SmallRng::from_entropy(); + let a = rng.next_u32(); + let b = rng.next_u32(); + let c = rng.next_u32(); + let d = rng.next_u32(); + format!("S-1-5-21-{}-{}-{}-{}", a, b, c, d) +} + +pub fn load_or_create_cap_sids(policy_cwd: &Path) -> CapSids { + let path = cap_sid_file(policy_cwd); + if path.exists() { + if let Ok(txt) = fs::read_to_string(&path) { + let t = txt.trim(); + if t.starts_with('{') && t.ends_with('}') { + if let Ok(obj) = serde_json::from_str::(t) { + return obj; + } + } else if !t.is_empty() { + return CapSids { + workspace: t.to_string(), + readonly: make_random_cap_sid_string(), + }; + } + } + } + CapSids { + workspace: make_random_cap_sid_string(), + readonly: make_random_cap_sid_string(), + } +} diff --git a/codex-rs/windows-sandbox-rs/src/env.rs b/codex-rs/windows-sandbox-rs/src/env.rs new file mode 100644 index 0000000000..a8a3cda71d --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/env.rs @@ -0,0 +1,165 @@ +use anyhow::Result; +use std::collections::HashMap; +use std::env; +use std::fs::File; +use std::fs::{self}; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +pub fn normalize_null_device_env(env_map: &mut HashMap) { + let keys: Vec = env_map.keys().cloned().collect(); + for k in keys { + if let Some(v) = env_map.get(&k).cloned() { + let t = v.trim().to_ascii_lowercase(); + if t == "/dev/null" || t == "\\\\\\\\dev\\\\\\\\null" { + env_map.insert(k, "NUL".to_string()); + } + } + } +} + +pub fn ensure_non_interactive_pager(env_map: &mut HashMap) { + env_map + .entry("GIT_PAGER".into()) + .or_insert_with(|| "more.com".into()); + env_map + .entry("PAGER".into()) + .or_insert_with(|| "more.com".into()); + env_map.entry("LESS".into()).or_insert_with(|| "".into()); +} + +fn prepend_path(env_map: &mut HashMap, prefix: &str) { + let existing = env_map + .get("PATH") + .cloned() + .or_else(|| env::var("PATH").ok()) + .unwrap_or_default(); + let parts: Vec = existing.split(';').map(|s| s.to_string()).collect(); + if parts + .first() + .map(|p| p.eq_ignore_ascii_case(prefix)) + .unwrap_or(false) + { + return; + } + let mut new_path = String::new(); + new_path.push_str(prefix); + if !existing.is_empty() { + new_path.push(';'); + new_path.push_str(&existing); + } + env_map.insert("PATH".into(), new_path); +} + +fn reorder_pathext_for_stubs(env_map: &mut HashMap) { + let default = env_map + .get("PATHEXT") + .cloned() + .or_else(|| env::var("PATHEXT").ok()) + .unwrap_or(".COM;.EXE;.BAT;.CMD".to_string()); + let exts: Vec = default + .split(';') + .filter(|e| !e.is_empty()) + .map(|s| s.to_string()) + .collect(); + let exts_norm: Vec = exts.iter().map(|e| e.to_ascii_uppercase()).collect(); + let want = [".BAT", ".CMD"]; // move to front if present + let mut front: Vec = Vec::new(); + for w in want { + if let Some(idx) = exts_norm.iter().position(|e| e == w) { + front.push(exts[idx].clone()); + } + } + let rest: Vec = exts + .into_iter() + .enumerate() + .filter(|(i, _)| { + let up = &exts_norm[*i]; + up != ".BAT" && up != ".CMD" + }) + .map(|(_, e)| e) + .collect(); + let mut combined = Vec::new(); + combined.extend(front); + combined.extend(rest); + env_map.insert("PATHEXT".into(), combined.join(";")); +} + +fn ensure_denybin(tools: &[&str], denybin_dir: Option<&Path>) -> Result { + let base = match denybin_dir { + Some(p) => p.to_path_buf(), + None => { + let home = dirs_next::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?; + home.join(".sbx-denybin") + } + }; + fs::create_dir_all(&base)?; + for tool in tools { + for ext in [".bat", ".cmd"] { + let path = base.join(format!("{}{}", tool, ext)); + if !path.exists() { + let mut f = File::create(&path)?; + f.write_all(b"@echo off\\r\\nexit /b 1\\r\\n")?; + } + } + } + Ok(base) +} + +pub fn apply_no_network_to_env(env_map: &mut HashMap) -> Result<()> { + env_map.insert("SBX_NONET_ACTIVE".into(), "1".into()); + env_map + .entry("HTTP_PROXY".into()) + .or_insert_with(|| "http://127.0.0.1:9".into()); + env_map + .entry("HTTPS_PROXY".into()) + .or_insert_with(|| "http://127.0.0.1:9".into()); + env_map + .entry("ALL_PROXY".into()) + .or_insert_with(|| "http://127.0.0.1:9".into()); + env_map + .entry("NO_PROXY".into()) + .or_insert_with(|| "localhost,127.0.0.1,::1".into()); + env_map + .entry("PIP_NO_INDEX".into()) + .or_insert_with(|| "1".into()); + env_map + .entry("PIP_DISABLE_PIP_VERSION_CHECK".into()) + .or_insert_with(|| "1".into()); + env_map + .entry("NPM_CONFIG_OFFLINE".into()) + .or_insert_with(|| "true".into()); + env_map + .entry("CARGO_NET_OFFLINE".into()) + .or_insert_with(|| "true".into()); + env_map + .entry("GIT_HTTP_PROXY".into()) + .or_insert_with(|| "http://127.0.0.1:9".into()); + env_map + .entry("GIT_HTTPS_PROXY".into()) + .or_insert_with(|| "http://127.0.0.1:9".into()); + env_map + .entry("GIT_SSH_COMMAND".into()) + .or_insert_with(|| "cmd /c exit 1".into()); + env_map + .entry("GIT_ALLOW_PROTOCOLS".into()) + .or_insert_with(|| "".into()); + + // Block interactive network tools that bypass HTTP(S) proxy settings, but + // allow curl/wget to run so commands like `curl --version` still succeed. + // Network access is disabled via proxy envs above. + let base = ensure_denybin(&["ssh", "scp"], None)?; + // Clean up any stale stubs from previous runs so real curl/wget can run. + for tool in ["curl", "wget"] { + for ext in [".bat", ".cmd"] { + let p = base.join(format!("{}{}", tool, ext)); + if p.exists() { + let _ = std::fs::remove_file(&p); + } + } + } + prepend_path(env_map, &base.to_string_lossy()); + reorder_pathext_for_stubs(env_map); + Ok(()) +} diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs new file mode 100644 index 0000000000..d0b9292dc2 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -0,0 +1,452 @@ +macro_rules! windows_modules { + ($($name:ident),+ $(,)?) => { + $(#[cfg(target_os = "windows")] mod $name;)+ + }; +} + +windows_modules!(acl, allow, audit, cap, env, logging, policy, token, winutil); + +#[cfg(target_os = "windows")] +pub use windows_impl::preflight_audit_everyone_writable; +#[cfg(target_os = "windows")] +pub use windows_impl::run_windows_sandbox_capture; +#[cfg(target_os = "windows")] +pub use windows_impl::CaptureResult; + +#[cfg(not(target_os = "windows"))] +pub use stub::preflight_audit_everyone_writable; +#[cfg(not(target_os = "windows"))] +pub use stub::run_windows_sandbox_capture; +#[cfg(not(target_os = "windows"))] +pub use stub::CaptureResult; + +#[cfg(target_os = "windows")] +mod windows_impl { + use super::acl::add_allow_ace; + use super::acl::allow_null_device; + use super::acl::revoke_ace; + use super::allow::compute_allow_paths; + use super::audit; + use super::cap::cap_sid_file; + use super::cap::load_or_create_cap_sids; + use super::env::apply_no_network_to_env; + use super::env::ensure_non_interactive_pager; + use super::env::normalize_null_device_env; + use super::logging::debug_log; + use super::logging::log_failure; + use super::logging::log_start; + use super::logging::log_success; + use super::policy::SandboxMode; + use super::policy::SandboxPolicy; + use super::token::convert_string_sid_to_sid; + use super::winutil::format_last_error; + use super::winutil::to_wide; + use anyhow::Result; + use std::collections::HashMap; + use std::ffi::c_void; + use std::fs; + use std::io; + use std::path::Path; + use std::path::PathBuf; + use std::ptr; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::Foundation::GetLastError; + use windows_sys::Win32::Foundation::SetHandleInformation; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT; + use windows_sys::Win32::System::Pipes::CreatePipe; + use windows_sys::Win32::System::Threading::CreateProcessAsUserW; + use windows_sys::Win32::System::Threading::GetExitCodeProcess; + use windows_sys::Win32::System::Threading::WaitForSingleObject; + use windows_sys::Win32::System::Threading::CREATE_UNICODE_ENVIRONMENT; + use windows_sys::Win32::System::Threading::INFINITE; + use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; + use windows_sys::Win32::System::Threading::STARTF_USESTDHANDLES; + use windows_sys::Win32::System::Threading::STARTUPINFOW; + + type PipeHandles = ((HANDLE, HANDLE), (HANDLE, HANDLE), (HANDLE, HANDLE)); + + fn ensure_dir(p: &Path) -> Result<()> { + if let Some(d) = p.parent() { + std::fs::create_dir_all(d)?; + } + Ok(()) + } + + fn make_env_block(env: &HashMap) -> Vec { + let mut items: Vec<(String, String)> = + env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + items.sort_by(|a, b| { + a.0.to_uppercase() + .cmp(&b.0.to_uppercase()) + .then(a.0.cmp(&b.0)) + }); + let mut w: Vec = Vec::new(); + for (k, v) in items { + let mut s = to_wide(format!("{}={}", k, v)); + s.pop(); + w.extend_from_slice(&s); + w.push(0); + } + w.push(0); + w + } + + // Quote a single Windows command-line argument following the rules used by + // CommandLineToArgvW/CRT so that spaces, quotes, and backslashes are preserved. + // Reference behavior matches Rust std::process::Command on Windows. + fn quote_windows_arg(arg: &str) -> String { + let needs_quotes = arg.is_empty() + || arg + .chars() + .any(|c| matches!(c, ' ' | '\t' | '\n' | '\r' | '"')); + if !needs_quotes { + return arg.to_string(); + } + + let mut quoted = String::with_capacity(arg.len() + 2); + quoted.push('"'); + let mut backslashes = 0; + for ch in arg.chars() { + match ch { + '\\' => { + backslashes += 1; + } + '"' => { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } + _ => { + if backslashes > 0 { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + } + quoted.push(ch); + } + } + } + if backslashes > 0 { + quoted.push_str(&"\\".repeat(backslashes * 2)); + } + quoted.push('"'); + quoted + } + + unsafe fn setup_stdio_pipes() -> io::Result { + let mut in_r: HANDLE = 0; + let mut in_w: HANDLE = 0; + let mut out_r: HANDLE = 0; + let mut out_w: HANDLE = 0; + let mut err_r: HANDLE = 0; + let mut err_w: HANDLE = 0; + if CreatePipe(&mut in_r, &mut in_w, ptr::null_mut(), 0) == 0 { + return Err(io::Error::from_raw_os_error(GetLastError() as i32)); + } + if CreatePipe(&mut out_r, &mut out_w, ptr::null_mut(), 0) == 0 { + return Err(io::Error::from_raw_os_error(GetLastError() as i32)); + } + if CreatePipe(&mut err_r, &mut err_w, ptr::null_mut(), 0) == 0 { + return Err(io::Error::from_raw_os_error(GetLastError() as i32)); + } + if SetHandleInformation(in_r, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { + return Err(io::Error::from_raw_os_error(GetLastError() as i32)); + } + if SetHandleInformation(out_w, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { + return Err(io::Error::from_raw_os_error(GetLastError() as i32)); + } + if SetHandleInformation(err_w, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { + return Err(io::Error::from_raw_os_error(GetLastError() as i32)); + } + Ok(((in_r, in_w), (out_r, out_w), (err_r, err_w))) + } + + pub struct CaptureResult { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, + pub timed_out: bool, + } + + pub fn preflight_audit_everyone_writable( + cwd: &Path, + env_map: &HashMap, + ) -> Result<()> { + audit::audit_everyone_writable(cwd, env_map) + } + + pub fn run_windows_sandbox_capture( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + command: Vec, + cwd: &Path, + mut env_map: HashMap, + timeout_ms: Option, + ) -> Result { + let policy = SandboxPolicy::parse(policy_json_or_preset)?; + normalize_null_device_env(&mut env_map); + ensure_non_interactive_pager(&mut env_map); + apply_no_network_to_env(&mut env_map)?; + + let current_dir = cwd.to_path_buf(); + // for now, don't fail if we detect world-writable directories + // audit::audit_everyone_writable(¤t_dir, &env_map)?; + log_start(&command); + let (h_token, psid_to_use): (HANDLE, *mut c_void) = unsafe { + match &policy.0 { + SandboxMode::ReadOnly => { + let caps = load_or_create_cap_sids(sandbox_policy_cwd); + ensure_dir(&cap_sid_file(sandbox_policy_cwd))?; + fs::write( + cap_sid_file(sandbox_policy_cwd), + serde_json::to_string(&caps)?, + )?; + let psid = convert_string_sid_to_sid(&caps.readonly).unwrap(); + super::token::create_readonly_token_with_cap(psid)? + } + SandboxMode::WorkspaceWrite => { + let caps = load_or_create_cap_sids(sandbox_policy_cwd); + ensure_dir(&cap_sid_file(sandbox_policy_cwd))?; + fs::write( + cap_sid_file(sandbox_policy_cwd), + serde_json::to_string(&caps)?, + )?; + let psid = convert_string_sid_to_sid(&caps.workspace).unwrap(); + super::token::create_workspace_write_token_with_cap(psid)? + } + } + }; + + unsafe { + if matches!(policy.0, SandboxMode::WorkspaceWrite) { + if let Ok(base) = super::token::get_current_token_for_restriction() { + if let Ok(bytes) = super::token::get_logon_sid_bytes(base) { + let mut tmp = bytes.clone(); + let psid2 = tmp.as_mut_ptr() as *mut c_void; + allow_null_device(psid2); + } + windows_sys::Win32::Foundation::CloseHandle(base); + } + } + } + + let persist_aces = matches!(policy.0, SandboxMode::WorkspaceWrite); + let allow = compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); + let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new(); + unsafe { + for p in &allow { + if let Ok(added) = add_allow_ace(p, psid_to_use) { + if added { + if persist_aces { + if p.is_dir() { + // best-effort seeding omitted intentionally + } + } else { + guards.push((p.clone(), psid_to_use)); + } + } + } + } + allow_null_device(psid_to_use); + } + + let (stdin_pair, stdout_pair, stderr_pair) = unsafe { setup_stdio_pipes()? }; + let ((in_r, in_w), (out_r, out_w), (err_r, err_w)) = (stdin_pair, stdout_pair, stderr_pair); + let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() }; + si.cb = std::mem::size_of::() as u32; + si.dwFlags |= STARTF_USESTDHANDLES; + si.hStdInput = in_r; + si.hStdOutput = out_w; + si.hStdError = err_w; + + let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let cmdline_str = command + .iter() + .map(|a| quote_windows_arg(a)) + .collect::>() + .join(" "); + let mut cmdline: Vec = to_wide(&cmdline_str); + let env_block = make_env_block(&env_map); + let desktop = to_wide("Winsta0\\Default"); + si.lpDesktop = desktop.as_ptr() as *mut u16; + let spawn_res = unsafe { + CreateProcessAsUserW( + h_token, + ptr::null(), + cmdline.as_mut_ptr(), + ptr::null_mut(), + ptr::null_mut(), + 1, + CREATE_UNICODE_ENVIRONMENT, + env_block.as_ptr() as *mut c_void, + to_wide(cwd).as_ptr(), + &si, + &mut pi, + ) + }; + if spawn_res == 0 { + let err = unsafe { GetLastError() } as i32; + let dbg = format!( + "CreateProcessAsUserW failed: {} ({}) | cwd={} | cmd={} | env_u16_len={} | si_flags={}", + err, + format_last_error(err), + cwd.display(), + cmdline_str, + env_block.len(), + si.dwFlags, + ); + debug_log(&dbg); + unsafe { + CloseHandle(in_r); + CloseHandle(in_w); + CloseHandle(out_r); + CloseHandle(out_w); + CloseHandle(err_r); + CloseHandle(err_w); + CloseHandle(h_token); + } + return Err(anyhow::anyhow!("CreateProcessAsUserW failed: {}", err)); + } + + unsafe { + CloseHandle(in_r); + // Close the parent's stdin write end so the child sees EOF immediately. + CloseHandle(in_w); + CloseHandle(out_w); + CloseHandle(err_w); + } + + let (tx_out, rx_out) = std::sync::mpsc::channel::>(); + let (tx_err, rx_err) = std::sync::mpsc::channel::>(); + let t_out = std::thread::spawn(move || { + let mut buf = Vec::new(); + let mut tmp = [0u8; 8192]; + loop { + let mut read_bytes: u32 = 0; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::ReadFile( + out_r, + tmp.as_mut_ptr(), + tmp.len() as u32, + &mut read_bytes, + std::ptr::null_mut(), + ) + }; + if ok == 0 || read_bytes == 0 { + break; + } + buf.extend_from_slice(&tmp[..read_bytes as usize]); + } + let _ = tx_out.send(buf); + }); + let t_err = std::thread::spawn(move || { + let mut buf = Vec::new(); + let mut tmp = [0u8; 8192]; + loop { + let mut read_bytes: u32 = 0; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::ReadFile( + err_r, + tmp.as_mut_ptr(), + tmp.len() as u32, + &mut read_bytes, + std::ptr::null_mut(), + ) + }; + if ok == 0 || read_bytes == 0 { + break; + } + buf.extend_from_slice(&tmp[..read_bytes as usize]); + } + let _ = tx_err.send(buf); + }); + + let timeout = timeout_ms.map(|ms| ms as u32).unwrap_or(INFINITE); + let res = unsafe { WaitForSingleObject(pi.hProcess, timeout) }; + let timed_out = res == 0x0000_0102; + let mut exit_code_u32: u32 = 1; + if !timed_out { + unsafe { + GetExitCodeProcess(pi.hProcess, &mut exit_code_u32); + } + } else { + unsafe { + windows_sys::Win32::System::Threading::TerminateProcess(pi.hProcess, 1); + } + } + + unsafe { + if pi.hThread != 0 { + CloseHandle(pi.hThread); + } + if pi.hProcess != 0 { + CloseHandle(pi.hProcess); + } + CloseHandle(h_token); + } + let _ = t_out.join(); + let _ = t_err.join(); + let stdout = rx_out.recv().unwrap_or_default(); + let stderr = rx_err.recv().unwrap_or_default(); + let exit_code = if timed_out { + 128 + 64 + } else { + exit_code_u32 as i32 + }; + + if exit_code == 0 { + log_success(&command); + } else { + log_failure(&command, &format!("exit code {}", exit_code)); + } + + if !persist_aces { + unsafe { + for (p, sid) in guards { + revoke_ace(&p, sid); + } + } + } + + Ok(CaptureResult { + exit_code, + stdout, + stderr, + timed_out, + }) + } +} + +#[cfg(not(target_os = "windows"))] +mod stub { + use anyhow::bail; + use anyhow::Result; + use std::collections::HashMap; + use std::path::Path; + + #[derive(Debug, Default)] + pub struct CaptureResult { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, + pub timed_out: bool, + } + + pub fn preflight_audit_everyone_writable( + _cwd: &Path, + _env_map: &HashMap, + ) -> Result<()> { + bail!("Windows sandbox is only available on Windows") + } + + pub fn run_windows_sandbox_capture( + _policy_json_or_preset: &str, + _sandbox_policy_cwd: &Path, + _command: Vec, + _cwd: &Path, + _env_map: HashMap, + _timeout_ms: Option, + ) -> Result { + bail!("Windows sandbox is only available on Windows") + } +} diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs new file mode 100644 index 0000000000..feb42ece1a --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -0,0 +1,47 @@ +use std::fs::OpenOptions; +use std::io::Write; + +const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; +pub const LOG_FILE_NAME: &str = "sandbox_commands.rust.log"; + +fn preview(command: &[String]) -> String { + let joined = command.join(" "); + if joined.len() <= LOG_COMMAND_PREVIEW_LIMIT { + joined + } else { + joined[..LOG_COMMAND_PREVIEW_LIMIT].to_string() + } +} + +fn append_line(line: &str) { + if let Ok(mut f) = OpenOptions::new() + .create(true) + .append(true) + .open(LOG_FILE_NAME) + { + let _ = writeln!(f, "{}", line); + } +} + +pub fn log_start(command: &[String]) { + let p = preview(command); + append_line(&format!("START: {}", p)); +} + +pub fn log_success(command: &[String]) { + let p = preview(command); + append_line(&format!("SUCCESS: {}", p)); +} + +pub fn log_failure(command: &[String], detail: &str) { + let p = preview(command); + append_line(&format!("FAILURE: {} ({})", p, detail)); +} + +// Debug logging helper. Emits only when SBX_DEBUG=1 to avoid noisy logs. +pub fn debug_log(msg: &str) { + if std::env::var("SBX_DEBUG").ok().as_deref() == Some("1") { + append_line(&format!("DEBUG: {}", msg)); + eprintln!("{}", msg); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/policy.rs b/codex-rs/windows-sandbox-rs/src/policy.rs new file mode 100644 index 0000000000..32cfa79f98 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/policy.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PolicyJson { + pub mode: String, + #[serde(default)] + pub workspace_roots: Vec, +} + +#[derive(Clone, Debug)] +pub enum SandboxMode { + ReadOnly, + WorkspaceWrite, +} + +#[derive(Clone, Debug)] +pub struct SandboxPolicy(pub SandboxMode); + +impl SandboxPolicy { + pub fn parse(value: &str) -> Result { + match value { + "read-only" => Ok(SandboxPolicy(SandboxMode::ReadOnly)), + "workspace-write" => Ok(SandboxPolicy(SandboxMode::WorkspaceWrite)), + other => { + let pj: PolicyJson = serde_json::from_str(other)?; + Ok(match pj.mode.as_str() { + "read-only" => SandboxPolicy(SandboxMode::ReadOnly), + "workspace-write" => SandboxPolicy(SandboxMode::WorkspaceWrite), + _ => SandboxPolicy(SandboxMode::ReadOnly), + }) + } + } + } +} diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs new file mode 100644 index 0000000000..77c4b8459c --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -0,0 +1,193 @@ +use crate::logging; +use crate::winutil::format_last_error; +use crate::winutil::to_wide; +use anyhow::anyhow; +use anyhow::Result; +use std::collections::HashMap; +use std::ffi::c_void; +use std::path::Path; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::SetHandleInformation; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::System::Console::GetStdHandle; +use windows_sys::Win32::System::Console::STD_ERROR_HANDLE; +use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; +use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; +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::CreateProcessAsUserW; +use windows_sys::Win32::System::Threading::GetExitCodeProcess; +use windows_sys::Win32::System::Threading::WaitForSingleObject; +use windows_sys::Win32::System::Threading::CREATE_UNICODE_ENVIRONMENT; +use windows_sys::Win32::System::Threading::INFINITE; +use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; +use windows_sys::Win32::System::Threading::STARTF_USESTDHANDLES; +use windows_sys::Win32::System::Threading::STARTUPINFOW; + +pub fn make_env_block(env: &HashMap) -> Vec { + let mut items: Vec<(String, String)> = + env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + items.sort_by(|a, b| { + a.0.to_uppercase() + .cmp(&b.0.to_uppercase()) + .then(a.0.cmp(&b.0)) + }); + let mut w: Vec = Vec::new(); + for (k, v) in items { + let mut s = to_wide(format!("{}={}", k, v)); + s.pop(); + w.extend_from_slice(&s); + w.push(0); + } + w.push(0); + w +} + +fn quote_arg(a: &str) -> String { + let needs_quote = a.is_empty() || a.chars().any(|ch| ch.is_whitespace() || ch == '"'); + if !needs_quote { + return a.to_string(); + } + let mut out = String::from("\""); + let mut bs: usize = 0; + for ch in a.chars() { + if (ch as u32) == 92 { + bs += 1; + continue; + } + if ch == '"' { + out.push_str(&"\\".repeat(bs * 2 + 1)); + out.push('"'); + bs = 0; + continue; + } + if bs > 0 { + out.push_str(&"\\".repeat(bs * 2)); + bs = 0; + } + out.push(ch); + } + if bs > 0 { + out.push_str(&"\\".repeat(bs * 2)); + } + out.push('"'); + out +} +unsafe fn ensure_inheritable_stdio(si: &mut STARTUPINFOW) -> Result<()> { + for kind in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { + let h = GetStdHandle(kind); + if h == 0 || h == INVALID_HANDLE_VALUE { + return Err(anyhow!("GetStdHandle failed: {}", GetLastError())); + } + if SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { + return Err(anyhow!("SetHandleInformation failed: {}", GetLastError())); + } + } + si.dwFlags |= STARTF_USESTDHANDLES; + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + si.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); + si.hStdError = GetStdHandle(STD_ERROR_HANDLE); + Ok(()) +} + +pub unsafe fn create_process_as_user( + h_token: HANDLE, + argv: &[String], + cwd: &Path, + env_map: &HashMap, +) -> Result<(PROCESS_INFORMATION, STARTUPINFOW)> { + let cmdline_str = argv + .iter() + .map(|a| quote_arg(a)) + .collect::>() + .join(" "); + let mut cmdline: Vec = to_wide(&cmdline_str); + let env_block = make_env_block(env_map); + let mut si: STARTUPINFOW = std::mem::zeroed(); + si.cb = std::mem::size_of::() as u32; + // Some processes (e.g., PowerShell) can fail with STATUS_DLL_INIT_FAILED + // if lpDesktop is not set when launching with a restricted token. + // Point explicitly at the interactive desktop. + let desktop = to_wide("Winsta0\\Default"); + si.lpDesktop = desktop.as_ptr() as *mut u16; + ensure_inheritable_stdio(&mut si)?; + let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); + let ok = CreateProcessAsUserW( + h_token, + std::ptr::null(), + cmdline.as_mut_ptr(), + std::ptr::null_mut(), + std::ptr::null_mut(), + 1, + CREATE_UNICODE_ENVIRONMENT, + env_block.as_ptr() as *mut c_void, + to_wide(cwd).as_ptr(), + &si, + &mut pi, + ); + if ok == 0 { + let err = GetLastError() as i32; + let msg = format!( + "CreateProcessAsUserW failed: {} ({}) | cwd={} | cmd={} | env_u16_len={} | si_flags={}", + err, + format_last_error(err), + cwd.display(), + cmdline_str, + env_block.len(), + si.dwFlags, + ); + logging::debug_log(&msg); + return Err(anyhow!("CreateProcessAsUserW failed: {}", err)); + } + Ok((pi, si)) +} + +pub unsafe fn wait_process_and_exitcode(pi: &PROCESS_INFORMATION) -> Result { + let res = WaitForSingleObject(pi.hProcess, INFINITE); + if res != 0 { + return Err(anyhow!("WaitForSingleObject failed: {}", GetLastError())); + } + let mut code: u32 = 0; + if GetExitCodeProcess(pi.hProcess, &mut code) == 0 { + return Err(anyhow!("GetExitCodeProcess failed: {}", GetLastError())); + } + Ok(code as i32) +} + +pub unsafe fn create_job_kill_on_close() -> Result { + let h = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()); + if h == 0 { + return Err(anyhow!("CreateJobObjectW failed: {}", GetLastError())); + } + 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 c_void, + std::mem::size_of::() as u32, + ); + if ok == 0 { + return Err(anyhow!( + "SetInformationJobObject failed: {}", + GetLastError() + )); + } + Ok(h) +} + +pub unsafe fn assign_to_job(h_job: HANDLE, h_process: HANDLE) -> Result<()> { + if AssignProcessToJobObject(h_job, h_process) == 0 { + return Err(anyhow!( + "AssignProcessToJobObject failed: {}", + GetLastError() + )); + } + Ok(()) +} diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs new file mode 100644 index 0000000000..60eae9377f --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -0,0 +1,272 @@ +use crate::winutil::to_wide; +use anyhow::anyhow; +use anyhow::Result; +use std::ffi::c_void; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::LUID; +use windows_sys::Win32::Security::AdjustTokenPrivileges; +use windows_sys::Win32::Security::CopySid; +use windows_sys::Win32::Security::CreateRestrictedToken; +use windows_sys::Win32::Security::CreateWellKnownSid; +use windows_sys::Win32::Security::GetLengthSid; +use windows_sys::Win32::Security::GetTokenInformation; +use windows_sys::Win32::Security::LookupPrivilegeValueW; + +use windows_sys::Win32::Security::TokenGroups; +use windows_sys::Win32::Security::SID_AND_ATTRIBUTES; +use windows_sys::Win32::Security::TOKEN_ADJUST_DEFAULT; +use windows_sys::Win32::Security::TOKEN_ADJUST_PRIVILEGES; +use windows_sys::Win32::Security::TOKEN_ADJUST_SESSIONID; +use windows_sys::Win32::Security::TOKEN_ASSIGN_PRIMARY; +use windows_sys::Win32::Security::TOKEN_DUPLICATE; +use windows_sys::Win32::Security::TOKEN_PRIVILEGES; +use windows_sys::Win32::Security::TOKEN_QUERY; +use windows_sys::Win32::System::Threading::GetCurrentProcess; + +const DISABLE_MAX_PRIVILEGE: u32 = 0x01; +const LUA_TOKEN: u32 = 0x04; +const WRITE_RESTRICTED: u32 = 0x08; +const WIN_WORLD_SID: i32 = 1; +const SE_GROUP_LOGON_ID: u32 = 0xC0000000; + +pub unsafe fn world_sid() -> Result> { + let mut size: u32 = 0; + CreateWellKnownSid( + WIN_WORLD_SID, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut size, + ); + let mut buf: Vec = vec![0u8; size as usize]; + let ok = CreateWellKnownSid( + WIN_WORLD_SID, + std::ptr::null_mut(), + buf.as_mut_ptr() as *mut c_void, + &mut size, + ); + if ok == 0 { + return Err(anyhow!("CreateWellKnownSid failed: {}", GetLastError())); + } + Ok(buf) +} + +pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { + #[link(name = "advapi32")] + extern "system" { + fn ConvertStringSidToSidW(StringSid: *const u16, Sid: *mut *mut c_void) -> i32; + } + let mut psid: *mut c_void = std::ptr::null_mut(); + let ok = unsafe { ConvertStringSidToSidW(to_wide(s).as_ptr(), &mut psid) }; + if ok != 0 { + Some(psid) + } else { + None + } +} + +pub unsafe fn get_current_token_for_restriction() -> Result { + let desired = TOKEN_DUPLICATE + | TOKEN_QUERY + | TOKEN_ASSIGN_PRIMARY + | TOKEN_ADJUST_DEFAULT + | TOKEN_ADJUST_SESSIONID + | TOKEN_ADJUST_PRIVILEGES; + let mut h: HANDLE = 0; + #[link(name = "advapi32")] + extern "system" { + fn OpenProcessToken( + ProcessHandle: HANDLE, + DesiredAccess: u32, + TokenHandle: *mut HANDLE, + ) -> i32; + } + let ok = unsafe { OpenProcessToken(GetCurrentProcess(), desired, &mut h) }; + if ok == 0 { + return Err(anyhow!("OpenProcessToken failed: {}", GetLastError())); + } + Ok(h) +} + +pub unsafe fn get_logon_sid_bytes(h_token: HANDLE) -> Result> { + unsafe fn scan_token_groups_for_logon(h: HANDLE) -> Option> { + let mut needed: u32 = 0; + GetTokenInformation(h, TokenGroups, std::ptr::null_mut(), 0, &mut needed); + if needed == 0 { + return None; + } + let mut buf: Vec = vec![0u8; needed as usize]; + let ok = GetTokenInformation( + h, + TokenGroups, + buf.as_mut_ptr() as *mut c_void, + needed, + &mut needed, + ); + if ok == 0 || (needed as usize) < std::mem::size_of::() { + return None; + } + let group_count = std::ptr::read_unaligned(buf.as_ptr() as *const u32) as usize; + // TOKEN_GROUPS layout is: DWORD GroupCount; SID_AND_ATTRIBUTES Groups[]; + // On 64-bit, Groups is aligned to pointer alignment after 4-byte GroupCount. + let after_count = unsafe { buf.as_ptr().add(std::mem::size_of::()) } as usize; + let align = std::mem::align_of::(); + let aligned = (after_count + (align - 1)) & !(align - 1); + let groups_ptr = aligned as *const SID_AND_ATTRIBUTES; + for i in 0..group_count { + let entry: SID_AND_ATTRIBUTES = std::ptr::read_unaligned(groups_ptr.add(i)); + if (entry.Attributes & SE_GROUP_LOGON_ID) == SE_GROUP_LOGON_ID { + let sid = entry.Sid; + let sid_len = GetLengthSid(sid); + if sid_len == 0 { + return None; + } + let mut out = vec![0u8; sid_len as usize]; + if CopySid(sid_len, out.as_mut_ptr() as *mut c_void, sid) == 0 { + return None; + } + return Some(out); + } + } + None + } + + if let Some(v) = scan_token_groups_for_logon(h_token) { + return Ok(v); + } + + #[repr(C)] + struct TOKEN_LINKED_TOKEN { + linked_token: HANDLE, + } + const TOKEN_LINKED_TOKEN_CLASS: i32 = 19; // TokenLinkedToken + let mut ln_needed: u32 = 0; + GetTokenInformation( + h_token, + TOKEN_LINKED_TOKEN_CLASS, + std::ptr::null_mut(), + 0, + &mut ln_needed, + ); + if ln_needed >= std::mem::size_of::() as u32 { + let mut ln_buf: Vec = vec![0u8; ln_needed as usize]; + let ok = GetTokenInformation( + h_token, + TOKEN_LINKED_TOKEN_CLASS, + ln_buf.as_mut_ptr() as *mut c_void, + ln_needed, + &mut ln_needed, + ); + if ok != 0 { + let lt: TOKEN_LINKED_TOKEN = + std::ptr::read_unaligned(ln_buf.as_ptr() as *const TOKEN_LINKED_TOKEN); + if lt.linked_token != 0 { + let res = scan_token_groups_for_logon(lt.linked_token); + CloseHandle(lt.linked_token); + if let Some(v) = res { + return Ok(v); + } + } + } + } + + Err(anyhow!("Logon SID not present on token")) +} +unsafe fn enable_single_privilege(h_token: HANDLE, name: &str) -> Result<()> { + let mut luid = LUID { + LowPart: 0, + HighPart: 0, + }; + let ok = LookupPrivilegeValueW(std::ptr::null(), to_wide(name).as_ptr(), &mut luid); + if ok == 0 { + return Err(anyhow!("LookupPrivilegeValueW failed: {}", GetLastError())); + } + let mut tp: TOKEN_PRIVILEGES = std::mem::zeroed(); + tp.PrivilegeCount = 1; + tp.Privileges[0].Luid = luid; + tp.Privileges[0].Attributes = 0x00000002; // SE_PRIVILEGE_ENABLED + let ok2 = AdjustTokenPrivileges(h_token, 0, &tp, 0, std::ptr::null_mut(), std::ptr::null_mut()); + if ok2 == 0 { + return Err(anyhow!("AdjustTokenPrivileges failed: {}", GetLastError())); + } + let err = GetLastError(); + if err != 0 { + return Err(anyhow!("AdjustTokenPrivileges error {}", err)); + } + Ok(()) +} + +// removed unused create_write_restricted_token_strict + +pub unsafe fn create_workspace_write_token_with_cap( + psid_capability: *mut c_void, +) -> Result<(HANDLE, *mut c_void)> { + let base = get_current_token_for_restriction()?; + let mut logon_sid_bytes = get_logon_sid_bytes(base)?; + let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; + let mut everyone = world_sid()?; + let psid_everyone = everyone.as_mut_ptr() as *mut c_void; + let mut entries: [SID_AND_ATTRIBUTES; 3] = std::mem::zeroed(); + // Exact set and order: Capability, Logon, Everyone + entries[0].Sid = psid_capability; + entries[0].Attributes = 0; + entries[1].Sid = psid_logon; + entries[1].Attributes = 0; + entries[2].Sid = psid_everyone; + entries[2].Attributes = 0; + let mut new_token: HANDLE = 0; + let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; + let ok = CreateRestrictedToken( + base, + flags, + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 3, + entries.as_mut_ptr(), + &mut new_token, + ); + if ok == 0 { + return Err(anyhow!("CreateRestrictedToken failed: {}", GetLastError())); + } + enable_single_privilege(new_token, "SeChangeNotifyPrivilege")?; + Ok((new_token, psid_capability)) +} + +pub unsafe fn create_readonly_token_with_cap( + psid_capability: *mut c_void, +) -> Result<(HANDLE, *mut c_void)> { + let base = get_current_token_for_restriction()?; + let mut logon_sid_bytes = get_logon_sid_bytes(base)?; + let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; + let mut everyone = world_sid()?; + let psid_everyone = everyone.as_mut_ptr() as *mut c_void; + let mut entries: [SID_AND_ATTRIBUTES; 3] = std::mem::zeroed(); + // Exact set and order: Capability, Logon, Everyone + entries[0].Sid = psid_capability; + entries[0].Attributes = 0; + entries[1].Sid = psid_logon; + entries[1].Attributes = 0; + entries[2].Sid = psid_everyone; + entries[2].Attributes = 0; + let mut new_token: HANDLE = 0; + let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; + let ok = CreateRestrictedToken( + base, + flags, + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 3, + entries.as_mut_ptr(), + &mut new_token, + ); + if ok == 0 { + return Err(anyhow!("CreateRestrictedToken failed: {}", GetLastError())); + } + enable_single_privilege(new_token, "SeChangeNotifyPrivilege")?; + Ok((new_token, psid_capability)) +} diff --git a/codex-rs/windows-sandbox-rs/src/winutil.rs b/codex-rs/windows-sandbox-rs/src/winutil.rs new file mode 100644 index 0000000000..5e74ce072e --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/winutil.rs @@ -0,0 +1,43 @@ +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::System::Diagnostics::Debug::FormatMessageW; +use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_ALLOCATE_BUFFER; +use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_FROM_SYSTEM; +use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_IGNORE_INSERTS; + +pub fn to_wide>(s: S) -> Vec { + let mut v: Vec = s.as_ref().encode_wide().collect(); + v.push(0); + v +} + +// Produce a readable description for a Win32 error code. +pub fn format_last_error(err: i32) -> String { + unsafe { + let mut buf_ptr: *mut u16 = std::ptr::null_mut(); + let flags = FORMAT_MESSAGE_ALLOCATE_BUFFER + | FORMAT_MESSAGE_FROM_SYSTEM + | FORMAT_MESSAGE_IGNORE_INSERTS; + let len = FormatMessageW( + flags, + std::ptr::null(), + err as u32, + 0, + // FORMAT_MESSAGE_ALLOCATE_BUFFER expects a pointer to receive the allocated buffer. + // Cast &mut *mut u16 to *mut u16 as required by windows-sys. + (&mut buf_ptr as *mut *mut u16) as *mut u16, + 0, + std::ptr::null_mut(), + ); + if len == 0 || buf_ptr.is_null() { + return format!("Win32 error {}", err); + } + let slice = std::slice::from_raw_parts(buf_ptr, len as usize); + let mut s = String::from_utf16_lossy(slice); + s = s.trim().to_string(); + let _ = LocalFree(buf_ptr as HLOCAL); + s + } +} diff --git a/docs/sandbox.md b/docs/sandbox.md index d08f4d85d2..674ecc4838 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -69,6 +69,12 @@ The mechanism Codex uses to enforce the sandbox policy depends on your OS: - **macOS 12+** uses **Apple Seatbelt**. Codex invokes `sandbox-exec` with a profile that corresponds to the selected `--sandbox` mode, constraining filesystem and network access at the OS level. - **Linux** combines **Landlock** and **seccomp** APIs to approximate the same guarantees. Kernel support is required; older kernels may not expose the necessary features. +- **Windows (experimental)**: + - Launches commands inside a restricted token derived from an AppContainer profile. + - Grants only specifically requested filesystem capabilities by attaching capability SIDs to that profile. + - Disables outbound network access by overriding proxy-related environment variables and inserting stub executables for common network tools. + +Windows sandbox support remains highly experimental. It cannot prevent file writes, deletions, or creations in any directory where the Everyone SID already has write permissions (for example, world-writable folders). In containerized Linux environments (for example Docker), sandboxing may not work when the host or container configuration does not expose Landlock/seccomp. In those cases, configure the container to provide the isolation you need and run Codex with `--sandbox danger-full-access` (or the shorthand `--dangerously-bypass-approvals-and-sandbox`) inside that container.