fix: support split carveouts in windows elevated sandbox (#14568)

## Summary
- preserve legacy Windows elevated sandbox behavior for existing
policies
- add elevated-only support for split filesystem policies that can be
represented as readable-root overrides, writable-root overrides, and
extra deny-write carveouts
- resolve those elevated filesystem overrides during sandbox transform
and thread them through setup and policy refresh
- keep failing closed for explicit unreadable (`none`) carveouts and
reopened writable descendants under read-only carveouts
- for explicit read-only-under-writable-root carveouts, materialize
missing carveout directories during elevated setup before applying the
deny-write ACL
- document the elevated vs restricted-token support split in the core
README

## Example
Given a split filesystem policy like:

```toml
":root" = "read"
":cwd" = "write"
"./docs" = "read"
"C:/scratch" = "write"
```

the elevated backend now provisions the readable-root overrides,
writable-root overrides, and extra deny-write carveouts during setup and
refresh instead of collapsing back to the legacy workspace-only shape.

If a read-only carveout under a writable root is missing at setup time,
elevated setup creates that carveout as an empty directory before
applying its deny-write ACE; otherwise the sandboxed command could
create it later and bypass the carveout. This is only for explicit
policy carveouts. Best-effort workspace protections like `.codex/` and
`.agents/` still skip missing directories.

A policy like:

```toml
"/workspace" = "write"
"/workspace/docs" = "read"
"/workspace/docs/tmp" = "write"
```

still fails closed, because the elevated backend does not reopen
writable descendants under read-only carveouts yet.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
viyatb-oai
2026-04-09 17:34:52 -07:00
committed by GitHub
parent 32224878b3
commit b976e701a8
11 changed files with 744 additions and 95 deletions

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
pub struct ElevatedSandboxCaptureRequest<'a> {
pub policy_json_or_preset: &'a str,
@@ -11,13 +12,14 @@ pub struct ElevatedSandboxCaptureRequest<'a> {
pub timeout_ms: Option<u64>,
pub use_private_desktop: bool,
pub proxy_enforced: bool,
pub read_roots_override: Option<&'a [PathBuf]>,
pub write_roots_override: Option<&'a [PathBuf]>,
pub deny_write_paths_override: &'a [PathBuf],
}
mod windows_impl {
use super::ElevatedSandboxCaptureRequest;
use crate::acl::allow_null_device;
use crate::allow::AllowDenyPaths;
use crate::allow::compute_allow_paths;
use crate::cap::load_or_create_cap_sids;
use crate::env::ensure_non_interactive_pager;
use crate::env::inherit_path_env;
@@ -235,13 +237,15 @@ mod windows_impl {
timeout_ms,
use_private_desktop,
proxy_enforced,
read_roots_override,
write_roots_override,
deny_write_paths_override,
} = request;
let policy = parse_policy(policy_json_or_preset)?;
normalize_null_device_env(&mut env_map);
ensure_non_interactive_pager(&mut env_map);
inherit_path_env(&mut env_map);
inject_git_safe_directory(&mut env_map, cwd, None);
let current_dir = cwd.to_path_buf();
// Use a temp-based log dir that the sandbox user can write.
let sandbox_base = codex_home.join(".sandbox");
ensure_codex_home_exists(&sandbox_base)?;
@@ -254,6 +258,9 @@ mod windows_impl {
cwd,
&env_map,
codex_home,
read_roots_override,
write_roots_override,
deny_write_paths_override,
proxy_enforced,
)?;
let sandbox_sid = resolve_sid(&sandbox_creds.username).map_err(|err: anyhow::Error| {
@@ -291,9 +298,6 @@ mod windows_impl {
}
};
let AllowDenyPaths { allow: _, deny: _ } =
compute_allow_paths(&policy, sandbox_policy_cwd, &current_dir, &env_map);
// Deny/allow ACEs are now applied during setup; avoid per-command churn.
unsafe {
allow_null_device(psid_to_use);
}

View File

@@ -5,6 +5,7 @@ use crate::setup::gather_read_roots;
use crate::setup::gather_write_roots;
use crate::setup::offline_proxy_settings_from_env;
use crate::setup::run_elevated_setup;
use crate::setup::run_setup_refresh_with_overrides;
use crate::setup::sandbox_users_path;
use crate::setup::setup_marker_path;
use crate::setup::SandboxNetworkIdentity;
@@ -19,6 +20,7 @@ use base64::Engine;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone)]
struct SandboxIdentity {
@@ -127,17 +129,25 @@ fn select_identity(
}))
}
#[allow(clippy::too_many_arguments)]
pub fn require_logon_sandbox_creds(
policy: &SandboxPolicy,
policy_cwd: &Path,
command_cwd: &Path,
env_map: &HashMap<String, String>,
codex_home: &Path,
read_roots_override: Option<&[PathBuf]>,
write_roots_override: Option<&[PathBuf]>,
deny_write_paths_override: &[PathBuf],
proxy_enforced: bool,
) -> Result<SandboxCreds> {
let sandbox_dir = crate::setup::sandbox_dir(codex_home);
let needed_read = gather_read_roots(command_cwd, policy, codex_home);
let needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map);
let needed_read = read_roots_override
.map(<[PathBuf]>::to_vec)
.unwrap_or_else(|| gather_read_roots(command_cwd, policy, codex_home));
let needed_write = write_roots_override
.map(<[PathBuf]>::to_vec)
.unwrap_or_else(|| gather_write_roots(policy, policy_cwd, command_cwd, env_map));
let network_identity = SandboxNetworkIdentity::from_policy(policy, proxy_enforced);
let desired_offline_proxy_settings =
offline_proxy_settings_from_env(env_map, network_identity);
@@ -187,20 +197,28 @@ pub fn require_logon_sandbox_creds(
proxy_enforced,
},
crate::setup::SetupRootOverrides {
read_roots: Some(needed_read),
write_roots: Some(needed_write),
read_roots: Some(needed_read.clone()),
write_roots: Some(needed_write.clone()),
deny_write_paths: Some(deny_write_paths_override.to_vec()),
},
)?;
identity = select_identity(network_identity, codex_home)?;
}
// Always refresh ACLs (non-elevated) for current roots via the setup binary.
crate::setup::run_setup_refresh(
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
proxy_enforced,
run_setup_refresh_with_overrides(
crate::setup::SandboxSetupRequest {
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
proxy_enforced,
},
crate::setup::SetupRootOverrides {
read_roots: Some(needed_read),
write_roots: Some(needed_write),
deny_write_paths: Some(deny_write_paths_override.to_vec()),
},
)?;
let identity = identity.ok_or_else(|| {
anyhow!(

View File

@@ -11,6 +11,7 @@ use codex_windows_sandbox::SETUP_VERSION;
use codex_windows_sandbox::SetupErrorCode;
use codex_windows_sandbox::SetupErrorReport;
use codex_windows_sandbox::SetupFailure;
use codex_windows_sandbox::add_deny_write_ace;
use codex_windows_sandbox::canonicalize_path;
use codex_windows_sandbox::convert_string_sid_to_sid;
use codex_windows_sandbox::ensure_allow_mask_aces_with_inheritance;
@@ -85,6 +86,7 @@ struct Payload {
read_roots: Vec<PathBuf>,
write_roots: Vec<PathBuf>,
#[serde(default)]
deny_write_paths: Vec<PathBuf>,
proxy_ports: Vec<u16>,
#[serde(default)]
allow_local_binding: bool,
@@ -642,6 +644,7 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD;
let mut grant_tasks: Vec<PathBuf> = Vec::new();
let mut seen_deny_paths: HashSet<PathBuf> = HashSet::new();
let mut seen_write_roots: HashSet<PathBuf> = HashSet::new();
let canonical_command_cwd = canonicalize_path(&payload.command_cwd);
@@ -759,6 +762,52 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
}
});
for path in &payload.deny_write_paths {
if !seen_deny_paths.insert(path.clone()) {
continue;
}
// These are explicit read-only-under-a-writable-root carveouts from the transformed
// sandbox policy; they are not deny-read paths.
//
// They are also not optional workspace sentinels such as `.codex` or `.agents`: those
// are protected best-effort below and still skip missing directories so we do not leave
// empty protection artifacts behind in a workspace.
//
// Deny ACEs attach to filesystem objects; if a policy carveout does not exist during
// setup, the sandbox could otherwise create it later under a writable parent and
// bypass the carveout. Materialize missing carveouts as directories so the deny-write
// ACL is present before the command starts.
if !path.exists() {
std::fs::create_dir_all(path)
.with_context(|| format!("failed to create deny-write path {}", path.display()))?;
}
let canonical_path = canonicalize_path(path);
let deny_psid = if canonical_path.starts_with(&canonical_command_cwd) {
workspace_psid
} else {
cap_psid
};
match unsafe { add_deny_write_ace(path, deny_psid) } {
Ok(true) => {
log_line(
log,
&format!("applied deny ACE to protect {}", path.display()),
)?;
}
Ok(false) => {}
Err(err) => {
refresh_errors.push(format!("deny ACE failed on {}: {err}", path.display()));
log_line(
log,
&format!("deny ACE failed on {}: {err}", path.display()),
)?;
}
}
}
lock_sandbox_dir(
&sandbox_bin_dir(&payload.codex_home),
&payload.real_user,

View File

@@ -92,6 +92,7 @@ pub struct SandboxSetupRequest<'a> {
pub struct SetupRootOverrides {
pub read_roots: Option<Vec<PathBuf>>,
pub write_roots: Option<Vec<PathBuf>>,
pub deny_write_paths: Option<Vec<PathBuf>>,
}
pub fn run_setup_refresh(
@@ -115,6 +116,13 @@ pub fn run_setup_refresh(
)
}
pub fn run_setup_refresh_with_overrides(
request: SandboxSetupRequest<'_>,
overrides: SetupRootOverrides,
) -> Result<()> {
run_setup_refresh_inner(request, overrides)
}
pub fn run_setup_refresh_with_extra_read_roots(
policy: &SandboxPolicy,
policy_cwd: &Path,
@@ -138,6 +146,7 @@ pub fn run_setup_refresh_with_extra_read_roots(
SetupRootOverrides {
read_roots: Some(read_roots),
write_roots: Some(Vec::new()),
deny_write_paths: None,
},
)
}
@@ -153,7 +162,7 @@ fn run_setup_refresh_inner(
) {
return Ok(());
}
let (read_roots, write_roots) = build_payload_roots(&request, overrides);
let (read_roots, write_roots) = build_payload_roots(&request, &overrides);
let network_identity =
SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced);
let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity);
@@ -165,6 +174,7 @@ fn run_setup_refresh_inner(
command_cwd: request.command_cwd.to_path_buf(),
read_roots,
write_roots,
deny_write_paths: overrides.deny_write_paths.unwrap_or_default(),
proxy_ports: offline_proxy_settings.proxy_ports,
allow_local_binding: offline_proxy_settings.allow_local_binding,
real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()),
@@ -434,6 +444,7 @@ struct ElevationPayload {
read_roots: Vec<PathBuf>,
write_roots: Vec<PathBuf>,
#[serde(default)]
deny_write_paths: Vec<PathBuf>,
proxy_ports: Vec<u16>,
#[serde(default)]
allow_local_binding: bool,
@@ -723,7 +734,7 @@ pub fn run_elevated_setup(
format!("failed to create sandbox dir {}: {err}", sbx_dir.display()),
)
})?;
let (read_roots, write_roots) = build_payload_roots(&request, overrides);
let (read_roots, write_roots) = build_payload_roots(&request, &overrides);
let network_identity =
SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced);
let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity);
@@ -735,6 +746,7 @@ pub fn run_elevated_setup(
command_cwd: request.command_cwd.to_path_buf(),
read_roots,
write_roots,
deny_write_paths: overrides.deny_write_paths.unwrap_or_default(),
proxy_ports: offline_proxy_settings.proxy_ports,
allow_local_binding: offline_proxy_settings.allow_local_binding,
real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()),
@@ -751,10 +763,10 @@ pub fn run_elevated_setup(
fn build_payload_roots(
request: &SandboxSetupRequest<'_>,
overrides: SetupRootOverrides,
overrides: &SetupRootOverrides,
) -> (Vec<PathBuf>, Vec<PathBuf>) {
let write_roots = if let Some(roots) = overrides.write_roots {
canonical_existing(&roots)
let write_roots = if let Some(roots) = overrides.write_roots.as_deref() {
canonical_existing(roots)
} else {
gather_write_roots(
request.policy,
@@ -764,8 +776,19 @@ fn build_payload_roots(
)
};
let write_roots = filter_sensitive_write_roots(write_roots, request.codex_home);
let mut read_roots = if let Some(roots) = overrides.read_roots {
canonical_existing(&roots)
let mut read_roots = if let Some(roots) = overrides.read_roots.as_deref() {
// An explicit override is the split policy's complete readable set. Keep only the
// helper/platform roots the elevated setup needs; do not re-add legacy cwd/full-read roots.
let mut read_roots = gather_helper_read_roots(request.codex_home);
if request.policy.include_platform_defaults() {
read_roots.extend(
WINDOWS_PLATFORM_DEFAULT_READ_ROOTS
.iter()
.map(PathBuf::from),
);
}
read_roots.extend(roots.iter().cloned());
canonical_existing(&read_roots)
} else {
gather_read_roots(request.command_cwd, request.policy, request.codex_home)
};
@@ -802,6 +825,7 @@ fn filter_sensitive_write_roots(mut roots: Vec<PathBuf>, codex_home: &Path) -> V
#[cfg(test)]
mod tests {
use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS;
use super::build_payload_roots;
use super::gather_legacy_full_read_roots;
use super::gather_read_roots;
use super::loopback_proxy_port_from_url;
@@ -1097,6 +1121,152 @@ mod tests {
assert!(roots.contains(&expected_writable));
}
#[test]
fn build_payload_roots_preserves_restricted_read_policy_when_no_override_is_needed() {
let tmp = TempDir::new().expect("tempdir");
let codex_home = tmp.path().join("codex-home");
let policy_cwd = tmp.path().join("policy-cwd");
let command_cwd = tmp.path().join("workspace");
let readable_root = tmp.path().join("docs");
fs::create_dir_all(&policy_cwd).expect("create policy cwd");
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 (read_roots, write_roots) = build_payload_roots(
&super::SandboxSetupRequest {
policy: &policy,
policy_cwd: &policy_cwd,
command_cwd: &command_cwd,
env_map: &HashMap::new(),
codex_home: &codex_home,
proxy_enforced: false,
},
&super::SetupRootOverrides::default(),
);
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_eq!(write_roots, Vec::<PathBuf>::new());
assert!(read_roots.contains(&expected_helper));
assert!(read_roots.contains(&expected_cwd));
assert!(read_roots.contains(&expected_readable));
assert!(
canonical_windows_platform_default_roots()
.into_iter()
.all(|path| !read_roots.contains(&path))
);
}
#[test]
fn build_payload_roots_preserves_helper_roots_when_read_override_is_provided() {
let tmp = TempDir::new().expect("tempdir");
let codex_home = tmp.path().join("codex-home");
let policy_cwd = tmp.path().join("policy-cwd");
let command_cwd = tmp.path().join("workspace");
let readable_root = tmp.path().join("docs");
fs::create_dir_all(&policy_cwd).expect("create policy cwd");
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: true,
readable_roots: Vec::new(),
},
network_access: false,
};
let (read_roots, write_roots) = build_payload_roots(
&super::SandboxSetupRequest {
policy: &policy,
policy_cwd: &policy_cwd,
command_cwd: &command_cwd,
env_map: &HashMap::new(),
codex_home: &codex_home,
proxy_enforced: false,
},
&super::SetupRootOverrides {
read_roots: Some(vec![readable_root.clone()]),
write_roots: None,
deny_write_paths: None,
},
);
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_eq!(write_roots, Vec::<PathBuf>::new());
assert!(read_roots.contains(&expected_helper));
assert!(!read_roots.contains(&expected_cwd));
assert!(read_roots.contains(&expected_readable));
assert!(
canonical_windows_platform_default_roots()
.into_iter()
.all(|path| read_roots.contains(&path))
);
}
#[test]
fn build_payload_roots_replaces_full_read_policy_when_read_override_is_provided() {
let tmp = TempDir::new().expect("tempdir");
let codex_home = tmp.path().join("codex-home");
let policy_cwd = tmp.path().join("policy-cwd");
let command_cwd = tmp.path().join("workspace");
let readable_root = tmp.path().join("docs");
fs::create_dir_all(&policy_cwd).expect("create policy cwd");
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::FullAccess,
network_access: false,
};
let (read_roots, write_roots) = build_payload_roots(
&super::SandboxSetupRequest {
policy: &policy,
policy_cwd: &policy_cwd,
command_cwd: &command_cwd,
env_map: &HashMap::new(),
codex_home: &codex_home,
proxy_enforced: false,
},
&super::SetupRootOverrides {
read_roots: Some(vec![readable_root.clone()]),
write_roots: None,
deny_write_paths: None,
},
);
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_eq!(write_roots, Vec::<PathBuf>::new());
assert!(read_roots.contains(&expected_helper));
assert!(!read_roots.contains(&expected_cwd));
assert!(read_roots.contains(&expected_readable));
assert!(
canonical_windows_platform_default_roots()
.into_iter()
.all(|path| !read_roots.contains(&path))
);
}
#[test]
fn full_read_roots_preserve_legacy_platform_defaults() {
let tmp = TempDir::new().expect("tempdir");