Skip to content

Commit 46f30d0

Browse files
viyatb-oaicodex
andauthored
feat(sandbox): add Windows deny-read parity (openai#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. openai#15979 `feat(permissions): add glob deny-read policy support` 2. openai#18096 `feat(sandbox): add glob deny-read platform enforcement` 3. openai#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>
1 parent c9e46ed commit 46f30d0

24 files changed

Lines changed: 1548 additions & 176 deletions

File tree

codex-rs/cli/src/debug_sandbox.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,11 @@ async fn run_command_under_windows_session(
370370
cwd.as_path(),
371371
env,
372372
None,
373+
/*read_roots_override*/ None,
374+
/*read_roots_include_platform_defaults*/ false,
375+
/*write_roots_override*/ None,
376+
/*deny_read_paths_override*/ &[],
377+
/*deny_write_paths_override*/ &[],
373378
/*tty*/ false,
374379
/*stdin_open*/ true,
375380
config.permissions.windows_sandbox_private_desktop,
@@ -384,6 +389,8 @@ async fn run_command_under_windows_session(
384389
cwd.as_path(),
385390
env,
386391
None,
392+
/*additional_deny_read_paths*/ &[],
393+
/*additional_deny_write_paths*/ &[],
387394
/*tty*/ false,
388395
/*stdin_open*/ true,
389396
config.permissions.windows_sandbox_private_desktop,

codex-rs/core/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,7 @@ codex_rust_crate(
5757
"//codex-rs/rmcp-client:test_stdio_server",
5858
"//codex-rs/rmcp-client:test_streamable_http_server",
5959
"//codex-rs/cli:codex",
60+
"//codex-rs/windows-sandbox-rs:codex-command-runner",
61+
"//codex-rs/windows-sandbox-rs:codex-windows-sandbox-setup",
6062
],
6163
)

codex-rs/core/src/config/mod.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2905,12 +2905,6 @@ impl Config {
29052905
}
29062906
})
29072907
.map_err(std::io::Error::from)?;
2908-
2909-
if cfg!(target_os = "windows") {
2910-
startup_warnings.push(format!(
2911-
"managed filesystem deny_read from {filesystem_requirements_source} is only enforced for direct file tools on Windows; shell subprocess reads are not sandboxed"
2912-
));
2913-
}
29142908
}
29152909
apply_requirement_constrained_value(
29162910
"approvals_reviewer",

codex-rs/core/src/exec.rs

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,19 @@ pub struct ExecParams {
9797

9898
/// Resolved filesystem overrides for the Windows sandbox backends.
9999
///
100-
/// The unelevated restricted-token backend only consumes extra deny-write
101-
/// carveouts on top of the legacy `WorkspaceWrite` allow set. The elevated
102-
/// backend can also consume explicit read and write roots during setup/refresh.
103-
/// Read-root overrides are layered on top of the baseline helper roots that the
104-
/// elevated setup path needs to launch the sandboxed command. Split policies
105-
/// that opt into platform defaults carry that explicitly with the override.
100+
/// The elevated Windows backend consumes extra deny-read paths plus explicit
101+
/// read and write roots during setup/refresh. The unelevated restricted-token
102+
/// backend only consumes extra deny-write carveouts on top of the legacy
103+
/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the
104+
/// baseline helper roots that the elevated setup path needs to launch the
105+
/// sandboxed command; split policies that opt into platform defaults carry
106+
/// that explicitly with the override.
106107
#[derive(Debug, Clone, PartialEq, Eq)]
107108
pub(crate) struct WindowsSandboxFilesystemOverrides {
108109
pub(crate) read_roots_override: Option<Vec<PathBuf>>,
109110
pub(crate) read_roots_include_platform_defaults: bool,
110111
pub(crate) write_roots_override: Option<Vec<PathBuf>>,
112+
pub(crate) additional_deny_read_paths: Vec<AbsolutePathBuf>,
111113
pub(crate) additional_deny_write_paths: Vec<AbsolutePathBuf>,
112114
}
113115

@@ -564,7 +566,7 @@ async fn exec_windows_sandbox(
564566
) -> Result<RawExecToolCallOutput> {
565567
use crate::config::find_codex_home;
566568
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
567-
use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths;
569+
use codex_windows_sandbox::run_windows_sandbox_capture_with_filesystem_overrides;
568570

569571
let ExecParams {
570572
command,
@@ -605,29 +607,17 @@ async fn exec_windows_sandbox(
605607
let proxy_enforced = network.is_some();
606608
let use_elevated = windows_sandbox_uses_elevated_backend(sandbox_level, proxy_enforced);
607609
let additional_deny_write_paths = windows_sandbox_filesystem_overrides
608-
.map(|overrides| {
609-
overrides
610-
.additional_deny_write_paths
611-
.iter()
612-
.map(AbsolutePathBuf::to_path_buf)
613-
.collect::<Vec<_>>()
614-
})
610+
.map(|overrides| overrides.additional_deny_write_paths.clone())
611+
.unwrap_or_default();
612+
let additional_deny_read_paths = windows_sandbox_filesystem_overrides
613+
.map(|overrides| overrides.additional_deny_read_paths.clone())
615614
.unwrap_or_default();
616615
let elevated_read_roots_override = windows_sandbox_filesystem_overrides
617616
.and_then(|overrides| overrides.read_roots_override.clone());
618617
let elevated_read_roots_include_platform_defaults = windows_sandbox_filesystem_overrides
619618
.is_some_and(|overrides| overrides.read_roots_include_platform_defaults);
620619
let elevated_write_roots_override = windows_sandbox_filesystem_overrides
621620
.and_then(|overrides| overrides.write_roots_override.clone());
622-
let elevated_deny_write_paths = windows_sandbox_filesystem_overrides
623-
.map(|overrides| {
624-
overrides
625-
.additional_deny_write_paths
626-
.iter()
627-
.map(AbsolutePathBuf::to_path_buf)
628-
.collect::<Vec<_>>()
629-
})
630-
.unwrap_or_default();
631621
let spawn_res = tokio::task::spawn_blocking(move || {
632622
if use_elevated {
633623
run_windows_sandbox_capture_elevated(
@@ -645,18 +635,20 @@ async fn exec_windows_sandbox(
645635
read_roots_include_platform_defaults:
646636
elevated_read_roots_include_platform_defaults,
647637
write_roots_override: elevated_write_roots_override.as_deref(),
648-
deny_write_paths_override: &elevated_deny_write_paths,
638+
deny_read_paths_override: &additional_deny_read_paths,
639+
deny_write_paths_override: &additional_deny_write_paths,
649640
},
650641
)
651642
} else {
652-
run_windows_sandbox_capture_with_extra_deny_write_paths(
643+
run_windows_sandbox_capture_with_filesystem_overrides(
653644
policy_str.as_str(),
654645
&sandbox_cwd,
655646
codex_home.as_ref(),
656647
command,
657648
&cwd,
658649
env,
659650
timeout_ms,
651+
&additional_deny_read_paths,
660652
&additional_deny_write_paths,
661653
windows_sandbox_private_desktop,
662654
)
@@ -1049,22 +1041,24 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
10491041
));
10501042
}
10511043

1052-
if !file_system_sandbox_policy.has_full_disk_read_access() {
1044+
// The restricted-token backend can still enforce split write restrictions,
1045+
// but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs
1046+
// participate in read access checks. Read restrictions therefore require the
1047+
// elevated backend, even when the filesystem root remains readable.
1048+
if !windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd) {
10531049
return Err(
10541050
"windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed"
10551051
.to_string(),
10561052
);
10571053
}
10581054

1059-
if !file_system_sandbox_policy
1060-
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
1061-
.is_empty()
1062-
|| !file_system_sandbox_policy
1063-
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
1064-
.is_empty()
1065-
{
1055+
let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths(
1056+
file_system_sandbox_policy,
1057+
sandbox_policy_cwd,
1058+
)?;
1059+
if !additional_deny_read_paths.is_empty() {
10661060
return Err(
1067-
"windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
1061+
"windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed"
10681062
.to_string(),
10691063
);
10701064
}
@@ -1131,14 +1125,15 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
11311125
}
11321126
}
11331127

1134-
if additional_deny_write_paths.is_empty() {
1128+
if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() {
11351129
return Ok(None);
11361130
}
11371131

11381132
Ok(Some(WindowsSandboxFilesystemOverrides {
11391133
read_roots_override: None,
11401134
read_roots_include_platform_defaults: false,
11411135
write_roots_override: None,
1136+
additional_deny_read_paths,
11421137
additional_deny_write_paths: additional_deny_write_paths
11431138
.into_iter()
11441139
.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,
11521147
.map_err(|err| err.to_string())
11531148
}
11541149

1150+
fn windows_policy_has_root_read_access(
1151+
file_system_sandbox_policy: &FileSystemSandboxPolicy,
1152+
cwd: &AbsolutePathBuf,
1153+
) -> bool {
1154+
let Some(root) = cwd.as_path().ancestors().last() else {
1155+
return false;
1156+
};
1157+
file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path())
1158+
}
1159+
11551160
pub(crate) fn resolve_windows_elevated_filesystem_overrides(
11561161
sandbox: SandboxType,
11571162
sandbox_policy: &SandboxPolicy,
@@ -1175,18 +1180,10 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
11751180
));
11761181
}
11771182

1178-
if !file_system_sandbox_policy
1179-
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
1180-
.is_empty()
1181-
|| !file_system_sandbox_policy
1182-
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
1183-
.is_empty()
1184-
{
1185-
return Err(
1186-
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
1187-
.to_string(),
1188-
);
1189-
}
1183+
let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths(
1184+
file_system_sandbox_policy,
1185+
sandbox_policy_cwd,
1186+
)?;
11901187

11911188
let split_writable_roots =
11921189
file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
@@ -1217,7 +1214,13 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
12171214
.collect();
12181215
let split_root_path_set: BTreeSet<PathBuf> = split_root_paths.iter().cloned().collect();
12191216

1220-
let read_roots_override = if file_system_sandbox_policy.has_full_disk_read_access() {
1217+
// `has_full_disk_read_access()` is intentionally false when deny-read
1218+
// entries exist. For Windows setup overrides, the important question is
1219+
// whether the baseline still reads from the filesystem root and only needs
1220+
// additional deny ACLs layered on top.
1221+
let split_has_root_read_access =
1222+
windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd);
1223+
let read_roots_override = if split_has_root_read_access {
12211224
None
12221225
} else {
12231226
Some(split_readable_roots)
@@ -1265,6 +1268,7 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
12651268

12661269
if read_roots_override.is_none()
12671270
&& write_roots_override.is_none()
1271+
&& additional_deny_read_paths.is_empty()
12681272
&& additional_deny_write_paths.is_empty()
12691273
{
12701274
return Ok(None);
@@ -1275,6 +1279,7 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
12751279
&& file_system_sandbox_policy.include_platform_defaults(),
12761280
read_roots_override,
12771281
write_roots_override,
1282+
additional_deny_read_paths,
12781283
additional_deny_write_paths,
12791284
}))
12801285
}

0 commit comments

Comments
 (0)