mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fix: support split carveouts in windows elevated sandbox (#14568)
## Summary - preserve legacy Windows elevated sandbox behavior for existing policies - add elevated-only support for split filesystem policies that can be represented as readable-root overrides, writable-root overrides, and extra deny-write carveouts - resolve those elevated filesystem overrides during sandbox transform and thread them through setup and policy refresh - keep failing closed for explicit unreadable (`none`) carveouts and reopened writable descendants under read-only carveouts - for explicit read-only-under-writable-root carveouts, materialize missing carveout directories during elevated setup before applying the deny-write ACL - document the elevated vs restricted-token support split in the core README ## Example Given a split filesystem policy like: ```toml ":root" = "read" ":cwd" = "write" "./docs" = "read" "C:/scratch" = "write" ``` the elevated backend now provisions the readable-root overrides, writable-root overrides, and extra deny-write carveouts during setup and refresh instead of collapsing back to the legacy workspace-only shape. If a read-only carveout under a writable root is missing at setup time, elevated setup creates that carveout as an empty directory before applying its deny-write ACE; otherwise the sandboxed command could create it later and bypass the carveout. This is only for explicit policy carveouts. Best-effort workspace protections like `.codex/` and `.agents/` still skip missing directories. A policy like: ```toml "/workspace" = "write" "/workspace/docs" = "read" "/workspace/docs/tmp" = "write" ``` still fails closed, because the elevated backend does not reopen writable descendants under read-only carveouts yet. --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+251
-48
@@ -94,17 +94,30 @@ pub struct ExecParams {
|
||||
pub arg0: Option<String>,
|
||||
}
|
||||
|
||||
/// Extra filesystem deny-write carveouts for the non-elevated Windows
|
||||
/// restricted-token backend.
|
||||
/// Resolved filesystem overrides for the Windows sandbox backends.
|
||||
///
|
||||
/// These are applied on top of the legacy `WorkspaceWrite` allow set, so we
|
||||
/// can support a narrow split-policy subset without changing legacy Windows
|
||||
/// sandbox semantics.
|
||||
/// The unelevated restricted-token backend only consumes extra deny-write
|
||||
/// carveouts on top of the legacy `WorkspaceWrite` allow set. The elevated
|
||||
/// backend can also consume explicit read and write roots during setup/refresh.
|
||||
/// Read-root overrides are layered on top of the baseline helper/platform roots
|
||||
/// that the elevated setup path needs to launch the sandboxed command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct WindowsRestrictedTokenFilesystemOverlay {
|
||||
pub(crate) struct WindowsSandboxFilesystemOverrides {
|
||||
pub(crate) read_roots_override: Option<Vec<PathBuf>>,
|
||||
pub(crate) write_roots_override: Option<Vec<PathBuf>>,
|
||||
pub(crate) additional_deny_write_paths: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
fn windows_sandbox_uses_elevated_backend(
|
||||
sandbox_level: WindowsSandboxLevel,
|
||||
proxy_enforced: bool,
|
||||
) -> bool {
|
||||
// Windows firewall enforcement is tied to the logon-user sandbox identities, so
|
||||
// proxy-enforced sessions must use that backend even when the configured mode is
|
||||
// the default restricted-token sandbox.
|
||||
proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ExecCapturePolicy {
|
||||
/// Shell-like execs keep the historical output cap and timeout behavior.
|
||||
@@ -300,8 +313,21 @@ pub fn build_exec_request(
|
||||
})
|
||||
.map(|request| ExecRequest::from_sandbox_exec_request(request, options))
|
||||
.map_err(CodexErr::from)?;
|
||||
exec_req.windows_restricted_token_filesystem_overlay =
|
||||
resolve_windows_restricted_token_filesystem_overlay(
|
||||
let use_windows_elevated_backend = windows_sandbox_uses_elevated_backend(
|
||||
exec_req.windows_sandbox_level,
|
||||
exec_req.network.is_some(),
|
||||
);
|
||||
exec_req.windows_sandbox_filesystem_overrides = if use_windows_elevated_backend {
|
||||
resolve_windows_elevated_filesystem_overrides(
|
||||
exec_req.sandbox,
|
||||
&exec_req.sandbox_policy,
|
||||
&exec_req.file_system_sandbox_policy,
|
||||
exec_req.network_sandbox_policy,
|
||||
sandbox_cwd,
|
||||
use_windows_elevated_backend,
|
||||
)
|
||||
} else {
|
||||
resolve_windows_restricted_token_filesystem_overrides(
|
||||
exec_req.sandbox,
|
||||
&exec_req.sandbox_policy,
|
||||
&exec_req.file_system_sandbox_policy,
|
||||
@@ -309,7 +335,8 @@ pub fn build_exec_request(
|
||||
sandbox_cwd,
|
||||
exec_req.windows_sandbox_level,
|
||||
)
|
||||
.map_err(CodexErr::UnsupportedOperation)?;
|
||||
}
|
||||
.map_err(CodexErr::UnsupportedOperation)?;
|
||||
Ok(exec_req)
|
||||
}
|
||||
|
||||
@@ -331,7 +358,7 @@ pub(crate) async fn execute_exec_request(
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay,
|
||||
windows_sandbox_filesystem_overrides,
|
||||
arg0,
|
||||
} = exec_request;
|
||||
|
||||
@@ -355,7 +382,7 @@ pub(crate) async fn execute_exec_request(
|
||||
sandbox,
|
||||
&sandbox_policy,
|
||||
&file_system_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay.as_ref(),
|
||||
windows_sandbox_filesystem_overrides.as_ref(),
|
||||
network_sandbox_policy,
|
||||
stdout_stream,
|
||||
after_spawn,
|
||||
@@ -435,7 +462,7 @@ fn record_windows_sandbox_spawn_failure(
|
||||
async fn exec_windows_sandbox(
|
||||
params: ExecParams,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>,
|
||||
windows_sandbox_filesystem_overrides: Option<&WindowsSandboxFilesystemOverrides>,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
use crate::config::find_codex_home;
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
|
||||
@@ -478,13 +505,23 @@ async fn exec_windows_sandbox(
|
||||
let command_path = command.first().cloned();
|
||||
let sandbox_level = windows_sandbox_level;
|
||||
let proxy_enforced = network.is_some();
|
||||
// Windows firewall enforcement is tied to the logon-user sandbox identities, so
|
||||
// proxy-enforced sessions must use that backend even when the configured mode is
|
||||
// the default restricted-token sandbox.
|
||||
let use_elevated = proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated);
|
||||
let additional_deny_write_paths = windows_restricted_token_filesystem_overlay
|
||||
.map(|overlay| {
|
||||
overlay
|
||||
let use_elevated = windows_sandbox_uses_elevated_backend(sandbox_level, proxy_enforced);
|
||||
let additional_deny_write_paths = windows_sandbox_filesystem_overrides
|
||||
.map(|overrides| {
|
||||
overrides
|
||||
.additional_deny_write_paths
|
||||
.iter()
|
||||
.map(AbsolutePathBuf::to_path_buf)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let elevated_read_roots_override = windows_sandbox_filesystem_overrides
|
||||
.and_then(|overrides| overrides.read_roots_override.clone());
|
||||
let elevated_write_roots_override = windows_sandbox_filesystem_overrides
|
||||
.and_then(|overrides| overrides.write_roots_override.clone());
|
||||
let elevated_deny_write_paths = windows_sandbox_filesystem_overrides
|
||||
.map(|overrides| {
|
||||
overrides
|
||||
.additional_deny_write_paths
|
||||
.iter()
|
||||
.map(AbsolutePathBuf::to_path_buf)
|
||||
@@ -504,6 +541,9 @@ async fn exec_windows_sandbox(
|
||||
timeout_ms,
|
||||
use_private_desktop: windows_sandbox_private_desktop,
|
||||
proxy_enforced,
|
||||
read_roots_override: elevated_read_roots_override.as_deref(),
|
||||
write_roots_override: elevated_write_roots_override.as_deref(),
|
||||
deny_write_paths_override: &elevated_deny_write_paths,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
@@ -764,7 +804,7 @@ async fn exec(
|
||||
_sandbox: SandboxType,
|
||||
_sandbox_policy: &SandboxPolicy,
|
||||
_file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
_windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>,
|
||||
_windows_sandbox_filesystem_overrides: Option<&WindowsSandboxFilesystemOverrides>,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
after_spawn: Option<Box<dyn FnOnce() + Send>>,
|
||||
@@ -774,7 +814,7 @@ async fn exec(
|
||||
return exec_windows_sandbox(
|
||||
params,
|
||||
_sandbox_policy,
|
||||
_windows_restricted_token_filesystem_overlay,
|
||||
_windows_sandbox_filesystem_overrides,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -843,26 +883,40 @@ pub(crate) fn unsupported_windows_restricted_token_sandbox_reason(
|
||||
sandbox_policy_cwd: &Path,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
) -> Option<String> {
|
||||
resolve_windows_restricted_token_filesystem_overlay(
|
||||
sandbox,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
windows_sandbox_level,
|
||||
)
|
||||
.err()
|
||||
if windows_sandbox_level == WindowsSandboxLevel::Elevated {
|
||||
resolve_windows_elevated_filesystem_overrides(
|
||||
sandbox,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
windows_sandbox_level == WindowsSandboxLevel::Elevated,
|
||||
)
|
||||
.err()
|
||||
} else {
|
||||
resolve_windows_restricted_token_filesystem_overrides(
|
||||
sandbox,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
windows_sandbox_level,
|
||||
)
|
||||
.err()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_windows_restricted_token_filesystem_overlay(
|
||||
pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
|
||||
sandbox: SandboxType,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
) -> std::result::Result<Option<WindowsRestrictedTokenFilesystemOverlay>, String> {
|
||||
if sandbox != SandboxType::WindowsRestrictedToken {
|
||||
) -> std::result::Result<Option<WindowsSandboxFilesystemOverrides>, String> {
|
||||
if sandbox != SandboxType::WindowsRestrictedToken
|
||||
|| windows_sandbox_level == WindowsSandboxLevel::Elevated
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -889,13 +943,6 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overlay(
|
||||
));
|
||||
}
|
||||
|
||||
if windows_sandbox_level != WindowsSandboxLevel::RestrictedToken {
|
||||
return Err(
|
||||
"windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if !file_system_sandbox_policy.has_full_disk_read_access() {
|
||||
return Err(
|
||||
"windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed"
|
||||
@@ -918,11 +965,11 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overlay(
|
||||
file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
|
||||
let legacy_root_paths: BTreeSet<PathBuf> = legacy_writable_roots
|
||||
.iter()
|
||||
.map(|root| normalize_windows_overlay_path(root.root.as_path()))
|
||||
.map(|root| normalize_windows_override_path(root.root.as_path()))
|
||||
.collect::<std::result::Result<_, _>>()?;
|
||||
let split_root_paths: BTreeSet<PathBuf> = split_writable_roots
|
||||
.iter()
|
||||
.map(|root| normalize_windows_overlay_path(root.root.as_path()))
|
||||
.map(|root| normalize_windows_override_path(root.root.as_path()))
|
||||
.collect::<std::result::Result<_, _>>()?;
|
||||
|
||||
if legacy_root_paths != split_root_paths {
|
||||
@@ -951,9 +998,9 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overlay(
|
||||
|
||||
let mut additional_deny_write_paths = BTreeSet::new();
|
||||
for split_root in &split_writable_roots {
|
||||
let split_root_path = normalize_windows_overlay_path(split_root.root.as_path())?;
|
||||
let split_root_path = normalize_windows_override_path(split_root.root.as_path())?;
|
||||
let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| {
|
||||
normalize_windows_overlay_path(candidate.root.as_path())
|
||||
normalize_windows_override_path(candidate.root.as_path())
|
||||
.is_ok_and(|candidate_path| candidate_path == split_root_path)
|
||||
}) else {
|
||||
return Err(
|
||||
@@ -968,8 +1015,9 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overlay(
|
||||
.iter()
|
||||
.any(|candidate| candidate == read_only_subpath)
|
||||
{
|
||||
additional_deny_write_paths
|
||||
.insert(normalize_windows_overlay_path(read_only_subpath.as_path())?);
|
||||
additional_deny_write_paths.insert(normalize_windows_override_path(
|
||||
read_only_subpath.as_path(),
|
||||
)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -978,7 +1026,9 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overlay(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(WindowsRestrictedTokenFilesystemOverlay {
|
||||
Ok(Some(WindowsSandboxFilesystemOverrides {
|
||||
read_roots_override: None,
|
||||
write_roots_override: None,
|
||||
additional_deny_write_paths: additional_deny_write_paths
|
||||
.into_iter()
|
||||
.map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string()))
|
||||
@@ -986,12 +1036,165 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overlay(
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalize_windows_overlay_path(path: &Path) -> std::result::Result<PathBuf, String> {
|
||||
fn normalize_windows_override_path(path: &Path) -> std::result::Result<PathBuf, String> {
|
||||
AbsolutePathBuf::from_absolute_path(dunce::simplified(path))
|
||||
.map(AbsolutePathBuf::into_path_buf)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_windows_elevated_filesystem_overrides(
|
||||
sandbox: SandboxType,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
use_windows_elevated_backend: bool,
|
||||
) -> std::result::Result<Option<WindowsSandboxFilesystemOverrides>, String> {
|
||||
if sandbox != SandboxType::WindowsRestrictedToken || !use_windows_elevated_backend {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !should_use_windows_restricted_token_sandbox(
|
||||
sandbox,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
) {
|
||||
return Err(format!(
|
||||
"windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed",
|
||||
file_system_sandbox_policy.kind,
|
||||
));
|
||||
}
|
||||
|
||||
if !file_system_sandbox_policy
|
||||
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.is_empty()
|
||||
{
|
||||
return Err(
|
||||
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let split_writable_roots =
|
||||
file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
|
||||
if has_reopened_writable_descendant(&split_writable_roots) {
|
||||
return Err(
|
||||
"windows elevated sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let needs_direct_runtime_enforcement = file_system_sandbox_policy
|
||||
.needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd);
|
||||
let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path);
|
||||
let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
|
||||
let legacy_readable_root_set: BTreeSet<PathBuf> = sandbox_policy
|
||||
.get_readable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.into_iter()
|
||||
.map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf)
|
||||
.map(&normalize_path)
|
||||
.collect();
|
||||
let legacy_root_paths: BTreeSet<PathBuf> = legacy_writable_roots
|
||||
.iter()
|
||||
.map(|root| normalize_path(root.root.to_path_buf()))
|
||||
.collect();
|
||||
let split_readable_roots: Vec<PathBuf> = file_system_sandbox_policy
|
||||
.get_readable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.into_iter()
|
||||
.map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf)
|
||||
.map(&normalize_path)
|
||||
.collect();
|
||||
let split_readable_root_set: BTreeSet<PathBuf> = split_readable_roots.iter().cloned().collect();
|
||||
let split_root_paths: Vec<PathBuf> = split_writable_roots
|
||||
.iter()
|
||||
.map(|root| normalize_path(root.root.to_path_buf()))
|
||||
.collect();
|
||||
let split_root_path_set: BTreeSet<PathBuf> = split_root_paths.iter().cloned().collect();
|
||||
|
||||
let matches_legacy_read_access = file_system_sandbox_policy.has_full_disk_read_access()
|
||||
== sandbox_policy.has_full_disk_read_access();
|
||||
let read_roots_override = if matches_legacy_read_access
|
||||
&& (file_system_sandbox_policy.has_full_disk_read_access()
|
||||
|| split_readable_root_set == legacy_readable_root_set)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(split_readable_roots)
|
||||
};
|
||||
|
||||
let write_roots_override = if split_root_path_set == legacy_root_paths {
|
||||
None
|
||||
} else {
|
||||
Some(split_root_paths)
|
||||
};
|
||||
|
||||
let additional_deny_write_paths = if needs_direct_runtime_enforcement {
|
||||
let mut deny_paths = BTreeSet::new();
|
||||
for writable_root in &split_writable_roots {
|
||||
let writable_root_path = normalize_path(writable_root.root.to_path_buf());
|
||||
let legacy_root = legacy_writable_roots.iter().find(|candidate| {
|
||||
normalize_path(candidate.root.to_path_buf()) == writable_root_path
|
||||
});
|
||||
for read_only_subpath in &writable_root.read_only_subpaths {
|
||||
let read_only_subpath_suffix = read_only_subpath
|
||||
.as_path()
|
||||
.strip_prefix(writable_root.root.as_path())
|
||||
.ok();
|
||||
let already_denied_by_legacy = legacy_root.is_some_and(|legacy_root| {
|
||||
legacy_root.read_only_subpaths.iter().any(|candidate| {
|
||||
candidate
|
||||
.as_path()
|
||||
.strip_prefix(legacy_root.root.as_path())
|
||||
.ok()
|
||||
== read_only_subpath_suffix
|
||||
})
|
||||
});
|
||||
if !already_denied_by_legacy {
|
||||
deny_paths.insert(normalize_path(read_only_subpath.to_path_buf()));
|
||||
}
|
||||
}
|
||||
}
|
||||
deny_paths
|
||||
.into_iter()
|
||||
.map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string()))
|
||||
.collect::<std::result::Result<_, _>>()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
if read_roots_override.is_none()
|
||||
&& write_roots_override.is_none()
|
||||
&& additional_deny_write_paths.is_empty()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(WindowsSandboxFilesystemOverrides {
|
||||
read_roots_override,
|
||||
write_roots_override,
|
||||
additional_deny_write_paths,
|
||||
}))
|
||||
}
|
||||
|
||||
fn has_reopened_writable_descendant(
|
||||
writable_roots: &[codex_protocol::protocol::WritableRoot],
|
||||
) -> bool {
|
||||
writable_roots.iter().any(|writable_root| {
|
||||
writable_root
|
||||
.read_only_subpaths
|
||||
.iter()
|
||||
.any(|read_only_subpath| {
|
||||
writable_roots.iter().any(|candidate| {
|
||||
candidate.root.as_path() != writable_root.root.as_path()
|
||||
&& candidate
|
||||
.root
|
||||
.as_path()
|
||||
.starts_with(read_only_subpath.as_path())
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Consumes the output of a child process according to the configured capture
|
||||
/// policy.
|
||||
async fn consume_output(
|
||||
|
||||
Reference in New Issue
Block a user