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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 332 additions and 71 deletions

View file

@ -65,6 +65,30 @@ falls back to the vendored bubblewrap path otherwise. When `/usr/bin/bwrap` is
missing, Codex also surfaces a startup warning through its normal notification
path instead of printing directly from the sandbox helper.
### Windows
Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on
Windows.
The elevated setup/runner backend supports legacy `ReadOnlyAccess::Restricted`
for `read-only` and `workspace-write` policies. Restricted read access honors
explicit readable roots plus the command `cwd`, and keeps writable roots
readable when `workspace-write` is used.
When `include_platform_defaults = true`, the elevated Windows backend adds
backend-managed system read roots required for basic execution, such as
`C:\Windows`, `C:\Program Files`, `C:\Program Files (x86)`, and
`C:\ProgramData`. When it is `false`, those extra system roots are omitted.
The unelevated restricted-token backend still supports the legacy full-read
Windows model only. Restricted read-only policies continue to fail closed there
instead of running with weaker read enforcement.
New `[permissions]` / split filesystem policies remain supported on Windows
only when they round-trip through the legacy `SandboxPolicy` model without
changing semantics. Richer split-only carveouts still fail closed instead of
running with weaker enforcement.
### All Platforms
Expects the binary containing `codex-core` to simulate the virtual `apply_patch` CLI when `arg1` is `--codex-run-as-apply-patch`. See the `codex-arg0` crate for details.

View file

@ -34,6 +34,7 @@ use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
use crate::tools::sandboxing::SandboxablePreference;
use codex_network_proxy::NetworkProxy;
#[cfg(any(target_os = "windows", test))]
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
@ -765,12 +766,14 @@ async fn exec(
) -> Result<RawExecToolCallOutput> {
#[cfg(target_os = "windows")]
if sandbox == SandboxType::WindowsRestrictedToken {
if let Some(reason) = unsupported_windows_restricted_token_sandbox_reason(
let support = windows_restricted_token_sandbox_support(
sandbox,
params.windows_sandbox_level,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
) {
);
if let Some(reason) = support.unsupported_reason {
return Err(CodexErr::Io(io::Error::other(reason)));
}
return exec_windows_sandbox(params, sandbox_policy).await;
@ -817,41 +820,56 @@ async fn exec(
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn should_use_windows_restricted_token_sandbox(
#[derive(Debug, PartialEq, Eq)]
struct WindowsRestrictedTokenSandboxSupport {
should_use: bool,
unsupported_reason: Option<String>,
}
#[cfg(any(target_os = "windows", test))]
fn windows_restricted_token_sandbox_support(
sandbox: SandboxType,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> bool {
sandbox == SandboxType::WindowsRestrictedToken
&& file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted
network_sandbox_policy: NetworkSandboxPolicy,
) -> WindowsRestrictedTokenSandboxSupport {
if sandbox != SandboxType::WindowsRestrictedToken {
return WindowsRestrictedTokenSandboxSupport {
should_use: false,
unsupported_reason: None,
};
}
// Windows currently reuses SandboxType::WindowsRestrictedToken for both
// the legacy restricted-token backend and the elevated setup/runner path.
// The sandbox level decides whether restricted read-only policies are
// supported.
let should_use = file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted
&& !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
}
&& (matches!(
windows_sandbox_level,
codex_protocol::config_types::WindowsSandboxLevel::Elevated
) || sandbox_policy.has_full_disk_read_access());
#[cfg(any(target_os = "windows", test))]
fn unsupported_windows_restricted_token_sandbox_reason(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
) -> Option<String> {
if should_use_windows_restricted_token_sandbox(
sandbox,
sandbox_policy,
file_system_sandbox_policy,
) {
return None;
}
(sandbox == SandboxType::WindowsRestrictedToken).then(|| {
format!(
let unsupported_reason = if should_use {
None
} else {
Some(format!(
"windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed",
file_system_sandbox_policy.kind,
)
})
))
};
WindowsRestrictedTokenSandboxSupport {
should_use,
unsupported_reason,
}
}
/// Consumes the output of a child process, truncating it so it is suitable for
/// use as the output of a `shell` tool call. Also enforces specified timeout.
async fn consume_truncated_output(

View file

@ -1,4 +1,5 @@
use super::*;
use codex_protocol::config_types::WindowsSandboxLevel;
use pretty_assertions::assert_eq;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
@ -188,12 +189,19 @@ fn windows_restricted_token_skips_external_sandbox_policies() {
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
should_use_windows_restricted_token_sandbox(
windows_restricted_token_sandbox_support(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
false
WindowsRestrictedTokenSandboxSupport {
should_use: false,
unsupported_reason: Some(
"windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
),
}
);
}
@ -203,12 +211,17 @@ fn windows_restricted_token_runs_for_legacy_restricted_policies() {
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
should_use_windows_restricted_token_sandbox(
windows_restricted_token_sandbox_support(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
true
WindowsRestrictedTokenSandboxSupport {
should_use: true,
unsupported_reason: None,
}
);
}
@ -220,16 +233,20 @@ fn windows_restricted_token_rejects_network_only_restrictions() {
let file_system_policy = FileSystemSandboxPolicy::unrestricted();
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
Some(
windows_restricted_token_sandbox_support(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
WindowsRestrictedTokenSandboxSupport {
should_use: false,
unsupported_reason: Some(
"windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
)
);
),
}
);
}
#[test]
@ -238,13 +255,46 @@ fn windows_restricted_token_allows_legacy_restricted_policies() {
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
windows_restricted_token_sandbox_support(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
None
WindowsRestrictedTokenSandboxSupport {
should_use: true,
unsupported_reason: None,
}
);
}
#[test]
fn windows_restricted_token_rejects_restricted_read_only_policies() {
let policy = SandboxPolicy::ReadOnly {
access: codex_protocol::protocol::ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![],
},
network_access: false,
};
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
assert_eq!(
windows_restricted_token_sandbox_support(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
WindowsRestrictedTokenSandboxSupport {
should_use: false,
unsupported_reason: Some(
"windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ReadOnly { access: Restricted { include_platform_defaults: true, readable_roots: [] }, network_access: false }; refusing to run unsandboxed".to_string()
),
},
"restricted-token should fail closed for restricted read-only policies"
);
}
@ -260,13 +310,44 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() {
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
windows_restricted_token_sandbox_support(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
None
WindowsRestrictedTokenSandboxSupport {
should_use: true,
unsupported_reason: None,
}
);
}
#[test]
fn windows_elevated_sandbox_allows_restricted_read_only_policies() {
let policy = SandboxPolicy::ReadOnly {
access: codex_protocol::protocol::ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![],
},
network_access: false,
};
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
assert_eq!(
windows_restricted_token_sandbox_support(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Elevated,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
WindowsRestrictedTokenSandboxSupport {
should_use: true,
unsupported_reason: None,
},
"elevated Windows sandbox should keep restricted read-only support enabled"
);
}
@ -278,7 +359,7 @@ fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions()
select_process_exec_tool_sandbox_type(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
codex_protocol::config_types::WindowsSandboxLevel::Disabled,
WindowsSandboxLevel::Disabled,
false,
),
expected
@ -318,7 +399,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
env,
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,
@ -375,7 +456,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> {
env,
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,

View file

@ -199,11 +199,6 @@ fn spawn_ipc_process(
);
let policy = parse_policy(&req.policy_json_or_preset).context("parse policy_json_or_preset")?;
if !policy.has_full_disk_read_access() {
anyhow::bail!(
"Restricted read-only access is not yet supported by the Windows sandbox backend"
);
}
let mut cap_psids: Vec<*mut c_void> = Vec::new();
for sid in &req.cap_sids {
let Some(psid) = (unsafe { convert_string_sid_to_sid(sid) }) else {

View file

@ -238,11 +238,6 @@ mod windows_impl {
) {
anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing")
}
if !policy.has_full_disk_read_access() {
anyhow::bail!(
"Restricted read-only access is not yet supported by the Windows sandbox backend"
);
}
let caps = load_or_create_cap_sids(codex_home)?;
let (psid_to_use, cap_sids) = match &policy {
SandboxPolicy::ReadOnly { .. } => (

View file

@ -291,7 +291,7 @@ mod windows_impl {
}
if !policy.has_full_disk_read_access() {
anyhow::bail!(
"Restricted read-only access is not yet supported by the Windows sandbox backend"
"Restricted read-only access requires the elevated Windows sandbox backend"
);
}
let caps = load_or_create_cap_sids(codex_home)?;

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