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
+54 -16
View File
@@ -232,8 +232,39 @@ impl ReadDenyMatcher {
/// can skip read-deny checks without allocating matcher state. The `cwd`
/// resolves cwd-relative policy paths and special paths before matching.
pub fn new(file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path) -> Option<Self> {
match Self::build(
file_system_sandbox_policy,
cwd,
InvalidDenyReadGlobBehavior::FailClosed,
) {
Ok(matcher) => matcher,
Err(_) => unreachable!("fail-closed glob handling does not return errors"),
}
}
/// Builds a matcher for callers that must reject malformed glob patterns.
///
/// Runtime read checks intentionally fail closed on malformed deny patterns.
/// Host-side expansion work should use this constructor instead so a typo
/// cannot broaden the set of paths it mutates before execution starts.
pub fn try_new(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> Result<Option<Self>, String> {
Self::build(
file_system_sandbox_policy,
cwd,
InvalidDenyReadGlobBehavior::ReturnError,
)
}
fn build(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
invalid_glob_behavior: InvalidDenyReadGlobBehavior,
) -> Result<Option<Self>, String> {
if !file_system_sandbox_policy.has_denied_read_restrictions() {
return None;
return Ok(None);
}
// Exact roots are stored as all meaningful path spellings we can derive
@@ -247,22 +278,23 @@ impl ReadDenyMatcher {
// Pattern entries stay as policy-level globs. They are matched at read
// time here instead of being snapshotted to startup filesystem state.
let mut invalid_pattern = false;
let deny_read_matchers = file_system_sandbox_policy
.get_unreadable_globs_with_cwd(cwd)
.into_iter()
.filter_map(|pattern| match build_glob_matcher(&pattern) {
Some(matcher) => Some(matcher),
None => {
invalid_pattern = true;
None
}
})
.collect();
Some(Self {
let mut deny_read_matchers = Vec::new();
for pattern in file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd) {
match build_glob_matcher(&pattern) {
Ok(matcher) => deny_read_matchers.push(matcher),
Err(err) => match invalid_glob_behavior {
InvalidDenyReadGlobBehavior::FailClosed => invalid_pattern = true,
InvalidDenyReadGlobBehavior::ReturnError => {
return Err(format!("invalid deny-read glob pattern `{pattern}`: {err}"));
}
},
}
}
Ok(Some(Self {
denied_candidates,
deny_read_matchers,
invalid_pattern,
})
}))
}
/// Returns whether `path` is denied by the policy used to build this matcher.
@@ -295,6 +327,12 @@ impl ReadDenyMatcher {
}
}
#[derive(Clone, Copy)]
enum InvalidDenyReadGlobBehavior {
FailClosed,
ReturnError,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
@@ -1291,15 +1329,15 @@ fn push_unique(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
}
}
fn build_glob_matcher(pattern: &str) -> Option<GlobMatcher> {
fn build_glob_matcher(pattern: &str) -> Result<GlobMatcher, String> {
// Keep `*` and `?` within a single path component and preserve an unclosed
// `[` as a literal so matcher behavior stays aligned with config parsing.
GlobBuilder::new(pattern)
.literal_separator(true)
.allow_unclosed_class(true)
.build()
.ok()
.map(|glob| glob.compile_matcher())
.map_err(|err| err.to_string())
}
fn resolve_file_system_special_path(