Windows Sandbox - Alpha version (#4905)

- Added the new codex-windows-sandbox crate that builds both a library
entry point (run_windows_sandbox_capture) and a CLI executable to launch
commands inside a Windows restricted-token sandbox, including ACL
management, capability SID provisioning, network lockdown, and output
capture
(windows-sandbox-rs/src/lib.rs:167, windows-sandbox-rs/src/main.rs:54).
- Introduced the experimental WindowsSandbox feature flag and wiring so
Windows builds can opt into the sandbox:
SandboxType::WindowsRestrictedToken, the in-process execution path, and
platform sandbox selection now honor the flag (core/src/features.rs:47,
core/src/config.rs:1224, core/src/safety.rs:19,
core/src/sandboxing/mod.rs:69, core/src/exec.rs:79,
core/src/exec.rs:172).
- Updated workspace metadata to include the new crate and its
Windows-specific dependencies so the core crate can link against it
(codex-rs/
    Cargo.toml:91, core/Cargo.toml:86).
- Added a PowerShell bootstrap script that installs the Windows
toolchain, required CLI utilities, and builds the workspace to ease
development
    on the platform (scripts/setup-windows.ps1:1).
- Landed a Python smoke-test suite that exercises
read-only/workspace-write policies, ACL behavior, and network denial for
the Windows sandbox
    binary (windows-sandbox-rs/sandbox_smoketests.py:1).
This commit is contained in:
iceweasel-oai
2025-10-30 15:51:57 -07:00
committed by GitHub
parent ff6d4cec6b
commit 87cce88f48
34 changed files with 2994 additions and 30 deletions

14
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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]...

View File

@@ -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 }

View File

@@ -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<PathBuf>,
) -> 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?;

View File

@@ -32,3 +32,17 @@ pub struct LandlockCommand {
#[arg(trailing_var_arg = true)]
pub command: Vec<String>,
}
#[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<String>,
}

View File

@@ -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<PathBuf>) -> 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<PathBuf>) -> 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.

View File

@@ -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")?;

View File

@@ -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]

View File

@@ -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;

View File

@@ -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<RawExecToolCallOutput> {
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<RawExecToolCallOutput, CodexErr>,
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<StdoutStream>,
) -> Result<RawExecToolCallOutput> {
#[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)]

View File

@@ -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,
},
];

View File

@@ -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<SandboxType> {
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
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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

View File

@@ -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);
})]

View File

@@ -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())
})

View File

@@ -89,7 +89,7 @@ pub(crate) fn compose_account_display(config: &Config) -> Option<StatusAccountDi
if let Some(tokens) = auth.tokens.as_ref() {
let info = &tokens.id_token;
let email = info.email.clone();
let plan = info.get_chatgpt_plan_type().map(|plan| title_case(&plan));
let plan = info.get_chatgpt_plan_type().as_deref().map(title_case);
return Some(StatusAccountDisplay::ChatGpt { email, plan });
}

369
codex-rs/windows-sandbox-rs/Cargo.lock generated Normal file
View File

@@ -0,0 +1,369 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "bitflags"
version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "codex-windows-sandbox"
version = "0.1.0"
dependencies = [
"anyhow",
"dirs-next",
"rand",
"serde",
"serde_json",
"thiserror",
"windows-sys",
]
[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "libc"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "libredox"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
dependencies = [
"bitflags",
"libc",
]
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom",
"libredox",
"thiserror",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
name = "syn"
version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zerocopy"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@@ -0,0 +1,43 @@
[package]
name = "codex-windows-sandbox"
version = "0.1.0"
edition = "2021"
[lib]
name = "codex_windows_sandbox"
path = "src/lib.rs"
[dependencies]
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[dependencies.rand]
version = "0.8"
default-features = false
features = ["std", "small_rng"]
[dependencies.dirs-next]
version = "2.0"
[dependencies.windows-sys]
version = "0.52"
features = [
"Win32_Foundation",
"Win32_System_Diagnostics_Debug",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_System_Threading",
"Win32_System_JobObjects",
"Win32_System_SystemServices",
"Win32_System_Environment",
"Win32_System_Pipes",
"Win32_System_WindowsProgramming",
"Win32_System_IO",
"Win32_System_Memory",
"Win32_System_Kernel",
"Win32_System_Console",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_ToolHelp",
"Win32_Networking_WinSock",
"Win32_System_LibraryLoader",
"Win32_System_Com",
"Win32_Security_Authentication_Identity",
]

View File

@@ -0,0 +1,306 @@
# sandbox_smoketests.py
# Run a suite of smoke tests against the Windows sandbox via the Codex CLI
# Requires: Python 3.8+ on Windows. No pip requirements.
import os
import sys
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional, Tuple
def _resolve_codex_cmd() -> 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)
# 2123. 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())

View File

@@ -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::<ACL_SIZE_INFORMATION>() 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::<ACE_HEADER>() + std::mem::size_of::<u32>()) 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<bool> {
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;

View File

@@ -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<String, String>,
) -> Vec<PathBuf> {
let mut allow: Vec<PathBuf> = 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
}

View File

@@ -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<PathBuf>, out: &mut Vec<PathBuf>, 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<String, String>) -> Vec<PathBuf> {
let mut set: HashSet<PathBuf> = HashSet::new();
let mut out: Vec<PathBuf> = 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<bool> {
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<String, String>,
) -> Result<()> {
let start = Instant::now();
let mut flagged: Vec<PathBuf> = 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(())
}

View File

@@ -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::<CapSids>(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(),
}
}

View File

@@ -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<String, String>) {
let keys: Vec<String> = 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<String, String>) {
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<String, String>, prefix: &str) {
let existing = env_map
.get("PATH")
.cloned()
.or_else(|| env::var("PATH").ok())
.unwrap_or_default();
let parts: Vec<String> = 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<String, String>) {
let default = env_map
.get("PATHEXT")
.cloned()
.or_else(|| env::var("PATHEXT").ok())
.unwrap_or(".COM;.EXE;.BAT;.CMD".to_string());
let exts: Vec<String> = default
.split(';')
.filter(|e| !e.is_empty())
.map(|s| s.to_string())
.collect();
let exts_norm: Vec<String> = exts.iter().map(|e| e.to_ascii_uppercase()).collect();
let want = [".BAT", ".CMD"]; // move to front if present
let mut front: Vec<String> = 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<String> = 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<PathBuf> {
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<String, String>) -> 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(())
}

View File

@@ -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<String, String>) -> Vec<u16> {
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<u16> = 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<PipeHandles> {
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<u8>,
pub stderr: Vec<u8>,
pub timed_out: bool,
}
pub fn preflight_audit_everyone_writable(
cwd: &Path,
env_map: &HashMap<String, String>,
) -> 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<String>,
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
) -> Result<CaptureResult> {
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(&current_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, &current_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::<STARTUPINFOW>() 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::<Vec<_>>()
.join(" ");
let mut cmdline: Vec<u16> = 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::<Vec<u8>>();
let (tx_err, rx_err) = std::sync::mpsc::channel::<Vec<u8>>();
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<u8>,
pub stderr: Vec<u8>,
pub timed_out: bool,
}
pub fn preflight_audit_everyone_writable(
_cwd: &Path,
_env_map: &HashMap<String, String>,
) -> 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<String>,
_cwd: &Path,
_env_map: HashMap<String, String>,
_timeout_ms: Option<u64>,
) -> Result<CaptureResult> {
bail!("Windows sandbox is only available on Windows")
}
}

View File

@@ -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);
}
}

View File

@@ -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<String>,
}
#[derive(Clone, Debug)]
pub enum SandboxMode {
ReadOnly,
WorkspaceWrite,
}
#[derive(Clone, Debug)]
pub struct SandboxPolicy(pub SandboxMode);
impl SandboxPolicy {
pub fn parse(value: &str) -> Result<Self> {
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),
})
}
}
}
}

View File

@@ -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<String, String>) -> Vec<u16> {
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<u16> = 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<String, String>,
) -> Result<(PROCESS_INFORMATION, STARTUPINFOW)> {
let cmdline_str = argv
.iter()
.map(|a| quote_arg(a))
.collect::<Vec<_>>()
.join(" ");
let mut cmdline: Vec<u16> = 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::<STARTUPINFOW>() 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<i32> {
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<HANDLE> {
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::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() 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(())
}

View File

@@ -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<Vec<u8>> {
let mut size: u32 = 0;
CreateWellKnownSid(
WIN_WORLD_SID,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut size,
);
let mut buf: Vec<u8> = 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<HANDLE> {
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<Vec<u8>> {
unsafe fn scan_token_groups_for_logon(h: HANDLE) -> Option<Vec<u8>> {
let mut needed: u32 = 0;
GetTokenInformation(h, TokenGroups, std::ptr::null_mut(), 0, &mut needed);
if needed == 0 {
return None;
}
let mut buf: Vec<u8> = 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::<u32>() {
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::<u32>()) } as usize;
let align = std::mem::align_of::<SID_AND_ATTRIBUTES>();
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::<TOKEN_LINKED_TOKEN>() as u32 {
let mut ln_buf: Vec<u8> = 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))
}

View File

@@ -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: AsRef<OsStr>>(s: S) -> Vec<u16> {
let mut v: Vec<u16> = 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
}
}

View File

@@ -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.