mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user