feat: support restricted ReadOnlyAccess in elevated Windows sandbox (#14610)

## Summary
- support legacy `ReadOnlyAccess::Restricted` on Windows in the elevated
setup/runner backend
- keep the unelevated restricted-token backend on the legacy full-read
model only, and fail closed for restricted read-only policies there
- keep the legacy full-read Windows path unchanged while deriving
narrower read roots only for elevated restricted-read policies
- honor `include_platform_defaults` by adding backend-managed Windows
system roots only when requested, while always keeping helper roots and
the command `cwd` readable
- preserve `workspace-write` semantics by keeping writable roots
readable when restricted read access is in use in the elevated backend
- document the current Windows boundary: legacy `SandboxPolicy` is
supported on both backends, while richer split-only carveouts still fail
closed instead of running with weaker enforcement

## Testing
- `cargo test -p codex-windows-sandbox`
- `cargo check -p codex-windows-sandbox --tests --target
x86_64-pc-windows-msvc`
- `cargo clippy -p codex-windows-sandbox --tests --target
x86_64-pc-windows-msvc -- -D warnings`
- `cargo test -p codex-core windows_restricted_token_`

## Notes
- local `cargo test -p codex-windows-sandbox` on macOS only exercises
the non-Windows stubs; the Windows-targeted compile and clippy runs
provide the local signal, and GitHub Windows CI exercises the runtime
path
This commit is contained in:
viyatb-oai
2026-03-17 19:08:50 -07:00
committed by GitHub
parent 6fe8a05dcb
commit d950543e65
7 changed files with 332 additions and 71 deletions

View File

@@ -51,6 +51,12 @@ const USERPROFILE_READ_ROOT_EXCLUSIONS: &[&str] = &[
".pki",
".terraform.d",
];
const WINDOWS_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
r"C:\Windows",
r"C:\Program Files",
r"C:\Program Files (x86)",
r"C:\ProgramData",
];
pub fn sandbox_dir(codex_home: &Path) -> PathBuf {
codex_home.join(".sandbox")
@@ -281,12 +287,8 @@ fn profile_read_roots(user_profile: &Path) -> Vec<PathBuf> {
.collect()
}
pub(crate) fn gather_read_roots(
command_cwd: &Path,
policy: &SandboxPolicy,
codex_home: &Path,
) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
fn gather_helper_read_roots(codex_home: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
roots.push(dir.to_path_buf());
@@ -295,14 +297,20 @@ pub(crate) fn gather_read_roots(
let helper_dir = helper_bin_dir(codex_home);
let _ = std::fs::create_dir_all(&helper_dir);
roots.push(helper_dir);
for p in [
PathBuf::from(r"C:\Windows"),
PathBuf::from(r"C:\Program Files"),
PathBuf::from(r"C:\Program Files (x86)"),
PathBuf::from(r"C:\ProgramData"),
] {
roots.push(p);
}
roots
}
fn gather_legacy_full_read_roots(
command_cwd: &Path,
policy: &SandboxPolicy,
codex_home: &Path,
) -> Vec<PathBuf> {
let mut roots = gather_helper_read_roots(codex_home);
roots.extend(
WINDOWS_PLATFORM_DEFAULT_READ_ROOTS
.iter()
.map(PathBuf::from),
);
if let Ok(up) = std::env::var("USERPROFILE") {
roots.extend(profile_read_roots(Path::new(&up)));
}
@@ -315,6 +323,40 @@ pub(crate) fn gather_read_roots(
canonical_existing(&roots)
}
fn gather_restricted_read_roots(
command_cwd: &Path,
policy: &SandboxPolicy,
codex_home: &Path,
) -> Vec<PathBuf> {
let mut roots = gather_helper_read_roots(codex_home);
if policy.include_platform_defaults() {
roots.extend(
WINDOWS_PLATFORM_DEFAULT_READ_ROOTS
.iter()
.map(PathBuf::from),
);
}
roots.extend(
policy
.get_readable_roots_with_cwd(command_cwd)
.into_iter()
.map(|path| path.to_path_buf()),
);
canonical_existing(&roots)
}
pub(crate) fn gather_read_roots(
command_cwd: &Path,
policy: &SandboxPolicy,
codex_home: &Path,
) -> Vec<PathBuf> {
if policy.has_full_disk_read_access() {
gather_legacy_full_read_roots(command_cwd, policy, codex_home)
} else {
gather_restricted_read_roots(command_cwd, policy, codex_home)
}
}
pub(crate) fn gather_write_roots(
policy: &SandboxPolicy,
policy_cwd: &Path,
@@ -629,16 +671,27 @@ fn filter_sensitive_write_roots(mut roots: Vec<PathBuf>, codex_home: &Path) -> V
#[cfg(test)]
mod tests {
use super::gather_legacy_full_read_roots;
use super::gather_read_roots;
use super::profile_read_roots;
use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS;
use crate::helper_materialization::helper_bin_dir;
use crate::policy::SandboxPolicy;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn canonical_windows_platform_default_roots() -> Vec<PathBuf> {
WINDOWS_PLATFORM_DEFAULT_READ_ROOTS
.iter()
.map(|path| dunce::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)))
.collect()
}
#[test]
fn profile_read_roots_excludes_configured_top_level_entries() {
let tmp = TempDir::new().expect("tempdir");
@@ -684,4 +737,99 @@ mod tests {
assert!(roots.contains(&expected));
}
#[test]
fn restricted_read_roots_skip_platform_defaults_when_disabled() {
let tmp = TempDir::new().expect("tempdir");
let codex_home = tmp.path().join("codex-home");
let command_cwd = tmp.path().join("workspace");
let readable_root = tmp.path().join("docs");
fs::create_dir_all(&command_cwd).expect("create workspace");
fs::create_dir_all(&readable_root).expect("create readable root");
let policy = SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: vec![AbsolutePathBuf::from_absolute_path(&readable_root)
.expect("absolute readable root")],
},
network_access: false,
};
let roots = gather_read_roots(&command_cwd, &policy, &codex_home);
let expected_helper =
dunce::canonicalize(helper_bin_dir(&codex_home)).expect("canonical helper dir");
let expected_cwd = dunce::canonicalize(&command_cwd).expect("canonical workspace");
let expected_readable =
dunce::canonicalize(&readable_root).expect("canonical readable root");
assert!(roots.contains(&expected_helper));
assert!(roots.contains(&expected_cwd));
assert!(roots.contains(&expected_readable));
assert!(canonical_windows_platform_default_roots()
.into_iter()
.all(|path| !roots.contains(&path)));
}
#[test]
fn restricted_read_roots_include_platform_defaults_when_enabled() {
let tmp = TempDir::new().expect("tempdir");
let codex_home = tmp.path().join("codex-home");
let command_cwd = tmp.path().join("workspace");
fs::create_dir_all(&command_cwd).expect("create workspace");
let policy = SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: Vec::new(),
},
network_access: false,
};
let roots = gather_read_roots(&command_cwd, &policy, &codex_home);
assert!(canonical_windows_platform_default_roots()
.into_iter()
.all(|path| roots.contains(&path)));
}
#[test]
fn restricted_workspace_write_roots_remain_readable() {
let tmp = TempDir::new().expect("tempdir");
let codex_home = tmp.path().join("codex-home");
let command_cwd = tmp.path().join("workspace");
let writable_root = tmp.path().join("extra-write-root");
fs::create_dir_all(&command_cwd).expect("create workspace");
fs::create_dir_all(&writable_root).expect("create writable root");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::from_absolute_path(&writable_root)
.expect("absolute writable root")],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: Vec::new(),
},
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let roots = gather_read_roots(&command_cwd, &policy, &codex_home);
let expected_writable =
dunce::canonicalize(&writable_root).expect("canonical writable root");
assert!(roots.contains(&expected_writable));
}
#[test]
fn full_read_roots_preserve_legacy_platform_defaults() {
let tmp = TempDir::new().expect("tempdir");
let codex_home = tmp.path().join("codex-home");
let command_cwd = tmp.path().join("workspace");
fs::create_dir_all(&command_cwd).expect("create workspace");
let policy = SandboxPolicy::new_read_only_policy();
let roots = gather_legacy_full_read_roots(&command_cwd, &policy, &codex_home);
assert!(canonical_windows_platform_default_roots()
.into_iter()
.all(|path| roots.contains(&path)));
}
}