feat(sandbox): add Windows deny-read parity (#18202)

## Why

The split filesystem policy stack already supports exact and glob
`access = none` read restrictions on macOS and Linux. Windows still
needed subprocess handling for those deny-read policies without claiming
enforcement from a backend that cannot provide it.

## Key finding

The unelevated restricted-token backend cannot safely enforce deny-read
overlays. Its `WRITE_RESTRICTED` token model is authoritative for write
checks, not read denials, so this PR intentionally fails that backend
closed when deny-read overrides are present instead of claiming
unsupported enforcement.

## What changed

This PR adds the Windows deny-read enforcement layer and makes the
backend split explicit:

- Resolves Windows deny-read filesystem policy entries into concrete ACL
targets.
- Preserves exact missing paths so they can be materialized and denied
before an enforceable sandboxed process starts.
- Snapshot-expands existing glob matches into ACL targets for Windows
subprocess enforcement.
- Honors `glob_scan_max_depth` when expanding Windows deny-read globs.
- Plans both the configured lexical path and the canonical target for
existing paths so reparse-point aliases are covered.
- Threads deny-read overrides through the elevated/logon-user Windows
sandbox backend and unified exec.
- Applies elevated deny-read ACLs synchronously before command launch
rather than delegating them to the background read-grant helper.
- Reconciles persistent deny-read ACEs per sandbox principal so policy
changes do not leave stale deny-read ACLs behind.
- Fails closed on the unelevated restricted-token backend when deny-read
overrides are present, because its `WRITE_RESTRICTED` token model is not
authoritative for read denials.

## Landed prerequisites

These prerequisite PRs are already on `main`:

1. #15979 `feat(permissions): add glob deny-read policy support`
2. #18096 `feat(sandbox): add glob deny-read platform enforcement`
3. #17740 `feat(config): support managed deny-read requirements`

This PR targets `main` directly and contains only the Windows deny-read
enforcement layer.

## Implementation notes

- Exact deny-read paths remain enforceable on the elevated path even
when they do not exist yet: Windows materializes the missing path before
applying the deny ACE, so the sandboxed command cannot create and read
it during the same run.
- Existing exact deny paths are preserved lexically until the ACL
planner, which then adds the canonical target as a second ACL target
when needed. That keeps both the configured alias and the resolved
object covered.
- Windows ACLs do not consume Codex glob syntax directly, so glob
deny-read entries are expanded to the concrete matches that exist before
process launch.
- Glob traversal deduplicates directory visits within each pattern walk
to avoid cycles, without collapsing distinct lexical roots that happen
to resolve to the same target.
- Persistent deny-read ACL state is keyed by sandbox principal SID, so
cleanup only removes ACEs owned by the same backend principal.
- Deny-read ACEs are fail-closed on the elevated path: setup aborts if
mandatory deny-read ACL application fails.
- Unelevated restricted-token sessions reject deny-read overrides early
instead of running with a silently unenforceable read policy.

## Verification

- `cargo test -p codex-core
windows_restricted_token_rejects_unreadable_split_carveouts`
- `just fmt`
- `just fix -p codex-core`
- `just fix -p codex-windows-sandbox`
- GitHub Actions rerun is in progress on the pushed head.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
viyatb-oai
2026-05-11 23:04:28 -07:00
committed by GitHub
co-authored by Codex
parent c9e46ed639
commit 46f30d0282
24 changed files with 1548 additions and 176 deletions
+53 -48
View File
@@ -97,17 +97,19 @@ pub struct ExecParams {
/// Resolved filesystem overrides for the Windows sandbox backends.
///
/// The unelevated restricted-token backend only consumes extra deny-write
/// carveouts on top of the legacy `WorkspaceWrite` allow set. The elevated
/// backend can also consume explicit read and write roots during setup/refresh.
/// Read-root overrides are layered on top of the baseline helper roots that the
/// elevated setup path needs to launch the sandboxed command. Split policies
/// that opt into platform defaults carry that explicitly with the override.
/// The elevated Windows backend consumes extra deny-read paths plus explicit
/// read and write roots during setup/refresh. The unelevated restricted-token
/// backend only consumes extra deny-write carveouts on top of the legacy
/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the
/// baseline helper roots that the elevated setup path needs to launch the
/// sandboxed command; split policies that opt into platform defaults carry
/// that explicitly with the override.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WindowsSandboxFilesystemOverrides {
pub(crate) read_roots_override: Option<Vec<PathBuf>>,
pub(crate) read_roots_include_platform_defaults: bool,
pub(crate) write_roots_override: Option<Vec<PathBuf>>,
pub(crate) additional_deny_read_paths: Vec<AbsolutePathBuf>,
pub(crate) additional_deny_write_paths: Vec<AbsolutePathBuf>,
}
@@ -564,7 +566,7 @@ async fn exec_windows_sandbox(
) -> Result<RawExecToolCallOutput> {
use crate::config::find_codex_home;
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths;
use codex_windows_sandbox::run_windows_sandbox_capture_with_filesystem_overrides;
let ExecParams {
command,
@@ -605,13 +607,10 @@ async fn exec_windows_sandbox(
let proxy_enforced = network.is_some();
let use_elevated = windows_sandbox_uses_elevated_backend(sandbox_level, proxy_enforced);
let additional_deny_write_paths = windows_sandbox_filesystem_overrides
.map(|overrides| {
overrides
.additional_deny_write_paths
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>()
})
.map(|overrides| overrides.additional_deny_write_paths.clone())
.unwrap_or_default();
let additional_deny_read_paths = windows_sandbox_filesystem_overrides
.map(|overrides| overrides.additional_deny_read_paths.clone())
.unwrap_or_default();
let elevated_read_roots_override = windows_sandbox_filesystem_overrides
.and_then(|overrides| overrides.read_roots_override.clone());
@@ -619,15 +618,6 @@ async fn exec_windows_sandbox(
.is_some_and(|overrides| overrides.read_roots_include_platform_defaults);
let elevated_write_roots_override = windows_sandbox_filesystem_overrides
.and_then(|overrides| overrides.write_roots_override.clone());
let elevated_deny_write_paths = windows_sandbox_filesystem_overrides
.map(|overrides| {
overrides
.additional_deny_write_paths
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let spawn_res = tokio::task::spawn_blocking(move || {
if use_elevated {
run_windows_sandbox_capture_elevated(
@@ -645,11 +635,12 @@ async fn exec_windows_sandbox(
read_roots_include_platform_defaults:
elevated_read_roots_include_platform_defaults,
write_roots_override: elevated_write_roots_override.as_deref(),
deny_write_paths_override: &elevated_deny_write_paths,
deny_read_paths_override: &additional_deny_read_paths,
deny_write_paths_override: &additional_deny_write_paths,
},
)
} else {
run_windows_sandbox_capture_with_extra_deny_write_paths(
run_windows_sandbox_capture_with_filesystem_overrides(
policy_str.as_str(),
&sandbox_cwd,
codex_home.as_ref(),
@@ -657,6 +648,7 @@ async fn exec_windows_sandbox(
&cwd,
env,
timeout_ms,
&additional_deny_read_paths,
&additional_deny_write_paths,
windows_sandbox_private_desktop,
)
@@ -1049,22 +1041,24 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
));
}
if !file_system_sandbox_policy.has_full_disk_read_access() {
// The restricted-token backend can still enforce split write restrictions,
// but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs
// participate in read access checks. Read restrictions therefore require the
// elevated backend, even when the filesystem root remains readable.
if !windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd) {
return Err(
"windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed"
.to_string(),
);
}
if !file_system_sandbox_policy
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
.is_empty()
|| !file_system_sandbox_policy
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
.is_empty()
{
let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths(
file_system_sandbox_policy,
sandbox_policy_cwd,
)?;
if !additional_deny_read_paths.is_empty() {
return Err(
"windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
"windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed"
.to_string(),
);
}
@@ -1131,7 +1125,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
}
}
if additional_deny_write_paths.is_empty() {
if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() {
return Ok(None);
}
@@ -1139,6 +1133,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
read_roots_override: None,
read_roots_include_platform_defaults: false,
write_roots_override: None,
additional_deny_read_paths,
additional_deny_write_paths: additional_deny_write_paths
.into_iter()
.map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string()))
@@ -1152,6 +1147,16 @@ fn normalize_windows_override_path(path: &Path) -> std::result::Result<PathBuf,
.map_err(|err| err.to_string())
}
fn windows_policy_has_root_read_access(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &AbsolutePathBuf,
) -> bool {
let Some(root) = cwd.as_path().ancestors().last() else {
return false;
};
file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path())
}
pub(crate) fn resolve_windows_elevated_filesystem_overrides(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
@@ -1175,18 +1180,10 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
));
}
if !file_system_sandbox_policy
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
.is_empty()
|| !file_system_sandbox_policy
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
.is_empty()
{
return Err(
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
.to_string(),
);
}
let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths(
file_system_sandbox_policy,
sandbox_policy_cwd,
)?;
let split_writable_roots =
file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
@@ -1217,7 +1214,13 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
.collect();
let split_root_path_set: BTreeSet<PathBuf> = split_root_paths.iter().cloned().collect();
let read_roots_override = if file_system_sandbox_policy.has_full_disk_read_access() {
// `has_full_disk_read_access()` is intentionally false when deny-read
// entries exist. For Windows setup overrides, the important question is
// whether the baseline still reads from the filesystem root and only needs
// additional deny ACLs layered on top.
let split_has_root_read_access =
windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd);
let read_roots_override = if split_has_root_read_access {
None
} else {
Some(split_readable_roots)
@@ -1265,6 +1268,7 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
if read_roots_override.is_none()
&& write_roots_override.is_none()
&& additional_deny_read_paths.is_empty()
&& additional_deny_write_paths.is_empty()
{
return Ok(None);
@@ -1275,6 +1279,7 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
&& file_system_sandbox_policy.include_platform_defaults(),
read_roots_override,
write_roots_override,
additional_deny_read_paths,
additional_deny_write_paths,
}))
}