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-oaiandCodex authored and GitHub committed 2026-05-11 23:04:28 -07:00
1 parent c9e46ed639
commit 46f30d0282
24 files changed
+1548 -176

No files matched your search

@@ -10,10 +10,12 @@ use crate::ipc_framed::SpawnRequest;
use crate::runner_client::spawn_runner_transport;
use crate::spawn_prep::prepare_elevated_spawn_context;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::ProcessDriver;
use codex_utils_pty::SpawnedProcess;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -27,10 +29,23 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated(
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
read_roots_override: Option<&[PathBuf]>,
read_roots_include_platform_defaults: bool,
write_roots_override: Option<&[PathBuf]>,
deny_read_paths_override: &[AbsolutePathBuf],
deny_write_paths_override: &[AbsolutePathBuf],
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
) -> Result<SpawnedProcess> {
let deny_read_paths_override = deny_read_paths_override
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let deny_write_paths_override = deny_write_paths_override
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let elevated = prepare_elevated_spawn_context(
policy_json_or_preset,
sandbox_policy_cwd,
@@ -38,6 +53,11 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated(
cwd,
&mut env_map,
&command,
read_roots_override,
read_roots_include_platform_defaults,
write_roots_override,
&deny_read_paths_override,
&deny_write_paths_override,
)?;
let spawn_request = SpawnRequest {
@@ -16,6 +16,7 @@ use crate::spawn_prep::apply_legacy_session_acl_rules;
use crate::spawn_prep::prepare_legacy_session_security;
use crate::spawn_prep::prepare_legacy_spawn_context;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::ProcessDriver;
use codex_utils_pty::SpawnedProcess;
use codex_utils_pty::TerminalSize;
@@ -287,6 +288,8 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
additional_deny_read_paths: &[AbsolutePathBuf],
additional_deny_write_paths: &[AbsolutePathBuf],
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
@@ -303,6 +306,19 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
if !common.policy.has_full_disk_read_access() {
anyhow::bail!("Restricted read-only access requires the elevated Windows sandbox backend");
}
// WRITE_RESTRICTED tokens consult restricting SIDs only for writes, so this
// backend cannot make capability-SID deny-read ACLs authoritative.
if !additional_deny_read_paths.is_empty() {
anyhow::bail!("deny-read overrides require the elevated Windows sandbox backend");
}
let additional_deny_read_paths = additional_deny_read_paths
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let additional_deny_write_paths = additional_deny_write_paths
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let security = prepare_legacy_session_security(&common.policy, codex_home, cwd)?;
allow_null_device_for_workspace_write(common.is_workspace_write);
@@ -310,12 +326,16 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
let guards = apply_legacy_session_acl_rules(
&common.policy,
sandbox_policy_cwd,
codex_home,
&common.current_dir,
&env_map,
&security.psid_generic,
security.psid_workspace.as_ref(),
&security.cap_sid_str,
&additional_deny_read_paths,
&additional_deny_write_paths,
persist_aces,
);
)?;
let (writer_tx, writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (stdout_tx, stdout_rx) = broadcast::channel::<Vec<u8>>(256);
@@ -10,9 +10,11 @@
mod backends;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::SpawnedProcess;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
#[allow(clippy::too_many_arguments)]
pub async fn spawn_windows_sandbox_session_legacy(
@@ -23,6 +25,8 @@ pub async fn spawn_windows_sandbox_session_legacy(
cwd: &Path,
env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
additional_deny_read_paths: &[AbsolutePathBuf],
additional_deny_write_paths: &[AbsolutePathBuf],
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
@@ -35,6 +39,8 @@ pub async fn spawn_windows_sandbox_session_legacy(
cwd,
env_map,
timeout_ms,
additional_deny_read_paths,
additional_deny_write_paths,
tty,
stdin_open,
use_private_desktop,
@@ -51,6 +57,11 @@ pub async fn spawn_windows_sandbox_session_elevated(
cwd: &Path,
env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
read_roots_override: Option<&[PathBuf]>,
read_roots_include_platform_defaults: bool,
write_roots_override: Option<&[PathBuf]>,
deny_read_paths_override: &[AbsolutePathBuf],
deny_write_paths_override: &[AbsolutePathBuf],
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
@@ -63,6 +74,11 @@ pub async fn spawn_windows_sandbox_session_elevated(
cwd,
env_map,
timeout_ms,
read_roots_override,
read_roots_include_platform_defaults,
write_roots_override,
deny_read_paths_override,
deny_write_paths_override,
tty,
stdin_open,
use_private_desktop,
@@ -5,6 +5,7 @@ use crate::ipc_framed::Message;
use crate::ipc_framed::decode_bytes;
use crate::ipc_framed::read_frame;
use crate::run_windows_sandbox_capture;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::ProcessDriver;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
@@ -160,6 +161,8 @@ fn legacy_non_tty_cmd_emits_output() {
cwd.as_path(),
HashMap::new(),
Some(5_000),
&[],
&[],
/*tty*/ false,
/*stdin_open*/ false,
/*use_private_desktop*/ true,
@@ -176,6 +179,44 @@ fn legacy_non_tty_cmd_emits_output() {
});
}
#[test]
fn legacy_non_tty_cmd_rejects_deny_read_overrides() {
let _guard = legacy_process_test_guard();
let runtime = current_thread_runtime();
runtime.block_on(async move {
let cwd = sandbox_cwd();
let codex_home = sandbox_home("legacy-non-tty-deny-read");
let secret_path =
AbsolutePathBuf::from_absolute_path(cwd.join("legacy-non-tty-deny-read-secret.env"))
.expect("absolute deny-read fixture path");
let err = spawn_windows_sandbox_session_legacy(
"workspace-write",
cwd.as_path(),
codex_home.path(),
vec![
"C:\\Windows\\System32\\cmd.exe".to_string(),
"/c".to_string(),
"echo deny-read".to_string(),
],
cwd.as_path(),
HashMap::new(),
Some(5_000),
std::slice::from_ref(&secret_path),
&[],
/*tty*/ false,
/*stdin_open*/ false,
/*use_private_desktop*/ true,
)
.await
.expect_err("legacy deny-read should require the elevated backend");
assert!(
err.to_string()
.contains("deny-read overrides require the elevated Windows sandbox backend"),
"unexpected error: {err:#}"
);
});
}
#[test]
fn legacy_non_tty_powershell_emits_output() {
let Some(pwsh) = pwsh_path() else {
@@ -200,6 +241,8 @@ fn legacy_non_tty_powershell_emits_output() {
cwd.as_path(),
HashMap::new(),
Some(5_000),
&[],
&[],
/*tty*/ false,
/*stdin_open*/ false,
/*use_private_desktop*/ true,
@@ -424,6 +467,8 @@ fn legacy_tty_powershell_emits_output_and_accepts_input() {
cwd.as_path(),
HashMap::new(),
Some(10_000),
&[],
&[],
/*tty*/ true,
/*stdin_open*/ true,
/*use_private_desktop*/ true,
@@ -472,6 +517,8 @@ fn legacy_tty_cmd_emits_output_and_accepts_input() {
cwd.as_path(),
HashMap::new(),
Some(10_000),
&[],
&[],
/*tty*/ true,
/*stdin_open*/ true,
/*use_private_desktop*/ true,
@@ -523,6 +570,8 @@ fn legacy_tty_cmd_default_desktop_emits_output_and_accepts_input() {
cwd.as_path(),
HashMap::new(),
Some(10_000),
&[],
&[],
/*tty*/ true,
/*stdin_open*/ true,
/*use_private_desktop*/ false,