mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
0ed2735d19
# Why When an organization requires the elevated Windows sandbox, Codex launches an elevated helper to provision users, configure firewall and ACL rules, and lock persistent sandbox directories. We observed that closing the helper after setup started could leave the machine partially initialized while the TUI still announced **Sandbox ready**. Model-only turns continued to work, but the first shell command retried setup and failed with Windows cancellation error `1223`. This was not an enforcement bypass; command execution continued to fail closed. The issue was a false readiness signal: `setup_marker.json` was written during user provisioning, before the remaining setup stages had completed. # What Treat `setup_marker.json` as the commit record for Windows sandbox setup: 1. Before full or provisioning setup begins, remove the existing marker and create the final marker path with a protected ACL. 2. Keep the marker empty and therefore invalid while setup is in progress. Sandbox users cannot read, modify, or replace it. 3. Run every synchronous setup stage. 4. After setup succeeds, write the valid marker contents without changing its ACL. 5. After the helper exits successfully, verify the existing readiness check before enabling the sandbox. If setup is canceled or fails, the marker remains invalid and Codex reports setup as incomplete instead of announcing readiness. Refresh-only and read-ACL-only helper runs continue to leave the marker untouched. The setup version remains `5` to avoid forcing all existing Windows users through elevated setup again. # Verification - Added coverage confirming sandbox users cannot read or modify the setup marker after elevated setup. - Added coverage confirming a successful helper exit without complete setup artifacts is rejected. - Ran `just test -p codex-windows-sandbox`.
323 lines
12 KiB
Rust
323 lines
12 KiB
Rust
use anyhow::Context;
|
|
use codex_core::exec::ExecCapturePolicy;
|
|
use codex_core::exec::ExecParams;
|
|
use codex_core::exec::process_exec_tool_call;
|
|
use codex_core::sandboxing::SandboxPermissions;
|
|
use codex_core::windows_sandbox::sandbox_setup_is_complete;
|
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
|
use codex_protocol::exec_output::ExecToolCallOutput;
|
|
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 core_test_support::PathExt;
|
|
use pretty_assertions::assert_eq;
|
|
use serial_test::serial;
|
|
use std::collections::HashMap;
|
|
use std::ffi::OsString;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use tempfile::TempDir;
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
original: Option<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),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum TestCodexHome {
|
|
Persistent(PathBuf),
|
|
Temporary(TempDir),
|
|
}
|
|
|
|
impl TestCodexHome {
|
|
fn path(&self) -> &Path {
|
|
match self {
|
|
Self::Persistent(path) => path.as_path(),
|
|
Self::Temporary(temp_dir) => temp_dir.path(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn codex_home_for_windows_sandbox_test(name: &str) -> anyhow::Result<TestCodexHome> {
|
|
if let Some(test_tmpdir) = std::env::var_os("TEST_TMPDIR") {
|
|
// The elevated backend provisions machine-local sandbox users. Bazel
|
|
// retries run in the same Windows VM, so keep CODEX_HOME stable within
|
|
// the test temp root and let setup reconcile its persisted ACL state.
|
|
let codex_home = PathBuf::from(test_tmpdir).join(name);
|
|
std::fs::create_dir_all(&codex_home)
|
|
.with_context(|| format!("create stable test CODEX_HOME {}", codex_home.display()))?;
|
|
return Ok(TestCodexHome::Persistent(codex_home));
|
|
}
|
|
|
|
Ok(TestCodexHome::Temporary(TempDir::new()?))
|
|
}
|
|
|
|
fn stage_windows_sandbox_helpers() -> anyhow::Result<()> {
|
|
let test_exe = std::env::current_exe().context("resolve current Windows test executable")?;
|
|
let test_exe_dir = test_exe
|
|
.parent()
|
|
.context("Windows test executable should have a parent directory")?;
|
|
let resources_dir = test_exe_dir.join("codex-resources");
|
|
match std::fs::create_dir_all(&resources_dir) {
|
|
Ok(()) => {}
|
|
Err(err)
|
|
if err.kind() == std::io::ErrorKind::PermissionDenied && resources_dir.is_dir() => {}
|
|
Err(err) => {
|
|
return Err(err)
|
|
.with_context(|| format!("create resources dir {}", resources_dir.display()));
|
|
}
|
|
}
|
|
for helper_name in ["codex-windows-sandbox-setup", "codex-command-runner"] {
|
|
let helper = codex_utils_cargo_bin::cargo_bin(helper_name)?;
|
|
let file_name = Path::new(helper_name).with_extension("exe");
|
|
let destination = resources_dir.join(file_name);
|
|
if let Err(err) = std::fs::copy(&helper, &destination) {
|
|
// A sandbox helper can briefly remain alive after the sandboxed
|
|
// command exits. Bazel may retry the test while that process still
|
|
// has the staged executable open, so keep the already-staged copy.
|
|
if err.kind() == std::io::ErrorKind::PermissionDenied && destination.exists() {
|
|
continue;
|
|
}
|
|
return Err(err).with_context(|| {
|
|
format!(
|
|
"stage Windows sandbox helper {} at {}",
|
|
helper.display(),
|
|
destination.display()
|
|
)
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_home)]
|
|
async fn windows_restricted_token_rejects_exact_and_glob_deny_read_policy() -> anyhow::Result<()> {
|
|
let codex_home =
|
|
codex_home_for_windows_sandbox_test("windows-restricted-token-deny-read-codex-home")?;
|
|
let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str());
|
|
let workspace = TempDir::new()?;
|
|
let cwd = dunce::canonicalize(workspace.path())?.abs();
|
|
let secret = cwd.join("secret.env");
|
|
let future_secret = cwd.join("future.env");
|
|
let public = cwd.join("public.txt");
|
|
std::fs::write(&secret, "glob secret\n")?;
|
|
std::fs::write(&public, "public ok\n")?;
|
|
|
|
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: "**/*.env".to_string(),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: future_secret,
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
},
|
|
]);
|
|
let permission_profile = PermissionProfile::from_runtime_permissions(
|
|
&file_system_sandbox_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
);
|
|
|
|
let err = process_exec_tool_call(
|
|
ExecParams {
|
|
command: vec![
|
|
"cmd.exe".to_string(),
|
|
"/D".to_string(),
|
|
"/C".to_string(),
|
|
"type secret.env >NUL 2>NUL & echo exact secret 1>future.env 2>NUL & type future.env 2>NUL & type public.txt & exit /B 0"
|
|
.to_string(),
|
|
],
|
|
cwd: cwd.clone(),
|
|
expiration: 10_000.into(),
|
|
capture_policy: ExecCapturePolicy::ShellTool,
|
|
env: HashMap::new(),
|
|
network: None,
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
|
|
windows_sandbox_private_desktop: false,
|
|
justification: None,
|
|
arg0: None,
|
|
},
|
|
&permission_profile,
|
|
&cwd,
|
|
std::slice::from_ref(&cwd),
|
|
&None,
|
|
/*use_legacy_landlock*/ false,
|
|
/*stdout_stream*/ None,
|
|
)
|
|
.await
|
|
.expect_err("restricted-token sandbox should reject deny-read restrictions");
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"unsupported operation: windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_home)]
|
|
async fn windows_elevated_enforces_deny_read_and_protects_setup_marker() -> anyhow::Result<()> {
|
|
let codex_home = codex_home_for_windows_sandbox_test("windows-elevated-deny-read-codex-home")?;
|
|
let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str());
|
|
stage_windows_sandbox_helpers()?;
|
|
let workspace = TempDir::new()?;
|
|
let cwd = dunce::canonicalize(workspace.path())?.abs();
|
|
let glob_secret = cwd.join("secret.env");
|
|
let exact_secret = cwd.join("exact-secret.txt");
|
|
let public = cwd.join("public.txt");
|
|
let setup_marker = codex_home.path().join(".sandbox").join("setup_marker.json");
|
|
std::fs::write(&glob_secret, "glob secret\n")?;
|
|
std::fs::write(&exact_secret, "exact secret\n")?;
|
|
std::fs::write(&public, "public ok\n")?;
|
|
|
|
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: "**/*.env".to_string(),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: exact_secret },
|
|
access: FileSystemAccessMode::Deny,
|
|
},
|
|
]);
|
|
let permission_profile = PermissionProfile::from_runtime_permissions(
|
|
&file_system_sandbox_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
);
|
|
|
|
let ExecToolCallOutput {
|
|
exit_code,
|
|
stdout,
|
|
..
|
|
} = process_exec_tool_call(
|
|
ExecParams {
|
|
command: vec![
|
|
"cmd.exe".to_string(),
|
|
"/D".to_string(),
|
|
"/C".to_string(),
|
|
format!(
|
|
"(type secret.env 1>NUL 2>NUL && echo GLOB-READ || echo GLOB-DENIED) & (type exact-secret.txt 1>NUL 2>NUL && echo EXACT-READ || echo EXACT-DENIED) & (type \"{}\" 1>NUL 2>NUL && echo MARKER-READ-ALLOWED || echo MARKER-READ-DENIED) & (echo tampered > \"{}\" 2>NUL && echo MARKER-WRITE-ALLOWED || echo MARKER-WRITE-DENIED) & type public.txt",
|
|
setup_marker.display(),
|
|
setup_marker.display()
|
|
),
|
|
],
|
|
cwd: cwd.clone(),
|
|
expiration: 10_000.into(),
|
|
capture_policy: ExecCapturePolicy::ShellTool,
|
|
env: HashMap::new(),
|
|
network: None,
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
windows_sandbox_level: WindowsSandboxLevel::Elevated,
|
|
windows_sandbox_private_desktop: false,
|
|
justification: None,
|
|
arg0: None,
|
|
},
|
|
&permission_profile,
|
|
&cwd,
|
|
std::slice::from_ref(&cwd),
|
|
&None,
|
|
/*use_legacy_landlock*/ false,
|
|
/*stdout_stream*/ None,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(exit_code, 0, "sandboxed command should complete");
|
|
assert!(
|
|
stdout.text.contains("GLOB-DENIED"),
|
|
"glob deny-read should block the secret: {stdout:?}"
|
|
);
|
|
assert!(
|
|
!stdout.text.contains("GLOB-READ"),
|
|
"glob deny-read should not allow the secret: {stdout:?}"
|
|
);
|
|
assert!(
|
|
stdout.text.contains("EXACT-DENIED"),
|
|
"exact deny-read should block the secret: {stdout:?}"
|
|
);
|
|
assert!(
|
|
!stdout.text.contains("EXACT-READ"),
|
|
"exact deny-read should not allow the secret: {stdout:?}"
|
|
);
|
|
assert!(
|
|
stdout.text.contains("public ok"),
|
|
"allowed reads should still work: {stdout:?}"
|
|
);
|
|
assert!(
|
|
stdout.text.contains("MARKER-READ-DENIED"),
|
|
"sandboxed command should not read setup readiness: {stdout:?}"
|
|
);
|
|
assert!(
|
|
stdout.text.contains("MARKER-WRITE-DENIED"),
|
|
"sandboxed command should not modify setup readiness: {stdout:?}"
|
|
);
|
|
assert!(
|
|
!stdout.text.contains("MARKER-READ-ALLOWED"),
|
|
"sandboxed command must not read setup readiness: {stdout:?}"
|
|
);
|
|
assert!(
|
|
!stdout.text.contains("MARKER-WRITE-ALLOWED"),
|
|
"sandboxed command must not modify setup readiness: {stdout:?}"
|
|
);
|
|
assert!(
|
|
sandbox_setup_is_complete(codex_home.path()),
|
|
"setup should remain ready after the tamper attempt"
|
|
);
|
|
Ok(())
|
|
}
|