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
parent c9e46ed639
commit 46f30d0282
24 changed files with 1548 additions and 176 deletions

View File

@@ -370,6 +370,11 @@ async fn run_command_under_windows_session(
cwd.as_path(),
env,
None,
/*read_roots_override*/ None,
/*read_roots_include_platform_defaults*/ false,
/*write_roots_override*/ None,
/*deny_read_paths_override*/ &[],
/*deny_write_paths_override*/ &[],
/*tty*/ false,
/*stdin_open*/ true,
config.permissions.windows_sandbox_private_desktop,
@@ -384,6 +389,8 @@ async fn run_command_under_windows_session(
cwd.as_path(),
env,
None,
/*additional_deny_read_paths*/ &[],
/*additional_deny_write_paths*/ &[],
/*tty*/ false,
/*stdin_open*/ true,
config.permissions.windows_sandbox_private_desktop,

View File

@@ -57,5 +57,7 @@ codex_rust_crate(
"//codex-rs/rmcp-client:test_stdio_server",
"//codex-rs/rmcp-client:test_streamable_http_server",
"//codex-rs/cli:codex",
"//codex-rs/windows-sandbox-rs:codex-command-runner",
"//codex-rs/windows-sandbox-rs:codex-windows-sandbox-setup",
],
)

View File

@@ -2905,12 +2905,6 @@ impl Config {
}
})
.map_err(std::io::Error::from)?;
if cfg!(target_os = "windows") {
startup_warnings.push(format!(
"managed filesystem deny_read from {filesystem_requirements_source} is only enforced for direct file tools on Windows; shell subprocess reads are not sandboxed"
));
}
}
apply_requirement_constrained_value(
"approvals_reviewer",

View File

@@ -97,17 +97,19 @@ pub struct ExecParams {
/// Resolved filesystem overrides for the Windows sandbox backends.
///
/// 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 roots that the
/// elevated setup path needs to launch the sandboxed command. Split policies
/// that opt into platform defaults carry that explicitly with the override.
/// The elevated Windows backend consumes extra deny-read paths plus explicit
/// read and write roots during setup/refresh. The unelevated restricted-token
/// backend only consumes extra deny-write carveouts on top of the legacy
/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the
/// baseline helper roots that the elevated setup path needs to launch the
/// sandboxed command; split policies that opt into platform defaults carry
/// that explicitly with the override.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WindowsSandboxFilesystemOverrides {
pub(crate) read_roots_override: Option<Vec<PathBuf>>,
pub(crate) read_roots_include_platform_defaults: bool,
pub(crate) write_roots_override: Option<Vec<PathBuf>>,
pub(crate) additional_deny_read_paths: Vec<AbsolutePathBuf>,
pub(crate) additional_deny_write_paths: Vec<AbsolutePathBuf>,
}
@@ -564,7 +566,7 @@ async fn exec_windows_sandbox(
) -> Result<RawExecToolCallOutput> {
use crate::config::find_codex_home;
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths;
use codex_windows_sandbox::run_windows_sandbox_capture_with_filesystem_overrides;
let ExecParams {
command,
@@ -605,13 +607,10 @@ async fn exec_windows_sandbox(
let proxy_enforced = network.is_some();
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<_>>()
})
.map(|overrides| overrides.additional_deny_write_paths.clone())
.unwrap_or_default();
let additional_deny_read_paths = windows_sandbox_filesystem_overrides
.map(|overrides| overrides.additional_deny_read_paths.clone())
.unwrap_or_default();
let elevated_read_roots_override = windows_sandbox_filesystem_overrides
.and_then(|overrides| overrides.read_roots_override.clone());
@@ -619,15 +618,6 @@ async fn exec_windows_sandbox(
.is_some_and(|overrides| overrides.read_roots_include_platform_defaults);
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)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let spawn_res = tokio::task::spawn_blocking(move || {
if use_elevated {
run_windows_sandbox_capture_elevated(
@@ -645,11 +635,12 @@ async fn exec_windows_sandbox(
read_roots_include_platform_defaults:
elevated_read_roots_include_platform_defaults,
write_roots_override: elevated_write_roots_override.as_deref(),
deny_write_paths_override: &elevated_deny_write_paths,
deny_read_paths_override: &additional_deny_read_paths,
deny_write_paths_override: &additional_deny_write_paths,
},
)
} else {
run_windows_sandbox_capture_with_extra_deny_write_paths(
run_windows_sandbox_capture_with_filesystem_overrides(
policy_str.as_str(),
&sandbox_cwd,
codex_home.as_ref(),
@@ -657,6 +648,7 @@ async fn exec_windows_sandbox(
&cwd,
env,
timeout_ms,
&additional_deny_read_paths,
&additional_deny_write_paths,
windows_sandbox_private_desktop,
)
@@ -1049,22 +1041,24 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
));
}
if !file_system_sandbox_policy.has_full_disk_read_access() {
// The restricted-token backend can still enforce split write restrictions,
// but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs
// participate in read access checks. Read restrictions therefore require the
// elevated backend, even when the filesystem root remains readable.
if !windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd) {
return Err(
"windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed"
.to_string(),
);
}
if !file_system_sandbox_policy
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
.is_empty()
|| !file_system_sandbox_policy
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
.is_empty()
{
let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths(
file_system_sandbox_policy,
sandbox_policy_cwd,
)?;
if !additional_deny_read_paths.is_empty() {
return Err(
"windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
"windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed"
.to_string(),
);
}
@@ -1131,7 +1125,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
}
}
if additional_deny_write_paths.is_empty() {
if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() {
return Ok(None);
}
@@ -1139,6 +1133,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
read_roots_override: None,
read_roots_include_platform_defaults: false,
write_roots_override: None,
additional_deny_read_paths,
additional_deny_write_paths: additional_deny_write_paths
.into_iter()
.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,
.map_err(|err| err.to_string())
}
fn windows_policy_has_root_read_access(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &AbsolutePathBuf,
) -> bool {
let Some(root) = cwd.as_path().ancestors().last() else {
return false;
};
file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path())
}
pub(crate) fn resolve_windows_elevated_filesystem_overrides(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
@@ -1175,18 +1180,10 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
));
}
if !file_system_sandbox_policy
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
.is_empty()
|| !file_system_sandbox_policy
.get_unreadable_globs_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 additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths(
file_system_sandbox_policy,
sandbox_policy_cwd,
)?;
let split_writable_roots =
file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
@@ -1217,7 +1214,13 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
.collect();
let split_root_path_set: BTreeSet<PathBuf> = split_root_paths.iter().cloned().collect();
let read_roots_override = if file_system_sandbox_policy.has_full_disk_read_access() {
// `has_full_disk_read_access()` is intentionally false when deny-read
// entries exist. For Windows setup overrides, the important question is
// whether the baseline still reads from the filesystem root and only needs
// additional deny ACLs layered on top.
let split_has_root_read_access =
windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd);
let read_roots_override = if split_has_root_read_access {
None
} else {
Some(split_readable_roots)
@@ -1265,6 +1268,7 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
if read_roots_override.is_none()
&& write_roots_override.is_none()
&& additional_deny_read_paths.is_empty()
&& additional_deny_write_paths.is_empty()
{
return Ok(None);
@@ -1275,6 +1279,7 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
&& file_system_sandbox_policy.include_platform_defaults(),
read_roots_override,
write_roots_override,
additional_deny_read_paths,
additional_deny_write_paths,
}))
}

View File

@@ -662,11 +662,63 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
read_roots_override: None,
read_roots_include_platform_defaults: false,
write_roots_override: None,
additional_deny_read_paths: vec![],
additional_deny_write_paths: expected_deny_write_paths,
}))
);
}
#[test]
fn windows_restricted_token_rejects_unreadable_split_carveouts() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let cwd = dunce::canonicalize(temp_dir.path())
.expect("canonicalize temp dir")
.abs();
let blocked = cwd.join("blocked");
std::fs::create_dir_all(blocked.as_path()).expect("create blocked");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
/*subpath*/ None,
),
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path { path: blocked },
access: codex_protocol::permissions::FileSystemAccessMode::None,
},
]);
assert_eq!(
resolve_windows_restricted_token_filesystem_overrides(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
&cwd,
WindowsSandboxLevel::RestrictedToken,
),
Err(
"windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed"
.to_string()
)
);
}
#[test]
fn windows_elevated_supports_split_restricted_read_roots() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
@@ -699,6 +751,7 @@ fn windows_elevated_supports_split_restricted_read_roots() {
read_roots_override: Some(vec![expected_docs]),
read_roots_include_platform_defaults: false,
write_roots_override: None,
additional_deny_read_paths: vec![],
additional_deny_write_paths: vec![],
}))
);
@@ -753,6 +806,7 @@ fn windows_elevated_supports_split_write_read_carveouts() {
read_roots_override: None,
read_roots_include_platform_defaults: false,
write_roots_override: None,
additional_deny_read_paths: vec![],
additional_deny_write_paths: vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(expected_docs)
.expect("absolute docs"),
@@ -762,10 +816,11 @@ fn windows_elevated_supports_split_write_read_carveouts() {
}
#[test]
fn windows_elevated_rejects_unreadable_split_carveouts() {
fn windows_elevated_supports_unreadable_split_carveouts() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let blocked = temp_dir.path().join("blocked");
std::fs::create_dir_all(&blocked).expect("create blocked");
let expected_blocked = dunce::canonicalize(&blocked).expect("canonical blocked");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
@@ -797,24 +852,38 @@ fn windows_elevated_rejects_unreadable_split_carveouts() {
]);
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
resolve_windows_elevated_filesystem_overrides(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
&temp_dir.path().abs(),
WindowsSandboxLevel::Elevated,
/*use_windows_elevated_backend*/ true,
),
Some(
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
.to_string()
)
Ok(Some(WindowsSandboxFilesystemOverrides {
read_roots_override: None,
read_roots_include_platform_defaults: false,
write_roots_override: None,
additional_deny_read_paths: vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(
expected_blocked.clone(),
)
.expect("absolute blocked"),
],
additional_deny_write_paths: vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(expected_blocked)
.expect("absolute blocked"),
],
}))
);
}
#[test]
fn windows_elevated_rejects_unreadable_globs() {
fn windows_elevated_supports_unreadable_globs() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let secret = temp_dir.path().join("app").join(".env");
std::fs::create_dir_all(secret.parent().expect("parent")).expect("create parent");
std::fs::write(&secret, "secret").expect("write secret");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
@@ -845,18 +914,24 @@ fn windows_elevated_rejects_unreadable_globs() {
]);
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
resolve_windows_elevated_filesystem_overrides(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
&temp_dir.path().abs(),
WindowsSandboxLevel::Elevated,
/*use_windows_elevated_backend*/ true,
),
Some(
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
.to_string()
)
Ok(Some(WindowsSandboxFilesystemOverrides {
read_roots_override: None,
read_roots_include_platform_defaults: false,
write_roots_override: None,
additional_deny_read_paths: vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(secret)
.expect("absolute secret"),
],
additional_deny_write_paths: vec![],
}))
);
}

View File

@@ -875,6 +875,28 @@ impl UnifiedExecProcessManager {
"windows sandbox: failed to resolve codex_home: {err}"
))
})?;
let additional_deny_write_paths = request
.windows_sandbox_filesystem_overrides
.as_ref()
.map(|overrides| overrides.additional_deny_write_paths.clone())
.unwrap_or_default();
let additional_deny_read_paths = request
.windows_sandbox_filesystem_overrides
.as_ref()
.map(|overrides| overrides.additional_deny_read_paths.clone())
.unwrap_or_default();
let elevated_read_roots_override = request
.windows_sandbox_filesystem_overrides
.as_ref()
.and_then(|overrides| overrides.read_roots_override.clone());
let elevated_read_roots_include_platform_defaults = request
.windows_sandbox_filesystem_overrides
.as_ref()
.is_some_and(|overrides| overrides.read_roots_include_platform_defaults);
let elevated_write_roots_override = request
.windows_sandbox_filesystem_overrides
.as_ref()
.and_then(|overrides| overrides.write_roots_override.clone());
let spawned = match request.windows_sandbox_level {
codex_protocol::config_types::WindowsSandboxLevel::Elevated => {
codex_windows_sandbox::spawn_windows_sandbox_session_elevated(
@@ -885,6 +907,11 @@ impl UnifiedExecProcessManager {
request.cwd.as_path(),
request.env.clone(),
None,
elevated_read_roots_override.as_deref(),
elevated_read_roots_include_platform_defaults,
elevated_write_roots_override.as_deref(),
&additional_deny_read_paths,
&additional_deny_write_paths,
tty,
tty,
request.windows_sandbox_private_desktop,
@@ -901,6 +928,8 @@ impl UnifiedExecProcessManager {
request.cwd.as_path(),
request.env.clone(),
None,
&additional_deny_read_paths,
&additional_deny_write_paths,
tty,
tty,
request.windows_sandbox_private_desktop,

View File

@@ -114,3 +114,5 @@ mod view_image;
mod web_search;
mod websocket_fallback;
mod window_headers;
#[cfg(target_os = "windows")]
mod windows_sandbox;

View File

@@ -0,0 +1,243 @@
use anyhow::Context;
use codex_core::exec::ExecCapturePolicy;
use codex_core::exec::ExecParams;
use codex_core::exec::process_exec_tool_call;
use codex_core::sandboxing::SandboxPermissions;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use core_test_support::PathExt;
use pretty_assertions::assert_eq;
use serial_test::serial;
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::Path;
use tempfile::TempDir;
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &std::ffi::OsStr) -> Self {
let original = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
fn stage_windows_sandbox_helpers() -> anyhow::Result<()> {
let test_exe = std::env::current_exe().context("resolve current Windows test executable")?;
let test_exe_dir = test_exe
.parent()
.context("Windows test executable should have a parent directory")?;
let resources_dir = test_exe_dir.join("codex-resources");
std::fs::create_dir_all(&resources_dir)?;
for helper_name in ["codex-windows-sandbox-setup", "codex-command-runner"] {
let helper = codex_utils_cargo_bin::cargo_bin(helper_name)?;
let file_name = Path::new(helper_name).with_extension("exe");
std::fs::copy(helper, resources_dir.join(file_name))?;
}
Ok(())
}
#[tokio::test]
#[serial(codex_home)]
async fn windows_restricted_token_rejects_exact_and_glob_deny_read_policy() -> anyhow::Result<()> {
let temp_home = TempDir::new()?;
let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", temp_home.path().as_os_str());
let workspace = TempDir::new()?;
let cwd = dunce::canonicalize(workspace.path())?.abs();
let secret = cwd.join("secret.env");
let future_secret = cwd.join("future.env");
let public = cwd.join("public.txt");
std::fs::write(&secret, "glob secret\n")?;
std::fs::write(&public, "public ok\n")?;
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: future_secret,
},
access: FileSystemAccessMode::None,
},
]);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
let err = process_exec_tool_call(
ExecParams {
command: vec![
"cmd.exe".to_string(),
"/D".to_string(),
"/C".to_string(),
"type secret.env >NUL 2>NUL & echo exact secret 1>future.env 2>NUL & type future.env 2>NUL & type public.txt & exit /B 0"
.to_string(),
],
cwd: cwd.clone(),
expiration: 10_000.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env: HashMap::new(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,
},
&permission_profile,
&cwd,
&None,
/*use_legacy_landlock*/ false,
/*stdout_stream*/ None,
)
.await
.expect_err("restricted-token sandbox should reject deny-read restrictions");
assert_eq!(
err.to_string(),
"unsupported operation: windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed"
);
Ok(())
}
#[tokio::test]
#[serial(codex_home)]
async fn windows_elevated_enforces_exact_and_glob_deny_read_policy() -> anyhow::Result<()> {
let temp_home = TempDir::new()?;
let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", temp_home.path().as_os_str());
stage_windows_sandbox_helpers()?;
let workspace = TempDir::new()?;
let cwd = dunce::canonicalize(workspace.path())?.abs();
let glob_secret = cwd.join("secret.env");
let exact_secret = cwd.join("exact-secret.txt");
let public = cwd.join("public.txt");
std::fs::write(&glob_secret, "glob secret\n")?;
std::fs::write(&exact_secret, "exact secret\n")?;
std::fs::write(&public, "public ok\n")?;
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: exact_secret },
access: FileSystemAccessMode::None,
},
]);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
let ExecToolCallOutput {
exit_code,
stdout,
stderr,
..
} = process_exec_tool_call(
ExecParams {
command: vec![
"cmd.exe".to_string(),
"/D".to_string(),
"/C".to_string(),
"(type secret.env 1>NUL 2>NUL && echo GLOB-READ || echo GLOB-DENIED) & (type exact-secret.txt 1>NUL 2>NUL && echo EXACT-READ || echo EXACT-DENIED) & type public.txt".to_string(),
],
cwd: cwd.clone(),
expiration: 10_000.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env: HashMap::new(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Elevated,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,
},
&permission_profile,
&cwd,
&None,
/*use_legacy_landlock*/ false,
/*stdout_stream*/ None,
)
.await?;
assert_eq!(exit_code, 0, "sandboxed command should complete");
assert!(
stdout.text.contains("GLOB-DENIED"),
"glob deny-read should block the secret: {stdout:?}"
);
assert!(
!stdout.text.contains("GLOB-READ"),
"glob deny-read should not allow the secret: {stdout:?}"
);
assert!(
stdout.text.contains("EXACT-DENIED"),
"exact deny-read should block the secret: {stdout:?}"
);
assert!(
!stdout.text.contains("EXACT-READ"),
"exact deny-read should not allow the secret: {stdout:?}"
);
assert!(
stdout.text.contains("public ok"),
"allowed reads should still work: {stdout:?}"
);
assert_eq!(stderr.text, "");
Ok(())
}

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(

View File

@@ -75,10 +75,25 @@ mod tests {
#[tokio::test]
async fn test_unix_executes_script_without_extension() -> Result<()> {
let env = TestExecutableEnv::new()?;
let mut cmd = Command::new(&env.program_name);
cmd.envs(&env.mcp_env);
// Linux can transiently report ETXTBSY while the freshly written test
// script is becoming executable on the backing filesystem.
let mut retries = 0;
let output = loop {
let mut cmd = Command::new(&env.program_name);
cmd.envs(&env.mcp_env);
let output = cmd.output().await;
if !output
.as_ref()
.is_err_and(|err| err.kind() == std::io::ErrorKind::ExecutableFileBusy)
|| retries == 2
{
break output;
}
retries += 1;
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
};
let output = cmd.output().await;
assert!(
output.is_ok(),
"Unix should execute PATH-resolved scripts directly: {output:?}"

View File

@@ -9,6 +9,7 @@ use windows_sys::Win32::Foundation::HLOCAL;
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE;
use windows_sys::Win32::Security::ACCESS_DENIED_ACE;
use windows_sys::Win32::Security::ACE_HEADER;
use windows_sys::Win32::Security::ACL;
use windows_sys::Win32::Security::ACL_SIZE_INFORMATION;
@@ -48,6 +49,9 @@ use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING;
use windows_sys::Win32::Storage::FileSystem::READ_CONTROL;
const SE_KERNEL_OBJECT: u32 = 6;
const INHERIT_ONLY_ACE: u8 = 0x08;
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
const ACCESS_DENIED_ACE_TYPE: u8 = 1;
const GENERIC_READ_MASK: u32 = 0x8000_0000;
const GENERIC_WRITE_MASK: u32 = 0x4000_0000;
const DENY_ACCESS: i32 = 3;
@@ -125,7 +129,7 @@ pub unsafe fn dacl_mask_allows(
continue;
}
let hdr = &*(p_ace as *const ACE_HEADER);
if hdr.AceType != 0 {
if hdr.AceType != ACCESS_ALLOWED_ACE_TYPE {
continue; // not ACCESS_ALLOWED
}
if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 {
@@ -194,7 +198,7 @@ pub unsafe fn dacl_has_write_allow_for_sid(p_dacl: *mut ACL, psid: *mut c_void)
continue;
}
let hdr = &*(p_ace as *const ACE_HEADER);
if hdr.AceType != 0 {
if hdr.AceType != ACCESS_ALLOWED_ACE_TYPE {
continue; // ACCESS_ALLOWED_ACE_TYPE
}
// Ignore ACEs that are inherit-only (do not apply to the current object)
@@ -242,13 +246,13 @@ pub unsafe fn dacl_has_write_deny_for_sid(p_dacl: *mut ACL, psid: *mut c_void) -
continue;
}
let hdr = &*(p_ace as *const ACE_HEADER);
if hdr.AceType != 1 {
if hdr.AceType != ACCESS_DENIED_ACE_TYPE {
continue; // ACCESS_DENIED_ACE_TYPE
}
if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 {
continue;
}
let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE);
let ace = &*(p_ace as *const ACCESS_DENIED_ACE);
let base = p_ace as usize;
let sid_ptr =
(base + std::mem::size_of::<ACE_HEADER>() + std::mem::size_of::<u32>()) as *mut c_void;
@@ -259,6 +263,44 @@ pub unsafe fn dacl_has_write_deny_for_sid(p_dacl: *mut ACL, psid: *mut c_void) -
false
}
pub unsafe fn dacl_has_read_deny_for_sid(p_dacl: *mut ACL, psid: *mut c_void) -> bool {
if p_dacl.is_null() {
return false;
}
let mut info: ACL_SIZE_INFORMATION = std::mem::zeroed();
let ok = GetAclInformation(
p_dacl as *const ACL,
&mut info as *mut _ as *mut c_void,
std::mem::size_of::<ACL_SIZE_INFORMATION>() as u32,
AclSizeInformation,
);
if ok == 0 {
return false;
}
let deny_read_mask = FILE_GENERIC_READ | GENERIC_READ_MASK;
for i in 0..info.AceCount {
let mut p_ace: *mut c_void = std::ptr::null_mut();
if GetAce(p_dacl as *const ACL, i, &mut p_ace) == 0 {
continue;
}
let hdr = &*(p_ace as *const ACE_HEADER);
if hdr.AceType != ACCESS_DENIED_ACE_TYPE {
continue; // ACCESS_DENIED_ACE_TYPE
}
if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 {
continue;
}
let ace = &*(p_ace as *const ACCESS_DENIED_ACE);
let base = p_ace as usize;
let sid_ptr =
(base + std::mem::size_of::<ACE_HEADER>() + std::mem::size_of::<u32>()) as *mut c_void;
if EqualSid(sid_ptr, psid) != 0 && (ace.Mask & deny_read_mask) != 0 {
return true;
}
}
false
}
const WRITE_ALLOW_MASK: u32 =
FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD;
@@ -445,6 +487,41 @@ pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result<bool> {
/// # Safety
/// Caller must ensure `psid` points to a valid SID and `path` refers to an existing file or directory.
pub unsafe fn add_deny_write_ace(path: &Path, psid: *mut c_void) -> Result<bool> {
add_deny_ace(path, psid, DenyAceKind::Write)
}
#[derive(Clone, Copy)]
enum DenyAceKind {
Read,
Write,
}
impl DenyAceKind {
fn mask(self) -> u32 {
match self {
Self::Read => FILE_GENERIC_READ | GENERIC_READ_MASK,
Self::Write => {
FILE_GENERIC_WRITE
| FILE_WRITE_DATA
| FILE_APPEND_DATA
| FILE_WRITE_EA
| FILE_WRITE_ATTRIBUTES
| GENERIC_WRITE_MASK
| DELETE
| FILE_DELETE_CHILD
}
}
}
unsafe fn already_present(self, p_dacl: *mut ACL, psid: *mut c_void) -> bool {
match self {
Self::Read => dacl_has_read_deny_for_sid(p_dacl, psid),
Self::Write => dacl_has_write_deny_for_sid(p_dacl, psid),
}
}
}
unsafe fn add_deny_ace(path: &Path, psid: *mut c_void, kind: DenyAceKind) -> Result<bool> {
let mut p_sd: *mut c_void = std::ptr::null_mut();
let mut p_dacl: *mut ACL = std::ptr::null_mut();
let code = GetNamedSecurityInfoW(
@@ -461,7 +538,7 @@ pub unsafe fn add_deny_write_ace(path: &Path, psid: *mut c_void) -> Result<bool>
return Err(anyhow!("GetNamedSecurityInfoW failed: {code}"));
}
let mut added = false;
if !dacl_has_write_deny_for_sid(p_dacl, psid) {
if !kind.already_present(p_dacl, psid) {
let trustee = TRUSTEE_W {
pMultipleTrustee: std::ptr::null_mut(),
MultipleTrusteeOperation: 0,
@@ -470,14 +547,7 @@ pub unsafe fn add_deny_write_ace(path: &Path, psid: *mut c_void) -> Result<bool>
ptstrName: psid as *mut u16,
};
let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed();
explicit.grfAccessPermissions = FILE_GENERIC_WRITE
| FILE_WRITE_DATA
| FILE_APPEND_DATA
| FILE_WRITE_EA
| FILE_WRITE_ATTRIBUTES
| GENERIC_WRITE_MASK
| DELETE
| FILE_DELETE_CHILD;
explicit.grfAccessPermissions = kind.mask();
explicit.grfAccessMode = DENY_ACCESS;
explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE;
explicit.Trustee = trustee;
@@ -507,6 +577,19 @@ pub unsafe fn add_deny_write_ace(path: &Path, psid: *mut c_void) -> Result<bool>
Ok(added)
}
/// Adds a deny ACE to prevent reads for the given SID on the target path.
///
/// `SetEntriesInAclW` places newly-created deny ACEs before allow ACEs, which
/// keeps the resulting DACL in the order Windows expects for denies to win.
/// The ACE is inheritable so a deny applied to a materialized directory also
/// covers files and directories later created underneath it.
///
/// # Safety
/// Caller must ensure `psid` points to a valid SID and `path` refers to an existing file or directory.
pub unsafe fn add_deny_read_ace(path: &Path, psid: *mut c_void) -> Result<bool> {
add_deny_ace(path, psid, DenyAceKind::Read)
}
pub unsafe fn revoke_ace(path: &Path, psid: *mut c_void) {
let mut p_sd: *mut c_void = std::ptr::null_mut();
let mut p_dacl: *mut ACL = std::ptr::null_mut();

View File

@@ -27,6 +27,7 @@ use codex_windows_sandbox::sandbox_bin_dir;
use codex_windows_sandbox::sandbox_dir;
use codex_windows_sandbox::sandbox_secrets_dir;
use codex_windows_sandbox::string_from_sid_bytes;
use codex_windows_sandbox::sync_persistent_deny_read_acls;
use codex_windows_sandbox::to_wide;
use codex_windows_sandbox::workspace_cap_sid_for_cwd;
use codex_windows_sandbox::write_setup_error_report;
@@ -85,6 +86,8 @@ struct Payload {
read_roots: Vec<PathBuf>,
write_roots: Vec<PathBuf>,
#[serde(default)]
deny_read_paths: Vec<PathBuf>,
#[serde(default)]
deny_write_paths: Vec<PathBuf>,
proxy_ports: Vec<u16>,
#[serde(default)]
@@ -460,39 +463,43 @@ fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> {
let sandbox_group_sid = resolve_sandbox_users_group_sid()?;
let sandbox_group_psid = sid_bytes_to_psid(&sandbox_group_sid)?;
let mut refresh_errors: Vec<String> = Vec::new();
let users_sid = resolve_sid("Users")?;
let users_psid = sid_bytes_to_psid(&users_sid)?;
let auth_sid = resolve_sid("Authenticated Users")?;
let auth_psid = sid_bytes_to_psid(&auth_sid)?;
let everyone_sid = resolve_sid("Everyone")?;
let everyone_psid = sid_bytes_to_psid(&everyone_sid)?;
let rx_psids = vec![users_psid, auth_psid, everyone_psid];
let subjects = ReadAclSubjects {
sandbox_group_psid,
rx_psids: &rx_psids,
};
apply_read_acls(
&payload.read_roots,
&subjects,
log,
&mut refresh_errors,
FILE_GENERIC_READ | FILE_GENERIC_EXECUTE,
"read",
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
)?;
if !payload.read_roots.is_empty() {
let users_sid = resolve_sid("Users")?;
let users_psid = sid_bytes_to_psid(&users_sid)?;
let auth_sid = resolve_sid("Authenticated Users")?;
let auth_psid = sid_bytes_to_psid(&auth_sid)?;
let everyone_sid = resolve_sid("Everyone")?;
let everyone_psid = sid_bytes_to_psid(&everyone_sid)?;
let rx_psids = vec![users_psid, auth_psid, everyone_psid];
let subjects = ReadAclSubjects {
sandbox_group_psid,
rx_psids: &rx_psids,
};
apply_read_acls(
&payload.read_roots,
&subjects,
log,
&mut refresh_errors,
FILE_GENERIC_READ | FILE_GENERIC_EXECUTE,
"read",
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
)?;
unsafe {
if !users_psid.is_null() {
LocalFree(users_psid as HLOCAL);
}
if !auth_psid.is_null() {
LocalFree(auth_psid as HLOCAL);
}
if !everyone_psid.is_null() {
LocalFree(everyone_psid as HLOCAL);
}
}
}
unsafe {
if !sandbox_group_psid.is_null() {
LocalFree(sandbox_group_psid as HLOCAL);
}
if !users_psid.is_null() {
LocalFree(users_psid as HLOCAL);
}
if !auth_psid.is_null() {
LocalFree(auth_psid as HLOCAL);
}
if !everyone_psid.is_null() {
LocalFree(everyone_psid as HLOCAL);
}
}
if !refresh_errors.is_empty() {
log_line(
@@ -556,6 +563,8 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
format!("convert sandbox users group SID to PSID failed: {err}"),
))
})?;
let sandbox_group_sid_str =
string_from_sid_bytes(&sandbox_group_sid).map_err(anyhow::Error::msg)?;
let caps = load_or_create_cap_sids(&payload.codex_home).map_err(|err| {
anyhow::Error::new(SetupFailure::new(
@@ -613,6 +622,25 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
);
}
// Deny-read ACEs must be present before the sandboxed command starts. Apply
// them synchronously here instead of delegating them to the background
// helper used for read grants.
let applied_deny_read_paths = unsafe {
sync_persistent_deny_read_acls(
&payload.codex_home,
&sandbox_group_sid_str,
&payload.deny_read_paths,
sandbox_group_psid,
)
}
.context("apply deny-read ACLs")?;
if !applied_deny_read_paths.is_empty() {
log_line(
log,
&format!("applied {} deny-read ACLs", applied_deny_read_paths.len()),
)?;
}
if payload.read_roots.is_empty() {
log_line(log, "no read roots to grant; skipping read ACL helper")?;
} else {
@@ -654,8 +682,6 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
}
let cap_sid_str = caps.workspace;
let sandbox_group_sid_str =
string_from_sid_bytes(&sandbox_group_sid).map_err(anyhow::Error::msg)?;
let write_mask =
FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD;
let mut grant_tasks: Vec<PathBuf> = Vec::new();

View File

@@ -0,0 +1,119 @@
use crate::acl::add_deny_read_ace;
use crate::acl::revoke_ace;
use crate::path_normalization::canonicalize_path;
use anyhow::Context;
use anyhow::Result;
use std::collections::HashSet;
use std::ffi::c_void;
use std::path::Path;
use std::path::PathBuf;
/// Build the exact ACL paths that should receive a deny-read ACE.
///
/// We keep both the lexical policy path and, when it already exists, the
/// canonical target. The lexical path covers the path users configured and lets
/// missing exact denies be materialized later; the canonical path also covers
/// an existing reparse-point target so a sandbox cannot read the same object
/// through the resolved location.
pub fn plan_deny_read_acl_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut planned = Vec::new();
let mut seen = HashSet::new();
for path in paths {
push_planned_path(&mut planned, &mut seen, path.to_path_buf());
if path.exists() {
push_planned_path(&mut planned, &mut seen, canonicalize_path(path));
}
}
planned
}
fn push_planned_path(planned: &mut Vec<PathBuf>, seen: &mut HashSet<String>, path: PathBuf) {
if seen.insert(lexical_path_key(&path)) {
planned.push(path);
}
}
pub(crate) fn lexical_path_key(path: &Path) -> String {
path.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_ascii_lowercase()
}
/// Applies deny-read ACEs to explicit paths. Missing paths are materialized as
/// directories before the ACE is applied so a sandboxed command cannot create a
/// previously absent denied path and then read from it in the same run.
/// If any path fails, deny ACEs applied by this call are revoked before the
/// error is returned so a one-shot sandbox run does not leave partial state.
///
/// # Safety
/// Caller must pass a valid SID pointer for the sandbox principal being denied.
pub unsafe fn apply_deny_read_acls(paths: &[PathBuf], psid: *mut c_void) -> Result<Vec<PathBuf>> {
let planned = plan_deny_read_acl_paths(paths);
let mut applied = Vec::new();
let mut seen = HashSet::new();
let mut added_in_this_call: Vec<PathBuf> = Vec::new();
for path in planned {
let result = (|| -> Result<bool> {
if !path.exists() {
std::fs::create_dir_all(&path)
.with_context(|| format!("create deny-read path {}", path.display()))?;
}
add_deny_read_ace(&path, psid)
.with_context(|| format!("apply deny-read ACE to {}", path.display()))
})();
let added = match result {
Ok(added) => added,
Err(err) => {
for added_path in &added_in_this_call {
revoke_ace(added_path, psid);
}
return Err(err);
}
};
if added {
added_in_this_call.push(path.clone());
}
push_planned_path(&mut applied, &mut seen, path);
}
Ok(applied)
}
#[cfg(test)]
mod tests {
use super::plan_deny_read_acl_paths;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn plan_preserves_missing_paths() {
let tmp = TempDir::new().expect("tempdir");
let missing = tmp.path().join("future-secret.env");
assert_eq!(
plan_deny_read_acl_paths(std::slice::from_ref(&missing)),
vec![missing]
);
}
#[test]
fn plan_includes_existing_canonical_targets() {
let tmp = TempDir::new().expect("tempdir");
let existing = tmp.path().join("secret.env");
std::fs::write(&existing, "secret").expect("write secret");
let planned: HashSet<PathBuf> = plan_deny_read_acl_paths(std::slice::from_ref(&existing))
.into_iter()
.collect();
let expected: HashSet<PathBuf> = [
existing.clone(),
dunce::canonicalize(&existing).expect("canonical path"),
]
.into_iter()
.collect();
assert_eq!(planned, expected);
}
}

View File

@@ -0,0 +1,381 @@
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::ReadDenyMatcher;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
struct GlobScanPlan {
root: PathBuf,
max_depth: Option<usize>,
}
/// Resolve split filesystem `None` read entries into concrete Windows ACL targets.
///
/// Windows ACLs do not understand Codex filesystem glob patterns directly. Exact
/// unreadable roots can be passed through as-is, including paths that do not
/// exist yet. Glob entries are snapshot-expanded to the files/directories that
/// already exist under their literal scan root; future exact paths are handled
/// later by materializing them before the deny ACE is applied.
pub fn resolve_windows_deny_read_paths(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &AbsolutePathBuf,
) -> Result<Vec<AbsolutePathBuf>, String> {
let mut paths = Vec::new();
let mut seen = HashSet::new();
for path in file_system_sandbox_policy.get_unreadable_roots_with_cwd(cwd.as_path()) {
push_absolute_path(&mut paths, &mut seen, path.into_path_buf())?;
}
let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd.as_path());
if unreadable_globs.is_empty() {
return Ok(paths);
}
let glob_policy = FileSystemSandboxPolicy::restricted(
unreadable_globs
.iter()
.map(|pattern| FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: pattern.clone(),
},
access: FileSystemAccessMode::None,
})
.collect(),
);
let Some(matcher) = ReadDenyMatcher::try_new(&glob_policy, cwd.as_path())? else {
return Ok(paths);
};
for pattern in unreadable_globs {
let mut seen_scan_dirs = HashSet::new();
let scan_plan = glob_scan_plan(&pattern, file_system_sandbox_policy.glob_scan_max_depth);
collect_existing_glob_matches(
&scan_plan.root,
&matcher,
&mut paths,
&mut seen,
&mut seen_scan_dirs,
scan_plan.max_depth,
/*depth*/ 0,
)?;
}
Ok(paths)
}
fn collect_existing_glob_matches(
path: &Path,
matcher: &ReadDenyMatcher,
paths: &mut Vec<AbsolutePathBuf>,
seen_paths: &mut HashSet<PathBuf>,
seen_scan_dirs: &mut HashSet<PathBuf>,
max_depth: Option<usize>,
depth: usize,
) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
if matcher.is_read_denied(path) {
push_absolute_path(paths, seen_paths, path.to_path_buf())?;
}
let Ok(metadata) = path.metadata() else {
return Ok(());
};
if !metadata.is_dir() {
return Ok(());
}
// Canonical directory keys keep recursive scans from following a symlink or
// junction cycle forever while preserving the original matched path for the
// ACL layer.
let scan_key = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if !seen_scan_dirs.insert(scan_key) {
return Ok(());
}
if max_depth.is_some_and(|max_depth| depth >= max_depth) {
return Ok(());
}
let Ok(entries) = std::fs::read_dir(path) else {
return Ok(());
};
for entry in entries.flatten() {
collect_existing_glob_matches(
&entry.path(),
matcher,
paths,
seen_paths,
seen_scan_dirs,
max_depth,
depth + 1,
)?;
}
Ok(())
}
fn push_absolute_path(
paths: &mut Vec<AbsolutePathBuf>,
seen: &mut HashSet<PathBuf>,
path: PathBuf,
) -> Result<(), String> {
let absolute_path = AbsolutePathBuf::from_absolute_path(dunce::simplified(&path))
.map_err(|err| err.to_string())?;
if seen.insert(absolute_path.to_path_buf()) {
paths.push(absolute_path);
}
Ok(())
}
fn glob_scan_plan(pattern: &str, configured_max_depth: Option<usize>) -> GlobScanPlan {
// Start scanning at the deepest literal directory prefix before the first
// glob metacharacter. For example, `C:\repo\**\*.env` only scans `C:\repo`
// instead of the current directory or drive root.
let first_glob = pattern
.char_indices()
.find(|(_, ch)| matches!(ch, '*' | '?' | '['))
.map(|(index, _)| index)
.unwrap_or(pattern.len());
let literal_prefix = &pattern[..first_glob];
let Some(separator_index) = literal_prefix.rfind(['/', '\\']) else {
return GlobScanPlan {
root: PathBuf::from("."),
max_depth: effective_glob_scan_max_depth(pattern, configured_max_depth),
};
};
let pattern_suffix = &pattern[separator_index + 1..];
let is_drive_root_separator = separator_index > 0
&& literal_prefix
.as_bytes()
.get(separator_index - 1)
.is_some_and(|ch| *ch == b':');
if separator_index == 0 || is_drive_root_separator {
return GlobScanPlan {
root: PathBuf::from(&literal_prefix[..=separator_index]),
max_depth: effective_glob_scan_max_depth(pattern_suffix, configured_max_depth),
};
}
GlobScanPlan {
root: PathBuf::from(literal_prefix[..separator_index].to_string()),
max_depth: effective_glob_scan_max_depth(pattern_suffix, configured_max_depth),
}
}
fn effective_glob_scan_max_depth(
pattern_suffix: &str,
configured_max_depth: Option<usize>,
) -> Option<usize> {
let components = pattern_suffix
.split(['/', '\\'])
.filter(|component| !component.is_empty())
.collect::<Vec<_>>();
if components.contains(&"**") {
return configured_max_depth;
}
Some(configured_max_depth.map_or(components.len(), |max_depth| {
max_depth.min(components.len())
}))
}
#[cfg(test)]
mod tests {
use super::glob_scan_plan;
use super::resolve_windows_deny_read_paths;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use std::path::PathBuf;
use tempfile::TempDir;
#[cfg(unix)]
use std::os::unix::fs::symlink;
fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern { pattern },
access: FileSystemAccessMode::None,
}
}
fn unreadable_path_entry(path: PathBuf) -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::from_absolute_path(path).expect("absolute path"),
},
access: FileSystemAccessMode::None,
}
}
#[test]
fn scan_root_uses_literal_prefix_before_glob() {
assert_eq!(
glob_scan_plan("/tmp/work/**/*.env", /*configured_max_depth*/ None).root,
PathBuf::from("/tmp/work")
);
assert_eq!(
glob_scan_plan(
r"C:\Users\dev\repo\**\*.env",
/*configured_max_depth*/ None,
)
.root,
PathBuf::from(r"C:\Users\dev\repo")
);
assert_eq!(
glob_scan_plan(r"C:\*.env", /*configured_max_depth*/ None).root,
PathBuf::from(r"C:\")
);
}
#[test]
fn scan_depth_is_bounded_for_non_recursive_globs() {
assert_eq!(
glob_scan_plan("/tmp/work/*.env", /*configured_max_depth*/ None).max_depth,
Some(1)
);
assert_eq!(
glob_scan_plan("/tmp/work/*/*.env", /*configured_max_depth*/ None).max_depth,
Some(2)
);
assert_eq!(
glob_scan_plan("/tmp/work/**/*.env", /*configured_max_depth*/ None).max_depth,
None
);
}
#[test]
fn configured_depth_caps_recursive_glob_scans() {
assert_eq!(
glob_scan_plan("/tmp/work/**/*.env", Some(2)).max_depth,
Some(2)
);
assert_eq!(
glob_scan_plan("/tmp/work/*/*.env", Some(1)).max_depth,
Some(1)
);
}
#[test]
fn exact_missing_paths_are_preserved() {
let tmp = TempDir::new().expect("tempdir");
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute cwd");
let missing = tmp.path().join("missing.env");
let policy = FileSystemSandboxPolicy::restricted(vec![unreadable_path_entry(missing)]);
assert_eq!(
resolve_windows_deny_read_paths(&policy, &cwd).expect("resolve"),
vec![
AbsolutePathBuf::from_absolute_path(
dunce::canonicalize(tmp.path())
.expect("canonical tempdir")
.join("missing.env")
)
.expect("absolute missing")
]
);
}
#[test]
fn glob_patterns_expand_to_existing_matches() {
let tmp = TempDir::new().expect("tempdir");
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute cwd");
let root_env = tmp.path().join(".env");
let nested_env = tmp.path().join("app").join(".env");
let notes = tmp.path().join("app").join("notes.txt");
std::fs::create_dir_all(notes.parent().expect("parent")).expect("create parent");
std::fs::write(&root_env, "secret").expect("write root env");
std::fs::write(&nested_env, "secret").expect("write nested env");
std::fs::write(&notes, "notes").expect("write notes");
let policy = FileSystemSandboxPolicy::restricted(vec![unreadable_glob_entry(format!(
"{}/**/*.env",
tmp.path().display()
))]);
let actual: HashSet<PathBuf> = resolve_windows_deny_read_paths(&policy, &cwd)
.expect("resolve")
.into_iter()
.map(AbsolutePathBuf::into_path_buf)
.collect();
let expected = [root_env, nested_env].into_iter().collect();
assert_eq!(actual, expected);
}
#[test]
fn invalid_glob_patterns_fail_before_expansion() {
let tmp = TempDir::new().expect("tempdir");
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute cwd");
let policy = FileSystemSandboxPolicy::restricted(vec![unreadable_glob_entry(format!(
"{}/**/[z-a]",
tmp.path().display()
))]);
let err = resolve_windows_deny_read_paths(&policy, &cwd).expect_err("invalid glob");
assert!(
err.contains("invalid deny-read glob pattern"),
"unexpected error: {err}"
);
assert!(err.contains("invalid range"), "unexpected error: {err}");
}
#[test]
fn non_recursive_globs_do_not_expand_nested_matches() {
let tmp = TempDir::new().expect("tempdir");
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute cwd");
let root_env = tmp.path().join(".env");
let nested_env = tmp.path().join("app").join(".env");
std::fs::create_dir_all(nested_env.parent().expect("parent")).expect("create parent");
std::fs::write(&root_env, "secret").expect("write root env");
std::fs::write(&nested_env, "secret").expect("write nested env");
let policy = FileSystemSandboxPolicy::restricted(vec![unreadable_glob_entry(format!(
"{}/*.env",
tmp.path().display()
))]);
assert_eq!(
resolve_windows_deny_read_paths(&policy, &cwd).expect("resolve"),
vec![AbsolutePathBuf::from_absolute_path(root_env).expect("absolute root env")]
);
}
#[cfg(unix)]
#[test]
fn aliased_glob_roots_each_preserve_their_lexical_matches() {
let tmp = TempDir::new().expect("tempdir");
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute cwd");
let target = tmp.path().join("target");
let alias_a = tmp.path().join("alias-a");
let alias_b = tmp.path().join("alias-b");
let secret = target.join("secret.env");
std::fs::create_dir_all(&target).expect("create target");
std::fs::write(&secret, "secret").expect("write secret");
symlink(&target, &alias_a).expect("create alias a");
symlink(&target, &alias_b).expect("create alias b");
let policy = FileSystemSandboxPolicy::restricted(vec![
unreadable_glob_entry(format!("{}/**/*.env", alias_a.display())),
unreadable_glob_entry(format!("{}/**/*.env", alias_b.display())),
]);
let actual: HashSet<PathBuf> = resolve_windows_deny_read_paths(&policy, &cwd)
.expect("resolve")
.into_iter()
.map(AbsolutePathBuf::into_path_buf)
.collect();
let expected = [alias_a.join("secret.env"), alias_b.join("secret.env")]
.into_iter()
.collect();
assert_eq!(actual, expected);
}
}

View File

@@ -0,0 +1,87 @@
use crate::acl::revoke_ace;
use crate::deny_read_acl::apply_deny_read_acls;
use crate::deny_read_acl::lexical_path_key;
use crate::setup::sandbox_dir;
use anyhow::Context;
use anyhow::Result;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::ffi::c_void;
use std::path::Path;
use std::path::PathBuf;
const DENY_READ_ACL_STATE_FILE: &str = "deny_read_acl_state.json";
#[derive(Default, Deserialize, Serialize)]
struct PersistentDenyReadAclState {
principals: BTreeMap<String, Vec<PathBuf>>,
}
/// Reconciles the persistent deny-read ACEs owned by one sandbox principal.
///
/// Workspace-write and elevated sandbox sessions intentionally leave ACLs in
/// place after a command exits, because descendants may outlive the launcher.
/// That makes the ACL set stateful across runs. Persist the paths applied for
/// each SID, apply the new desired set first, and only then revoke stale paths
/// from the same SID so profile changes do not leave old deny-read ACEs behind.
///
/// # Safety
/// Caller must pass a valid SID pointer matching `principal_sid`.
pub unsafe fn sync_persistent_deny_read_acls(
codex_home: &Path,
principal_sid: &str,
desired_paths: &[PathBuf],
psid: *mut c_void,
) -> Result<Vec<PathBuf>> {
let state_path = sandbox_dir(codex_home).join(DENY_READ_ACL_STATE_FILE);
let mut state = load_state(&state_path)?;
let previous_paths = state
.principals
.get(principal_sid)
.cloned()
.unwrap_or_default();
let applied_paths = unsafe { apply_deny_read_acls(desired_paths, psid) }?;
let desired_keys = applied_paths
.iter()
.map(|path| lexical_path_key(path))
.collect::<HashSet<_>>();
for path in previous_paths {
if !desired_keys.contains(&lexical_path_key(&path)) {
revoke_ace(&path, psid);
}
}
if applied_paths.is_empty() {
state.principals.remove(principal_sid);
} else {
state
.principals
.insert(principal_sid.to_string(), applied_paths.clone());
}
store_state(&state_path, &state)?;
Ok(applied_paths)
}
fn load_state(path: &Path) -> Result<PersistentDenyReadAclState> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.with_context(|| format!("parse deny-read ACL state {}", path.display())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Ok(PersistentDenyReadAclState::default())
}
Err(err) => {
Err(err).with_context(|| format!("read deny-read ACL state {}", path.display()))
}
}
}
fn store_state(path: &Path, state: &PersistentDenyReadAclState) -> Result<()> {
let bytes = serde_json::to_vec_pretty(state).context("serialize deny-read ACL state")?;
std::fs::write(path, bytes)
.with_context(|| format!("write deny-read ACL state {}", path.display()))
}

View File

@@ -1,3 +1,4 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -15,7 +16,8 @@ pub struct ElevatedSandboxCaptureRequest<'a> {
pub read_roots_override: Option<&'a [PathBuf]>,
pub read_roots_include_platform_defaults: bool,
pub write_roots_override: Option<&'a [PathBuf]>,
pub deny_write_paths_override: &'a [PathBuf],
pub deny_read_paths_override: &'a [AbsolutePathBuf],
pub deny_write_paths_override: &'a [AbsolutePathBuf],
}
mod windows_impl {
@@ -41,6 +43,7 @@ mod windows_impl {
use crate::sandbox_utils::inject_git_safe_directory;
use crate::token::convert_string_sid_to_sid;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Path;
pub use crate::windows_impl::CaptureResult;
@@ -63,8 +66,17 @@ mod windows_impl {
read_roots_override,
read_roots_include_platform_defaults,
write_roots_override,
deny_read_paths_override,
deny_write_paths_override,
} = request;
let deny_read_paths_override = deny_read_paths_override
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let deny_write_paths_override = deny_write_paths_override
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let policy = parse_policy(policy_json_or_preset)?;
normalize_null_device_env(&mut env_map);
ensure_non_interactive_pager(&mut env_map);
@@ -85,7 +97,8 @@ mod windows_impl {
read_roots_override,
read_roots_include_platform_defaults,
write_roots_override,
deny_write_paths_override,
&deny_read_paths_override,
&deny_write_paths_override,
proxy_enforced,
)?;
// Build capability SID for ACL grants.

View File

@@ -139,6 +139,7 @@ pub fn require_logon_sandbox_creds(
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],
proxy_enforced: bool,
) -> Result<SandboxCreds> {
@@ -201,6 +202,7 @@ pub fn require_logon_sandbox_creds(
read_roots: Some(needed_read.clone()),
read_roots_include_platform_defaults,
write_roots: Some(needed_write.clone()),
deny_read_paths: Some(deny_read_paths_override.to_vec()),
deny_write_paths: Some(deny_write_paths_override.to_vec()),
},
)?;
@@ -220,6 +222,7 @@ pub fn require_logon_sandbox_creds(
read_roots: Some(needed_read),
read_roots_include_platform_defaults,
write_roots: Some(needed_write),
deny_read_paths: Some(deny_read_paths_override.to_vec()),
deny_write_paths: Some(deny_write_paths_override.to_vec()),
},
)?;

View File

@@ -14,14 +14,14 @@ mod audit;
#[cfg(target_os = "windows")]
mod cap;
#[cfg(target_os = "windows")]
mod deny_read_acl;
#[cfg(target_os = "windows")]
mod deny_read_state;
#[cfg(target_os = "windows")]
mod desktop;
#[cfg(target_os = "windows")]
mod dpapi;
#[cfg(target_os = "windows")]
mod elevated;
#[cfg(target_os = "windows")]
mod elevated_impl;
#[cfg(target_os = "windows")]
mod env;
#[cfg(target_os = "windows")]
mod helper_materialization;
@@ -36,22 +36,10 @@ mod path_normalization;
#[cfg(target_os = "windows")]
mod policy;
#[cfg(target_os = "windows")]
mod proc_thread_attr;
#[cfg(target_os = "windows")]
mod process;
#[cfg(target_os = "windows")]
mod sandbox_utils;
#[cfg(target_os = "windows")]
mod setup;
#[cfg(target_os = "windows")]
mod setup_error;
#[cfg(target_os = "windows")]
mod spawn_prep;
#[cfg(target_os = "windows")]
mod token;
#[cfg(target_os = "windows")]
mod unified_exec;
#[cfg(target_os = "windows")]
mod wfp;
#[cfg(target_os = "windows")]
mod wfp_setup;
@@ -60,16 +48,46 @@ mod winutil;
#[cfg(target_os = "windows")]
mod workspace_acl;
mod deny_read_resolver;
#[cfg(target_os = "windows")]
mod conpty;
#[cfg(target_os = "windows")]
mod elevated;
#[cfg(target_os = "windows")]
mod elevated_impl;
#[cfg(target_os = "windows")]
mod proc_thread_attr;
#[cfg(target_os = "windows")]
mod sandbox_utils;
#[cfg(target_os = "windows")]
mod setup;
#[cfg(target_os = "windows")]
mod setup_error;
#[cfg(target_os = "windows")]
mod spawn_prep;
#[cfg(target_os = "windows")]
mod unified_exec;
#[cfg(target_os = "windows")]
pub(crate) use elevated::ipc_framed;
#[cfg(target_os = "windows")]
pub(crate) use elevated::runner_client;
#[cfg(target_os = "windows")]
pub(crate) use elevated::runner_pipe;
#[cfg(target_os = "windows")]
pub use acl::add_deny_read_ace;
#[cfg(target_os = "windows")]
pub use acl::add_deny_write_ace;
@@ -96,6 +114,13 @@ pub use conpty::ConptyInstance;
#[cfg(target_os = "windows")]
pub use conpty::spawn_conpty_process_as_user;
#[cfg(target_os = "windows")]
pub use deny_read_acl::apply_deny_read_acls;
#[cfg(target_os = "windows")]
pub use deny_read_acl::plan_deny_read_acl_paths;
pub use deny_read_resolver::resolve_windows_deny_read_paths;
#[cfg(target_os = "windows")]
pub use deny_read_state::sync_persistent_deny_read_acls;
#[cfg(target_os = "windows")]
pub use desktop::LaunchDesktop;
#[cfg(target_os = "windows")]
pub use dpapi::protect as dpapi_protect;
@@ -225,7 +250,7 @@ pub use windows_impl::CaptureResult;
#[cfg(target_os = "windows")]
pub use windows_impl::run_windows_sandbox_capture;
#[cfg(target_os = "windows")]
pub use windows_impl::run_windows_sandbox_capture_with_extra_deny_write_paths;
pub use windows_impl::run_windows_sandbox_capture_with_filesystem_overrides;
#[cfg(target_os = "windows")]
pub use windows_impl::run_windows_sandbox_legacy_preflight;
#[cfg(target_os = "windows")]
@@ -256,6 +281,8 @@ mod windows_impl {
use super::allow::compute_allow_paths;
use super::cap::load_or_create_cap_sids;
use super::cap::workspace_cap_sid_for_cwd;
use super::deny_read_acl::apply_deny_read_acls;
use super::deny_read_state::sync_persistent_deny_read_acls;
use super::logging::log_failure;
use super::logging::log_success;
use super::path_normalization::canonicalize_path;
@@ -266,7 +293,9 @@ mod windows_impl {
use super::token::convert_string_sid_to_sid;
use super::token::create_workspace_write_token_with_caps_from;
use super::workspace_acl::is_command_cwd_root;
use anyhow::Context;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::ffi::c_void;
use std::io;
@@ -331,7 +360,7 @@ mod windows_impl {
timeout_ms: Option<u64>,
use_private_desktop: bool,
) -> Result<CaptureResult> {
run_windows_sandbox_capture_with_extra_deny_write_paths(
run_windows_sandbox_capture_with_filesystem_overrides(
policy_json_or_preset,
sandbox_policy_cwd,
codex_home,
@@ -340,12 +369,13 @@ mod windows_impl {
env_map,
timeout_ms,
&[],
&[],
use_private_desktop,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_windows_sandbox_capture_with_extra_deny_write_paths(
pub fn run_windows_sandbox_capture_with_filesystem_overrides(
policy_json_or_preset: &str,
sandbox_policy_cwd: &Path,
codex_home: &Path,
@@ -353,9 +383,18 @@ mod windows_impl {
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
additional_deny_write_paths: &[PathBuf],
additional_deny_read_paths: &[AbsolutePathBuf],
additional_deny_write_paths: &[AbsolutePathBuf],
use_private_desktop: bool,
) -> Result<CaptureResult> {
let additional_deny_read_paths = additional_deny_read_paths
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let additional_deny_write_paths = additional_deny_write_paths
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let common = prepare_legacy_spawn_context(
policy_json_or_preset,
codex_home,
@@ -374,6 +413,11 @@ mod windows_impl {
"Restricted read-only access requires the elevated Windows sandbox backend"
);
}
// WRITE_RESTRICTED tokens consult restricting SIDs only for writes, so this
// backend cannot make capability-SID deny-read ACLs authoritative.
if !additional_deny_read_paths.is_empty() {
anyhow::bail!("deny-read overrides require the elevated Windows sandbox backend");
}
let caps = load_or_create_cap_sids(codex_home)?;
let (h_token, psid_generic, psid_workspace): (HANDLE, *mut c_void, Option<*mut c_void>) = unsafe {
match &policy {
@@ -424,9 +468,14 @@ mod windows_impl {
let AllowDenyPaths { allow, mut deny } =
compute_allow_paths(&policy, sandbox_policy_cwd, &current_dir, &env_map);
for path in additional_deny_write_paths {
if path.exists() {
deny.insert(path.clone());
// Explicit deny-write carveouts must already exist when the process
// starts, otherwise it could create a missing path under a writable
// parent before the deny-write ACE exists.
if !path.exists() {
std::fs::create_dir_all(&path)
.with_context(|| format!("create deny-write path {}", path.display()))?;
}
deny.insert(path.clone());
}
let canonical_cwd = canonicalize_path(&current_dir);
let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new();
@@ -457,6 +506,32 @@ mod windows_impl {
guards.push((p.clone(), psid_generic));
}
}
// Read denies are layered after allow/deny-write setup so they can
// override broad read grants for the sandbox principal without
// changing the existing write policy computation.
let applied_deny_read_paths = match if persist_aces {
sync_persistent_deny_read_acls(
codex_home,
&caps.workspace,
&additional_deny_read_paths,
psid_generic,
)
} else {
apply_deny_read_acls(&additional_deny_read_paths, psid_generic)
} {
Ok(paths) => paths,
Err(err) => {
if !persist_aces {
cleanup_acl_guards(&mut guards);
}
return Err(err);
}
};
if !persist_aces {
for path in applied_deny_read_paths {
guards.push((path, psid_generic));
}
}
allow_null_device(psid_generic);
if let Some(psid) = psid_workspace {
allow_null_device(psid);
@@ -478,6 +553,7 @@ mod windows_impl {
let created = match spawn_res {
Ok(v) => v,
Err(err) => {
cleanup_acl_guards(&mut guards);
unsafe {
CloseHandle(in_r);
CloseHandle(in_w);
@@ -586,11 +662,7 @@ mod windows_impl {
}
if !persist_aces {
unsafe {
for (p, sid) in guards {
revoke_ace(&p, sid);
}
}
cleanup_acl_guards(&mut guards);
}
Ok(CaptureResult {
exit_code,
@@ -600,6 +672,14 @@ mod windows_impl {
})
}
fn cleanup_acl_guards(guards: &mut Vec<(PathBuf, *mut c_void)>) {
unsafe {
for (p, sid) in guards.drain(..) {
revoke_ace(&p, sid);
}
}
}
pub fn run_windows_sandbox_legacy_preflight(
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,

View File

@@ -15,6 +15,7 @@ use crate::allow::compute_allow_paths;
use crate::helper_materialization::helper_bin_dir;
use crate::logging::log_note;
use crate::path_normalization::canonical_path_key;
use crate::path_normalization::canonicalize_path;
use crate::policy::SandboxPolicy;
use crate::setup_error::SetupErrorCode;
use crate::setup_error::SetupFailure;
@@ -96,6 +97,7 @@ pub struct SetupRootOverrides {
pub read_roots: Option<Vec<PathBuf>>,
pub read_roots_include_platform_defaults: bool,
pub write_roots: Option<Vec<PathBuf>>,
pub deny_read_paths: Option<Vec<PathBuf>>,
pub deny_write_paths: Option<Vec<PathBuf>>,
}
@@ -151,6 +153,7 @@ pub fn run_setup_refresh_with_extra_read_roots(
read_roots: Some(read_roots),
read_roots_include_platform_defaults: false,
write_roots: Some(Vec::new()),
deny_read_paths: None,
deny_write_paths: None,
},
)
@@ -168,6 +171,7 @@ fn run_setup_refresh_inner(
return Ok(());
}
let (read_roots, write_roots) = build_payload_roots(&request, &overrides);
let deny_read_paths = build_payload_deny_read_paths(overrides.deny_read_paths);
let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths);
let network_identity =
SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced);
@@ -180,6 +184,7 @@ fn run_setup_refresh_inner(
command_cwd: request.command_cwd.to_path_buf(),
read_roots,
write_roots,
deny_read_paths,
deny_write_paths,
proxy_ports: offline_proxy_settings.proxy_ports,
allow_local_binding: offline_proxy_settings.allow_local_binding,
@@ -418,6 +423,8 @@ struct ElevationPayload {
read_roots: Vec<PathBuf>,
write_roots: Vec<PathBuf>,
#[serde(default)]
deny_read_paths: Vec<PathBuf>,
#[serde(default)]
deny_write_paths: Vec<PathBuf>,
proxy_ports: Vec<u16>,
#[serde(default)]
@@ -720,6 +727,7 @@ pub fn run_elevated_setup(
)
})?;
let (read_roots, write_roots) = build_payload_roots(&request, &overrides);
let deny_read_paths = build_payload_deny_read_paths(overrides.deny_read_paths);
let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths);
let network_identity =
SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced);
@@ -732,6 +740,7 @@ pub fn run_elevated_setup(
command_cwd: request.command_cwd.to_path_buf(),
read_roots,
write_roots,
deny_read_paths,
deny_write_paths,
proxy_ports: offline_proxy_settings.proxy_ports,
allow_local_binding: offline_proxy_settings.allow_local_binding,
@@ -805,18 +814,18 @@ fn build_payload_deny_write_paths(
let mut deny_write_paths: Vec<PathBuf> = explicit_deny_write_paths
.unwrap_or_default()
.into_iter()
.map(|path| {
if path.exists() {
dunce::canonicalize(&path).unwrap_or(path)
} else {
path
}
})
.map(|path| canonicalize_path(&path))
.collect();
deny_write_paths.extend(allow_deny_paths.deny);
deny_write_paths
}
fn build_payload_deny_read_paths(explicit_deny_read_paths: Option<Vec<PathBuf>>) -> Vec<PathBuf> {
// Keep the configured spelling here so the ACL layer can plan both the
// lexical path and any existing canonical target for reparse-point aliases.
explicit_deny_read_paths.unwrap_or_default()
}
fn expand_user_profile_root(roots: Vec<PathBuf>) -> Vec<PathBuf> {
let Ok(user_profile) = std::env::var("USERPROFILE") else {
return roots;
@@ -1327,6 +1336,7 @@ mod tests {
read_roots: Some(vec![readable_root.clone()]),
read_roots_include_platform_defaults: true,
write_roots: None,
deny_read_paths: None,
deny_write_paths: None,
},
);
@@ -1374,6 +1384,7 @@ mod tests {
read_roots: Some(vec![readable_root.clone()]),
read_roots_include_platform_defaults: false,
write_roots: None,
deny_read_paths: None,
deny_write_paths: None,
},
);
@@ -1454,4 +1465,17 @@ mod tests {
.all(|path| roots.contains(&path))
);
}
#[test]
fn build_payload_deny_read_paths_preserves_explicit_paths() {
let tmp = TempDir::new().expect("tempdir");
let existing = tmp.path().join("secret.env");
let missing = tmp.path().join("future.env");
fs::write(&existing, "secret").expect("write existing");
assert_eq!(
super::build_payload_deny_read_paths(Some(vec![existing.clone(), missing.clone()])),
vec![existing, missing]
);
}
}

View File

@@ -5,6 +5,8 @@ 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;
@@ -25,6 +27,7 @@ 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;
@@ -211,20 +214,34 @@ pub(crate) fn allow_null_device_for_workspace_write(is_workspace_write: bool) {
}
}
#[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,
) -> Vec<PathBuf> {
let AllowDenyPaths { allow, deny } =
) -> 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)
@@ -245,6 +262,19 @@ pub(crate) fn apply_legacy_session_acl_rules(
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());
@@ -254,9 +284,10 @@ pub(crate) fn apply_legacy_session_acl_rules(
}
}
}
guards
Ok(guards)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn prepare_elevated_spawn_context(
policy_json_or_preset: &str,
sandbox_policy_cwd: &Path,
@@ -264,6 +295,11 @@ pub(crate) fn prepare_elevated_spawn_context(
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,
@@ -283,7 +319,7 @@ pub(crate) fn prepare_elevated_spawn_context(
);
let write_roots: Vec<PathBuf> = allow.into_iter().collect();
let deny_write_paths: Vec<PathBuf> = deny.into_iter().collect();
let write_roots_override = if common.is_workspace_write {
let computed_write_roots_override = if common.is_workspace_write {
Some(write_roots.as_slice())
} else {
None
@@ -294,10 +330,15 @@ pub(crate) fn prepare_elevated_spawn_context(
cwd,
env_map,
codex_home,
/*read_roots_override*/ None,
/*read_roots_include_platform_defaults*/ false,
write_roots_override,
&deny_write_paths,
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)?;

View File

@@ -10,10 +10,12 @@ use crate::ipc_framed::SpawnRequest;
use crate::runner_client::spawn_runner_transport;
use crate::spawn_prep::prepare_elevated_spawn_context;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::ProcessDriver;
use codex_utils_pty::SpawnedProcess;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -27,10 +29,23 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated(
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
read_roots_override: Option<&[PathBuf]>,
read_roots_include_platform_defaults: bool,
write_roots_override: Option<&[PathBuf]>,
deny_read_paths_override: &[AbsolutePathBuf],
deny_write_paths_override: &[AbsolutePathBuf],
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
) -> Result<SpawnedProcess> {
let deny_read_paths_override = deny_read_paths_override
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let deny_write_paths_override = deny_write_paths_override
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let elevated = prepare_elevated_spawn_context(
policy_json_or_preset,
sandbox_policy_cwd,
@@ -38,6 +53,11 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated(
cwd,
&mut env_map,
&command,
read_roots_override,
read_roots_include_platform_defaults,
write_roots_override,
&deny_read_paths_override,
&deny_write_paths_override,
)?;
let spawn_request = SpawnRequest {

View File

@@ -16,6 +16,7 @@ use crate::spawn_prep::apply_legacy_session_acl_rules;
use crate::spawn_prep::prepare_legacy_session_security;
use crate::spawn_prep::prepare_legacy_spawn_context;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::ProcessDriver;
use codex_utils_pty::SpawnedProcess;
use codex_utils_pty::TerminalSize;
@@ -287,6 +288,8 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
additional_deny_read_paths: &[AbsolutePathBuf],
additional_deny_write_paths: &[AbsolutePathBuf],
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
@@ -303,6 +306,19 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
if !common.policy.has_full_disk_read_access() {
anyhow::bail!("Restricted read-only access requires the elevated Windows sandbox backend");
}
// WRITE_RESTRICTED tokens consult restricting SIDs only for writes, so this
// backend cannot make capability-SID deny-read ACLs authoritative.
if !additional_deny_read_paths.is_empty() {
anyhow::bail!("deny-read overrides require the elevated Windows sandbox backend");
}
let additional_deny_read_paths = additional_deny_read_paths
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let additional_deny_write_paths = additional_deny_write_paths
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>();
let security = prepare_legacy_session_security(&common.policy, codex_home, cwd)?;
allow_null_device_for_workspace_write(common.is_workspace_write);
@@ -310,12 +326,16 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
let guards = apply_legacy_session_acl_rules(
&common.policy,
sandbox_policy_cwd,
codex_home,
&common.current_dir,
&env_map,
&security.psid_generic,
security.psid_workspace.as_ref(),
&security.cap_sid_str,
&additional_deny_read_paths,
&additional_deny_write_paths,
persist_aces,
);
)?;
let (writer_tx, writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (stdout_tx, stdout_rx) = broadcast::channel::<Vec<u8>>(256);

View File

@@ -10,9 +10,11 @@
mod backends;
use anyhow::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::SpawnedProcess;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
#[allow(clippy::too_many_arguments)]
pub async fn spawn_windows_sandbox_session_legacy(
@@ -23,6 +25,8 @@ pub async fn spawn_windows_sandbox_session_legacy(
cwd: &Path,
env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
additional_deny_read_paths: &[AbsolutePathBuf],
additional_deny_write_paths: &[AbsolutePathBuf],
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
@@ -35,6 +39,8 @@ pub async fn spawn_windows_sandbox_session_legacy(
cwd,
env_map,
timeout_ms,
additional_deny_read_paths,
additional_deny_write_paths,
tty,
stdin_open,
use_private_desktop,
@@ -51,6 +57,11 @@ pub async fn spawn_windows_sandbox_session_elevated(
cwd: &Path,
env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
read_roots_override: Option<&[PathBuf]>,
read_roots_include_platform_defaults: bool,
write_roots_override: Option<&[PathBuf]>,
deny_read_paths_override: &[AbsolutePathBuf],
deny_write_paths_override: &[AbsolutePathBuf],
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
@@ -63,6 +74,11 @@ pub async fn spawn_windows_sandbox_session_elevated(
cwd,
env_map,
timeout_ms,
read_roots_override,
read_roots_include_platform_defaults,
write_roots_override,
deny_read_paths_override,
deny_write_paths_override,
tty,
stdin_open,
use_private_desktop,

View File

@@ -5,6 +5,7 @@ use crate::ipc_framed::Message;
use crate::ipc_framed::decode_bytes;
use crate::ipc_framed::read_frame;
use crate::run_windows_sandbox_capture;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::ProcessDriver;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
@@ -160,6 +161,8 @@ fn legacy_non_tty_cmd_emits_output() {
cwd.as_path(),
HashMap::new(),
Some(5_000),
&[],
&[],
/*tty*/ false,
/*stdin_open*/ false,
/*use_private_desktop*/ true,
@@ -176,6 +179,44 @@ fn legacy_non_tty_cmd_emits_output() {
});
}
#[test]
fn legacy_non_tty_cmd_rejects_deny_read_overrides() {
let _guard = legacy_process_test_guard();
let runtime = current_thread_runtime();
runtime.block_on(async move {
let cwd = sandbox_cwd();
let codex_home = sandbox_home("legacy-non-tty-deny-read");
let secret_path =
AbsolutePathBuf::from_absolute_path(cwd.join("legacy-non-tty-deny-read-secret.env"))
.expect("absolute deny-read fixture path");
let err = spawn_windows_sandbox_session_legacy(
"workspace-write",
cwd.as_path(),
codex_home.path(),
vec![
"C:\\Windows\\System32\\cmd.exe".to_string(),
"/c".to_string(),
"echo deny-read".to_string(),
],
cwd.as_path(),
HashMap::new(),
Some(5_000),
std::slice::from_ref(&secret_path),
&[],
/*tty*/ false,
/*stdin_open*/ false,
/*use_private_desktop*/ true,
)
.await
.expect_err("legacy deny-read should require the elevated backend");
assert!(
err.to_string()
.contains("deny-read overrides require the elevated Windows sandbox backend"),
"unexpected error: {err:#}"
);
});
}
#[test]
fn legacy_non_tty_powershell_emits_output() {
let Some(pwsh) = pwsh_path() else {
@@ -200,6 +241,8 @@ fn legacy_non_tty_powershell_emits_output() {
cwd.as_path(),
HashMap::new(),
Some(5_000),
&[],
&[],
/*tty*/ false,
/*stdin_open*/ false,
/*use_private_desktop*/ true,
@@ -424,6 +467,8 @@ fn legacy_tty_powershell_emits_output_and_accepts_input() {
cwd.as_path(),
HashMap::new(),
Some(10_000),
&[],
&[],
/*tty*/ true,
/*stdin_open*/ true,
/*use_private_desktop*/ true,
@@ -472,6 +517,8 @@ fn legacy_tty_cmd_emits_output_and_accepts_input() {
cwd.as_path(),
HashMap::new(),
Some(10_000),
&[],
&[],
/*tty*/ true,
/*stdin_open*/ true,
/*use_private_desktop*/ true,
@@ -523,6 +570,8 @@ fn legacy_tty_cmd_default_desktop_emits_output_and_accepts_input() {
cwd.as_path(),
HashMap::new(),
Some(10_000),
&[],
&[],
/*tty*/ true,
/*stdin_open*/ true,
/*use_private_desktop*/ false,