Files
codex/codex-rs/windows-sandbox-rs/src/spawn_prep.rs
T
46f30d0282 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>
2026-05-11 23:04:28 -07:00

454 lines
14 KiB
Rust

use crate::acl::add_allow_ace;
use crate::acl::add_deny_write_ace;
use crate::acl::allow_null_device;
use crate::allow::AllowDenyPaths;
use crate::allow::compute_allow_paths;
use crate::cap::load_or_create_cap_sids;
use crate::cap::workspace_cap_sid_for_cwd;
use crate::deny_read_acl::apply_deny_read_acls;
use crate::deny_read_state::sync_persistent_deny_read_acls;
use crate::env::apply_no_network_to_env;
use crate::env::ensure_non_interactive_pager;
use crate::env::inherit_path_env;
use crate::env::normalize_null_device_env;
use crate::identity::SandboxCreds;
use crate::identity::require_logon_sandbox_creds;
use crate::logging::log_start;
use crate::path_normalization::canonicalize_path;
use crate::policy::SandboxPolicy;
use crate::policy::parse_policy;
use crate::sandbox_utils::ensure_codex_home_exists;
use crate::sandbox_utils::inject_git_safe_directory;
use crate::token::convert_string_sid_to_sid;
use crate::token::create_readonly_token_with_cap;
use crate::token::create_workspace_write_token_with_caps_from;
use crate::token::get_current_token_for_restriction;
use crate::token::get_logon_sid_bytes;
use crate::workspace_acl::is_command_cwd_root;
use crate::workspace_acl::protect_workspace_agents_dir;
use crate::workspace_acl::protect_workspace_codex_dir;
use anyhow::Context;
use anyhow::Result;
use std::collections::HashMap;
use std::ffi::c_void;
use std::path::Path;
use std::path::PathBuf;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Foundation::HLOCAL;
use windows_sys::Win32::Foundation::LocalFree;
pub(crate) struct SpawnContext {
pub(crate) policy: SandboxPolicy,
pub(crate) current_dir: PathBuf,
pub(crate) sandbox_base: PathBuf,
pub(crate) logs_base_dir: Option<PathBuf>,
pub(crate) is_workspace_write: bool,
}
pub(crate) struct ElevatedSpawnContext {
pub(crate) common: SpawnContext,
pub(crate) sandbox_creds: SandboxCreds,
pub(crate) cap_sids: Vec<String>,
}
pub(crate) struct LegacySessionSecurity {
pub(crate) h_token: HANDLE,
pub(crate) psid_generic: LocalSid,
pub(crate) psid_workspace: Option<LocalSid>,
pub(crate) cap_sid_str: String,
}
/// Owns a SID allocated by `ConvertStringSidToSidW` and releases it with `LocalFree`.
pub struct LocalSid {
psid: *mut c_void,
}
impl LocalSid {
pub fn from_string(sid: &str) -> Result<Self> {
let psid = unsafe { convert_string_sid_to_sid(sid) }
.ok_or_else(|| anyhow::anyhow!("invalid SID string: {sid}"))?;
Ok(Self { psid })
}
pub fn as_ptr(&self) -> *mut c_void {
self.psid
}
}
impl Drop for LocalSid {
fn drop(&mut self) {
if !self.psid.is_null() {
unsafe {
LocalFree(self.psid as HLOCAL);
}
}
}
}
pub(crate) fn should_apply_network_block(policy: &SandboxPolicy) -> bool {
!policy.has_full_network_access()
}
fn prepare_spawn_context_common(
policy_json_or_preset: &str,
codex_home: &Path,
cwd: &Path,
env_map: &mut HashMap<String, String>,
command: &[String],
inherit_path: bool,
add_git_safe_directory: bool,
) -> Result<SpawnContext> {
let policy = parse_policy(policy_json_or_preset)?;
if matches!(
&policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
) {
anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing")
}
normalize_null_device_env(env_map);
ensure_non_interactive_pager(env_map);
if inherit_path {
inherit_path_env(env_map);
}
if add_git_safe_directory {
inject_git_safe_directory(env_map, cwd);
}
ensure_codex_home_exists(codex_home)?;
let sandbox_base = codex_home.join(".sandbox");
std::fs::create_dir_all(&sandbox_base)?;
let logs_base_dir = Some(sandbox_base.clone());
log_start(command, logs_base_dir.as_deref());
let is_workspace_write = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. });
Ok(SpawnContext {
policy,
current_dir: cwd.to_path_buf(),
sandbox_base,
logs_base_dir,
is_workspace_write,
})
}
pub(crate) fn prepare_legacy_spawn_context(
policy_json_or_preset: &str,
codex_home: &Path,
cwd: &Path,
env_map: &mut HashMap<String, String>,
command: &[String],
inherit_path: bool,
add_git_safe_directory: bool,
) -> Result<SpawnContext> {
let common = prepare_spawn_context_common(
policy_json_or_preset,
codex_home,
cwd,
env_map,
command,
inherit_path,
add_git_safe_directory,
)?;
if should_apply_network_block(&common.policy) {
apply_no_network_to_env(env_map)?;
}
Ok(common)
}
pub(crate) fn prepare_legacy_session_security(
policy: &SandboxPolicy,
codex_home: &Path,
cwd: &Path,
) -> Result<LegacySessionSecurity> {
let caps = load_or_create_cap_sids(codex_home)?;
let (h_token, psid_generic, psid_workspace, cap_sid_str) = unsafe {
match policy {
SandboxPolicy::ReadOnly { .. } => {
let psid = LocalSid::from_string(&caps.readonly)?;
let (h_token, _psid) = create_readonly_token_with_cap(psid.as_ptr())?;
(h_token, psid, None, caps.readonly)
}
SandboxPolicy::WorkspaceWrite { .. } => {
let psid_generic = LocalSid::from_string(&caps.workspace)?;
let workspace_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?;
let psid_workspace = LocalSid::from_string(&workspace_sid)?;
let base = get_current_token_for_restriction()?;
let h_token = create_workspace_write_token_with_caps_from(
base,
&[psid_generic.as_ptr(), psid_workspace.as_ptr()],
);
CloseHandle(base);
let h_token = h_token?;
(h_token, psid_generic, Some(psid_workspace), caps.workspace)
}
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
unreachable!("dangerous policies rejected before legacy session prep")
}
}
};
Ok(LegacySessionSecurity {
h_token,
psid_generic,
psid_workspace,
cap_sid_str,
})
}
pub(crate) fn allow_null_device_for_workspace_write(is_workspace_write: bool) {
if !is_workspace_write {
return;
}
unsafe {
if let Ok(base) = get_current_token_for_restriction() {
if let Ok(bytes) = get_logon_sid_bytes(base) {
let mut tmp = bytes;
let psid = tmp.as_mut_ptr() as *mut c_void;
allow_null_device(psid);
}
CloseHandle(base);
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn apply_legacy_session_acl_rules(
policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
codex_home: &Path,
current_dir: &Path,
env_map: &HashMap<String, String>,
psid_generic: &LocalSid,
psid_workspace: Option<&LocalSid>,
cap_sid_str: &str,
additional_deny_read_paths: &[PathBuf],
additional_deny_write_paths: &[PathBuf],
persist_aces: bool,
) -> Result<Vec<PathBuf>> {
let AllowDenyPaths { allow, mut deny } =
compute_allow_paths(policy, sandbox_policy_cwd, current_dir, env_map);
let mut guards: Vec<PathBuf> = Vec::new();
let canonical_cwd = canonicalize_path(current_dir);
unsafe {
for path in additional_deny_write_paths {
// Explicit carveouts must exist before the command starts so the
// sandbox cannot create them under a writable parent first.
if !path.exists() {
std::fs::create_dir_all(path)
.with_context(|| format!("create deny-write path {}", path.display()))?;
}
deny.insert(path.clone());
}
for p in &allow {
let psid = if matches!(policy, SandboxPolicy::WorkspaceWrite { .. })
&& is_command_cwd_root(p, &canonical_cwd)
{
psid_workspace.unwrap_or(psid_generic).as_ptr()
} else {
psid_generic.as_ptr()
};
if matches!(add_allow_ace(p, psid), Ok(true)) && !persist_aces {
guards.push(p.clone());
}
}
for p in &deny {
if let Ok(added) = add_deny_write_ace(p, psid_generic.as_ptr())
&& added
&& !persist_aces
{
guards.push(p.clone());
}
}
let applied_deny_read_paths = if persist_aces {
sync_persistent_deny_read_acls(
codex_home,
cap_sid_str,
additional_deny_read_paths,
psid_generic.as_ptr(),
)?
} else {
apply_deny_read_acls(additional_deny_read_paths, psid_generic.as_ptr())?
};
if !persist_aces {
guards.extend(applied_deny_read_paths);
}
allow_null_device(psid_generic.as_ptr());
if let Some(psid_workspace) = psid_workspace {
allow_null_device(psid_workspace.as_ptr());
if persist_aces && matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) {
let _ = protect_workspace_codex_dir(current_dir, psid_workspace.as_ptr());
let _ = protect_workspace_agents_dir(current_dir, psid_workspace.as_ptr());
}
}
}
Ok(guards)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn prepare_elevated_spawn_context(
policy_json_or_preset: &str,
sandbox_policy_cwd: &Path,
codex_home: &Path,
cwd: &Path,
env_map: &mut HashMap<String, String>,
command: &[String],
read_roots_override: Option<&[PathBuf]>,
read_roots_include_platform_defaults: bool,
write_roots_override: Option<&[PathBuf]>,
deny_read_paths_override: &[PathBuf],
deny_write_paths_override: &[PathBuf],
) -> Result<ElevatedSpawnContext> {
let common = prepare_spawn_context_common(
policy_json_or_preset,
codex_home,
cwd,
env_map,
command,
/*inherit_path*/ true,
/*add_git_safe_directory*/ true,
)?;
let AllowDenyPaths { allow, deny } = compute_allow_paths(
&common.policy,
sandbox_policy_cwd,
&common.current_dir,
env_map,
);
let write_roots: Vec<PathBuf> = allow.into_iter().collect();
let deny_write_paths: Vec<PathBuf> = deny.into_iter().collect();
let computed_write_roots_override = if common.is_workspace_write {
Some(write_roots.as_slice())
} else {
None
};
let sandbox_creds = require_logon_sandbox_creds(
&common.policy,
sandbox_policy_cwd,
cwd,
env_map,
codex_home,
read_roots_override,
read_roots_include_platform_defaults,
write_roots_override.or(computed_write_roots_override),
deny_read_paths_override,
if deny_write_paths_override.is_empty() {
&deny_write_paths
} else {
deny_write_paths_override
},
/*proxy_enforced*/ false,
)?;
let caps = load_or_create_cap_sids(codex_home)?;
let (psid_to_use, cap_sids) = match &common.policy {
SandboxPolicy::ReadOnly { .. } => (
LocalSid::from_string(&caps.readonly)?,
vec![caps.readonly.clone()],
),
SandboxPolicy::WorkspaceWrite { .. } => {
let cap_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?;
(
LocalSid::from_string(&caps.workspace)?,
vec![caps.workspace.clone(), cap_sid],
)
}
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
unreachable!("dangerous policies rejected before elevated session prep")
}
};
unsafe {
allow_null_device(psid_to_use.as_ptr());
}
Ok(ElevatedSpawnContext {
common,
sandbox_creds,
cap_sids,
})
}
#[cfg(test)]
mod tests {
use super::SandboxPolicy;
use super::prepare_legacy_spawn_context;
use super::prepare_spawn_context_common;
use super::should_apply_network_block;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use tempfile::TempDir;
#[test]
fn no_network_env_rewrite_applies_for_workspace_write() {
assert!(should_apply_network_block(
&SandboxPolicy::new_workspace_write_policy(),
));
}
#[test]
fn no_network_env_rewrite_skips_when_network_access_is_allowed() {
assert!(!should_apply_network_block(
&SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
));
}
#[test]
fn legacy_spawn_env_applies_offline_network_rewrite() {
let codex_home = TempDir::new().expect("tempdir");
let cwd = TempDir::new().expect("tempdir");
let mut env_map = HashMap::new();
let _context = prepare_legacy_spawn_context(
"workspace-write",
codex_home.path(),
cwd.path(),
&mut env_map,
&["cmd.exe".to_string()],
/*inherit_path*/ true,
/*add_git_safe_directory*/ false,
)
.expect("legacy env prep");
assert_eq!(env_map.get("SBX_NONET_ACTIVE"), Some(&"1".to_string()));
assert_eq!(
env_map.get("HTTP_PROXY"),
Some(&"http://127.0.0.1:9".to_string())
);
}
#[test]
fn common_spawn_env_keeps_network_env_unchanged() {
let codex_home = TempDir::new().expect("tempdir");
let cwd = TempDir::new().expect("tempdir");
let mut env_map = HashMap::from([(
"HTTP_PROXY".to_string(),
"http://user.proxy:8080".to_string(),
)]);
let context = prepare_spawn_context_common(
"workspace-write",
codex_home.path(),
cwd.path(),
&mut env_map,
&["cmd.exe".to_string()],
/*inherit_path*/ true,
/*add_git_safe_directory*/ true,
)
.expect("preserve existing env prep");
assert_eq!(context.policy, SandboxPolicy::new_workspace_write_policy());
assert_eq!(env_map.get("SBX_NONET_ACTIVE"), None);
assert_eq!(
env_map.get("HTTP_PROXY"),
Some(&"http://user.proxy:8080".to_string())
);
}
}