Run fs helper through Windows sandbox wrapper

This commit is contained in:
David Wiesen
2026-06-11 15:42:40 -07:00
parent b295c376dd
commit cb50831046
5 changed files with 532 additions and 27 deletions
+3
View File
@@ -2846,8 +2846,11 @@ dependencies = [
"codex-shell-command",
"codex-test-binary-support",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-home-dir",
"codex-utils-pty",
"codex-utils-rustls-provider",
"codex-windows-sandbox",
"ctor 0.6.3",
"futures",
"http 1.4.0",
+5
View File
@@ -49,9 +49,14 @@ tokio-tungstenite = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[target.'cfg(windows)'.dependencies]
codex-utils-home-dir = { workspace = true }
codex-windows-sandbox = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
codex-test-binary-support = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
ctor = { workspace = true }
http = { workspace = true }
pretty_assertions = { workspace = true }
+218 -27
View File
@@ -59,11 +59,15 @@ impl FileSystemSandboxRunner {
request: FsHelperRequest,
) -> Result<FsHelperPayload, JSONRPCErrorError> {
let cwd = sandbox_cwd(sandbox)?;
let helper = self.helper_exe_for_launch()?;
let mut file_system_policy = sandbox.permissions.file_system_sandbox_policy();
// The sandboxed child re-enters Codex as the filesystem helper. On Windows
// that executable can be a materialized copy under CODEX_HOME/.sandbox-bin,
// so derive helper read roots from the exact helper path we will launch.
let helper_read_roots = if sandbox.use_legacy_landlock {
Vec::new()
} else {
helper_read_roots(&self.runtime_paths)
helper_read_roots(&helper, self.runtime_paths.codex_linux_sandbox_exe.as_ref())
};
add_helper_runtime_permissions(&mut file_system_policy, &helper_read_roots, cwd.as_path());
normalize_file_system_policy_root_aliases(&mut file_system_policy);
@@ -73,18 +77,48 @@ impl FileSystemSandboxRunner {
&file_system_policy,
network_policy,
);
let command = self.sandbox_exec_request(&permission_profile, &cwd, sandbox)?;
let command = self.sandbox_exec_request(&permission_profile, &cwd, sandbox, &helper)?;
let request_json = serde_json::to_vec(&request).map_err(json_error)?;
run_command(command, request_json).await
}
fn helper_exe_for_launch(&self) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
#[cfg(target_os = "windows")]
{
// Windows sandbox launch grants are prepared around the executable
// path that the sandbox will spawn. When exec-server is embedded or
// hosted, `current_exe()` can point at the host process instead of
// the configured Codex binary, so materialize the runtime-provided
// helper and use that exact path for both launch and read roots.
let codex_home = codex_utils_home_dir::find_codex_home().map_err(|err| {
internal_error(format!(
"windows fs sandbox helper failed to resolve CODEX_HOME: {err}"
))
})?;
let helper = codex_windows_sandbox::resolve_exe_for_launch(
self.runtime_paths.codex_self_exe.as_path(),
codex_home.as_path(),
);
AbsolutePathBuf::from_absolute_path(helper.as_path()).map_err(|err| {
internal_error(format!(
"windows fs sandbox helper path is not absolute: {err}"
))
})
}
#[cfg(not(target_os = "windows"))]
{
Ok(self.runtime_paths.codex_self_exe.clone())
}
}
fn sandbox_exec_request(
&self,
permission_profile: &PermissionProfile,
cwd: &AbsolutePathBuf,
sandbox_context: &FileSystemSandboxContext,
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
let helper = &self.runtime_paths.codex_self_exe;
helper: &AbsolutePathBuf,
) -> Result<PreparedFsHelperCommand, JSONRPCErrorError> {
let sandbox_manager = SandboxManager::new();
let (file_system_policy, network_policy) = permission_profile.to_runtime_permissions();
let sandbox = sandbox_manager.select_initial(
@@ -101,7 +135,7 @@ impl FileSystemSandboxRunner {
env: self.helper_env.clone(),
additional_permissions: None,
};
sandbox_manager
let mut command = sandbox_manager
.transform(SandboxTransformRequest {
command,
permissions: permission_profile,
@@ -114,7 +148,43 @@ impl FileSystemSandboxRunner {
windows_sandbox_level: sandbox_context.windows_sandbox_level,
windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop,
})
.map_err(|err| invalid_request(format!("failed to prepare fs sandbox: {err}")))
.map_err(|err| invalid_request(format!("failed to prepare fs sandbox: {err}")))?;
#[cfg(target_os = "windows")]
if command.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken {
let request_file = wrap_windows_sandbox_command(&mut command, helper)?;
return Ok(PreparedFsHelperCommand::with_windows_wrapper_request_file(
command,
request_file,
));
}
Ok(PreparedFsHelperCommand::new(command))
}
}
struct PreparedFsHelperCommand {
request: SandboxExecRequest,
#[cfg(target_os = "windows")]
_windows_wrapper_request_file: Option<WindowsSandboxWrapperRequestFile>,
}
impl PreparedFsHelperCommand {
fn new(request: SandboxExecRequest) -> Self {
Self {
request,
#[cfg(target_os = "windows")]
_windows_wrapper_request_file: None,
}
}
#[cfg(target_os = "windows")]
fn with_windows_wrapper_request_file(
request: SandboxExecRequest,
request_file: WindowsSandboxWrapperRequestFile,
) -> Self {
Self {
request,
_windows_wrapper_request_file: Some(request_file),
}
}
}
@@ -134,10 +204,13 @@ fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result<AbsolutePathBuf, JS
.map_err(|err| invalid_request(format!("current directory is not absolute: {err}")))
}
fn helper_read_roots(runtime_paths: &ExecServerRuntimePaths) -> Vec<AbsolutePathBuf> {
fn helper_read_roots(
codex_self_exe: &AbsolutePathBuf,
codex_linux_sandbox_exe: Option<&AbsolutePathBuf>,
) -> Vec<AbsolutePathBuf> {
let mut roots = Vec::new();
for path in std::iter::once(runtime_paths.codex_self_exe.as_path())
.chain(runtime_paths.codex_linux_sandbox_exe.as_deref())
for path in std::iter::once(codex_self_exe.as_path())
.chain(codex_linux_sandbox_exe.map(AbsolutePathBuf::as_path))
{
if let Some(parent) = path.parent()
&& let Ok(root) = AbsolutePathBuf::from_absolute_path(parent)
@@ -247,20 +320,11 @@ fn bazel_bwrap_env_key_is_allowed(_key: &str) -> bool {
}
async fn run_command(
command: SandboxExecRequest,
command: PreparedFsHelperCommand,
request_json: Vec<u8>,
) -> Result<FsHelperPayload, JSONRPCErrorError> {
let mut child = spawn_command(command)?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| internal_error("failed to open fs sandbox helper stdin".to_string()))?;
stdin.write_all(&request_json).await.map_err(io_error)?;
stdin.shutdown().await.map_err(io_error)?;
drop(stdin);
let output = child.wait_with_output().await.map_err(io_error)?;
if !output.status.success() {
let output = run_child_command(command, request_json).await?;
if !output.success {
return Err(internal_error(format!(
"fs sandbox helper failed with status {status}: {stderr}",
status = output.status,
@@ -274,7 +338,36 @@ async fn run_command(
}
}
fn spawn_command(
struct FsHelperCommandOutput {
success: bool,
status: String,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
async fn run_child_command(
command: PreparedFsHelperCommand,
request_json: Vec<u8>,
) -> Result<FsHelperCommandOutput, JSONRPCErrorError> {
let mut child = spawn_child_command(command.request)?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| internal_error("failed to open fs sandbox helper stdin".to_string()))?;
stdin.write_all(&request_json).await.map_err(io_error)?;
stdin.shutdown().await.map_err(io_error)?;
drop(stdin);
let output = child.wait_with_output().await.map_err(io_error)?;
Ok(FsHelperCommandOutput {
success: output.status.success(),
status: output.status.to_string(),
stdout: output.stdout,
stderr: output.stderr,
})
}
fn spawn_child_command(
SandboxExecRequest {
command: argv,
cwd,
@@ -304,6 +397,90 @@ fn spawn_command(
command.spawn().map_err(io_error)
}
#[cfg(target_os = "windows")]
fn wrap_windows_sandbox_command(
request: &mut SandboxExecRequest,
helper: &AbsolutePathBuf,
) -> Result<WindowsSandboxWrapperRequestFile, JSONRPCErrorError> {
let codex_home = codex_utils_home_dir::find_codex_home().map_err(|err| {
internal_error(format!(
"windows fs sandbox helper failed to resolve CODEX_HOME: {err}"
))
})?;
let wrapper_request =
codex_windows_sandbox::create_windows_sandbox_wrapper_request_for_permission_profile(
std::mem::take(&mut request.command),
request.cwd.clone(),
request.permission_profile.clone(),
request.windows_sandbox_level,
request.windows_sandbox_private_desktop,
codex_home.to_path_buf(),
);
let request_file =
WindowsSandboxWrapperRequestFile::create(codex_home.as_path(), &wrapper_request)?;
let mut args = codex_windows_sandbox::create_windows_sandbox_command_args_for_request_file(
request_file.path.as_path(),
);
request.command = Vec::with_capacity(1 + args.len());
request
.command
.push(helper.as_path().to_string_lossy().into_owned());
request.command.append(&mut args);
request.sandbox = codex_sandboxing::SandboxType::None;
request.arg0 = None;
Ok(request_file)
}
#[cfg(target_os = "windows")]
struct WindowsSandboxWrapperRequestFile {
path: std::path::PathBuf,
}
#[cfg(target_os = "windows")]
impl WindowsSandboxWrapperRequestFile {
fn create(
codex_home: &std::path::Path,
request: &codex_windows_sandbox::WindowsSandboxWrapperRequest,
) -> Result<Self, JSONRPCErrorError> {
let request_dir = codex_windows_sandbox::sandbox_dir(codex_home);
std::fs::create_dir_all(&request_dir).map_err(|err| {
internal_error(format!(
"failed to create windows fs sandbox wrapper request dir {}: {err}",
request_dir.display()
))
})?;
let path = request_dir.join(format!(
"fs-helper-wrapper-request-{}.json",
uuid::Uuid::new_v4()
));
let request_json = serde_json::to_vec(request).map_err(json_error)?;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|err| {
internal_error(format!(
"failed to create windows fs sandbox wrapper request file {}: {err}",
path.display()
))
})?;
std::io::Write::write_all(&mut file, &request_json).map_err(|err| {
internal_error(format!(
"failed to write windows fs sandbox wrapper request file {}: {err}",
path.display()
))
})?;
Ok(Self { path })
}
}
#[cfg(target_os = "windows")]
impl Drop for WindowsSandboxWrapperRequestFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn io_error(err: std::io::Error) -> JSONRPCErrorError {
internal_error(err.to_string())
}
@@ -387,7 +564,10 @@ mod tests {
add_helper_runtime_permissions(
&mut policy,
&helper_read_roots(&runtime_paths),
&helper_read_roots(
&runtime_paths.codex_self_exe,
/*codex_linux_sandbox_exe*/ None,
),
cwd.as_path(),
);
@@ -498,10 +678,15 @@ mod tests {
let sandbox_context = sandbox_context_with_cwd(&file_system_policy, cwd.clone());
let request = runner
.sandbox_exec_request(&permission_profile, &cwd, &sandbox_context)
.sandbox_exec_request(
&permission_profile,
&cwd,
&sandbox_context,
&runner.runtime_paths.codex_self_exe,
)
.expect("sandbox exec request");
assert_eq!(request.env.get(&path_key), Some(&path));
assert_eq!(request.request.env.get(&path_key), Some(&path));
}
#[test]
@@ -556,7 +741,10 @@ mod tests {
add_helper_runtime_permissions(
&mut policy,
&helper_read_roots(&runtime_paths),
&helper_read_roots(
&runtime_paths.codex_self_exe,
/*codex_linux_sandbox_exe*/ None,
),
cwd.as_path(),
);
@@ -581,7 +769,10 @@ mod tests {
add_helper_runtime_permissions(
&mut policy,
&helper_read_roots(&runtime_paths),
&helper_read_roots(
&runtime_paths.codex_self_exe,
runtime_paths.codex_linux_sandbox_exe.as_ref(),
),
cwd.as_path(),
);
+6
View File
@@ -12,6 +12,8 @@ use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use codex_test_binary_support::TestBinaryDispatchGuard;
use codex_test_binary_support::TestBinaryDispatchMode;
use codex_test_binary_support::configure_test_binary_dispatch;
#[cfg(target_os = "windows")]
use codex_windows_sandbox::CODEX_WINDOWS_SANDBOX_ARG1;
use ctor::ctor;
pub(crate) mod exec_server;
@@ -27,6 +29,10 @@ pub static TEST_BINARY_DISPATCH_GUARD: Option<TestBinaryDispatchGuard> = {
if argv1 == Some(CODEX_FS_HELPER_ARG1) {
return TestBinaryDispatchMode::DispatchArg0Only;
}
#[cfg(target_os = "windows")]
if argv1 == Some(CODEX_WINDOWS_SANDBOX_ARG1) {
return TestBinaryDispatchMode::DispatchArg0Only;
}
if exe_name == CODEX_LINUX_SANDBOX_ARG0 {
return TestBinaryDispatchMode::DispatchArg0Only;
}
@@ -0,0 +1,300 @@
#![cfg(target_os = "windows")]
mod common;
use anyhow::Context;
use anyhow::Result;
use codex_exec_server::ExecServerRuntimePaths;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::LocalFileSystem;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serial_test::serial;
use std::path::Path;
use tempfile::TempDir;
struct EnvVarGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &std::ffi::OsStr) -> Self {
let original = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
struct SandboxedFileSystem {
_codex_home: TempDir,
_guard: EnvVarGuard,
file_system: LocalFileSystem,
}
fn sandboxed_file_system() -> Result<SandboxedFileSystem> {
let (codex_exe, _) = common::current_test_binary_helper_paths()?;
sandboxed_file_system_with_codex_exe(codex_exe)
}
fn sandboxed_file_system_with_codex_exe(
codex_exe: std::path::PathBuf,
) -> Result<SandboxedFileSystem> {
let codex_home = TempDir::new()?;
let guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str());
let file_system = LocalFileSystem::with_runtime_paths(ExecServerRuntimePaths::new(
codex_exe, /*codex_linux_sandbox_exe*/ None,
)?);
Ok(SandboxedFileSystem {
_codex_home: codex_home,
_guard: guard,
file_system,
})
}
fn process_is_elevated() -> bool {
let status = std::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"$principal = [Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent(); if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 0 } else { exit 1 }",
])
.status();
status.is_ok_and(|status| status.success())
}
fn stage_windows_sandbox_helpers(codex_home: &Path) -> Result<bool> {
let helper_dir = codex_home.join(".sandbox-bin");
std::fs::create_dir_all(&helper_dir)
.with_context(|| format!("create helper dir {}", helper_dir.display()))?;
for helper_name in ["codex-windows-sandbox-setup", "codex-command-runner"] {
let helper = match codex_utils_cargo_bin::cargo_bin(helper_name) {
Ok(helper) => helper,
Err(err) => {
eprintln!("skipping elevated fs-helper test: {helper_name} unavailable: {err}");
return Ok(false);
}
};
std::fs::copy(
&helper,
helper_dir.join(Path::new(helper_name).with_extension("exe")),
)
.with_context(|| format!("stage Windows sandbox helper {}", helper.display()))?;
}
Ok(true)
}
fn temp_workspace() -> Result<(TempDir, AbsolutePathBuf)> {
let dir = TempDir::new()?;
let path = AbsolutePathBuf::try_from(std::fs::canonicalize(dir.path())?)?;
Ok((dir, path))
}
fn root_read_entry() -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}
}
fn path_entry(path: AbsolutePathBuf, access: FileSystemAccessMode) -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access,
}
}
fn sandbox_from_entries(
workspace: AbsolutePathBuf,
windows_sandbox_level: WindowsSandboxLevel,
entries: Vec<FileSystemSandboxEntry>,
) -> FileSystemSandboxContext {
let file_system_policy = FileSystemSandboxPolicy::restricted(entries);
let permissions = PermissionProfile::from_runtime_permissions(
&file_system_policy,
NetworkSandboxPolicy::Restricted,
);
let mut sandbox =
FileSystemSandboxContext::from_permission_profile_with_cwd(permissions, workspace);
sandbox.windows_sandbox_level = windows_sandbox_level;
sandbox
}
fn workspace_write_sandbox(
workspace: AbsolutePathBuf,
windows_sandbox_level: WindowsSandboxLevel,
) -> FileSystemSandboxContext {
sandbox_from_entries(
workspace.clone(),
windows_sandbox_level,
vec![
root_read_entry(),
path_entry(workspace, FileSystemAccessMode::Write),
],
)
}
fn workspace_write_sandbox_with_read_only_path(
workspace: AbsolutePathBuf,
read_only_path: AbsolutePathBuf,
windows_sandbox_level: WindowsSandboxLevel,
) -> FileSystemSandboxContext {
sandbox_from_entries(
workspace.clone(),
windows_sandbox_level,
vec![
root_read_entry(),
path_entry(workspace, FileSystemAccessMode::Write),
path_entry(read_only_path, FileSystemAccessMode::Read),
],
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial(codex_home)]
async fn restricted_token_fs_helper_uses_configured_helper_path() -> Result<()> {
let helper_dir = TempDir::new()?;
let current_exe = std::env::current_exe()?;
let configured_helper = helper_dir.path().join("configured-codex-helper.exe");
std::fs::copy(&current_exe, &configured_helper)?;
let sandboxed = sandboxed_file_system_with_codex_exe(configured_helper.clone())?;
let (_workspace_dir, workspace) = temp_workspace()?;
let file_path = workspace.join("allowed.txt");
let sandbox = workspace_write_sandbox(workspace, WindowsSandboxLevel::RestrictedToken);
sandboxed
.file_system
.write_file(&file_path, b"allowed".to_vec(), Some(&sandbox))
.await?;
let materialized_helper = sandboxed._codex_home.path().join(".sandbox-bin").join(
configured_helper
.file_name()
.expect("configured helper name"),
);
assert_eq!(std::fs::read(&file_path)?, b"allowed");
assert_eq!(materialized_helper.exists(), true);
assert_eq!(
materialized_helper.file_name(),
configured_helper.file_name()
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial(codex_home)]
async fn restricted_token_fs_helper_rejects_missing_read_only_carveout() -> Result<()> {
let sandboxed = sandboxed_file_system()?;
let (_workspace_dir, workspace) = temp_workspace()?;
let protected_dir = workspace.join("protected");
let blocked_path = protected_dir.join("blocked.txt");
let sandbox = workspace_write_sandbox_with_read_only_path(
workspace,
protected_dir,
WindowsSandboxLevel::RestrictedToken,
);
let error = sandboxed
.file_system
.write_file(&blocked_path, b"blocked".to_vec(), Some(&sandbox))
.await
.expect_err("read-only carveout should reject writes even before it exists");
assert!(
error.to_string().contains("Access is denied")
|| error.to_string().contains("Permission denied")
|| error.to_string().contains("Operation not permitted")
|| error.to_string().contains("is not permitted"),
"unexpected sandbox denial message: {error}",
);
assert_eq!(blocked_path.exists(), false);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial(codex_home)]
async fn elevated_fs_helper_rejects_unenforceable_reopened_writable_descendant() -> Result<()> {
let sandboxed = sandboxed_file_system()?;
let (_workspace_dir, workspace) = temp_workspace()?;
let read_only_dir = workspace.join("read-only");
let reopened_dir = read_only_dir.join("writable");
let target_path = reopened_dir.join("blocked.txt");
let sandbox = sandbox_from_entries(
workspace.clone(),
WindowsSandboxLevel::Elevated,
vec![
root_read_entry(),
path_entry(workspace, FileSystemAccessMode::Write),
path_entry(read_only_dir, FileSystemAccessMode::Read),
path_entry(reopened_dir, FileSystemAccessMode::Write),
],
);
let error = sandboxed
.file_system
.write_file(&target_path, b"blocked".to_vec(), Some(&sandbox))
.await
.expect_err("elevated fs helper should reject unenforceable reopened descendants");
assert!(
error.to_string().contains(
"windows elevated sandbox cannot reopen writable descendants under read-only carveouts"
),
"unexpected elevated sandbox error: {error}",
);
assert_eq!(target_path.exists(), false);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial(codex_home)]
async fn elevated_fs_helper_allows_writable_path_when_setup_is_available() -> Result<()> {
if !process_is_elevated() {
eprintln!("skipping elevated fs-helper test: test process is not elevated");
return Ok(());
}
let sandboxed = sandboxed_file_system()?;
if !stage_windows_sandbox_helpers(sandboxed._codex_home.path())? {
return Ok(());
}
let (_workspace_dir, workspace) = temp_workspace()?;
let file_path = workspace.join("allowed-elevated.txt");
let sandbox = workspace_write_sandbox(workspace, WindowsSandboxLevel::Elevated);
sandboxed
.file_system
.write_file(&file_path, b"allowed elevated".to_vec(), Some(&sandbox))
.await?;
assert_eq!(std::fs::read(&file_path)?, b"allowed elevated");
Ok(())
}