mirror of
https://github.com/openai/codex.git
synced 2026-05-28 15:00:16 +00:00
## 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>
244 lines
8.4 KiB
Rust
244 lines
8.4 KiB
Rust
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(())
|
|
}
|