Files
codex/codex-rs/rmcp-client/src/program_resolver.rs
viyatb-oai 46f30d0282 feat(sandbox): add Windows deny-read parity (#18202)
## Why

The split filesystem policy stack already supports exact and glob
`access = none` read restrictions on macOS and Linux. Windows still
needed subprocess handling for those deny-read policies without claiming
enforcement from a backend that cannot provide it.

## Key finding

The unelevated restricted-token backend cannot safely enforce deny-read
overlays. Its `WRITE_RESTRICTED` token model is authoritative for write
checks, not read denials, so this PR intentionally fails that backend
closed when deny-read overrides are present instead of claiming
unsupported enforcement.

## What changed

This PR adds the Windows deny-read enforcement layer and makes the
backend split explicit:

- Resolves Windows deny-read filesystem policy entries into concrete ACL
targets.
- Preserves exact missing paths so they can be materialized and denied
before an enforceable sandboxed process starts.
- Snapshot-expands existing glob matches into ACL targets for Windows
subprocess enforcement.
- Honors `glob_scan_max_depth` when expanding Windows deny-read globs.
- Plans both the configured lexical path and the canonical target for
existing paths so reparse-point aliases are covered.
- Threads deny-read overrides through the elevated/logon-user Windows
sandbox backend and unified exec.
- Applies elevated deny-read ACLs synchronously before command launch
rather than delegating them to the background read-grant helper.
- Reconciles persistent deny-read ACEs per sandbox principal so policy
changes do not leave stale deny-read ACLs behind.
- Fails closed on the unelevated restricted-token backend when deny-read
overrides are present, because its `WRITE_RESTRICTED` token model is not
authoritative for read denials.

## Landed prerequisites

These prerequisite PRs are already on `main`:

1. #15979 `feat(permissions): add glob deny-read policy support`
2. #18096 `feat(sandbox): add glob deny-read platform enforcement`
3. #17740 `feat(config): support managed deny-read requirements`

This PR targets `main` directly and contains only the Windows deny-read
enforcement layer.

## Implementation notes

- Exact deny-read paths remain enforceable on the elevated path even
when they do not exist yet: Windows materializes the missing path before
applying the deny ACE, so the sandboxed command cannot create and read
it during the same run.
- Existing exact deny paths are preserved lexically until the ACL
planner, which then adds the canonical target as a second ACL target
when needed. That keeps both the configured alias and the resolved
object covered.
- Windows ACLs do not consume Codex glob syntax directly, so glob
deny-read entries are expanded to the concrete matches that exist before
process launch.
- Glob traversal deduplicates directory visits within each pattern walk
to avoid cycles, without collapsing distinct lexical roots that happen
to resolve to the same target.
- Persistent deny-read ACL state is keyed by sandbox principal SID, so
cleanup only removes ACEs owned by the same backend principal.
- Deny-read ACEs are fail-closed on the elevated path: setup aborts if
mandatory deny-read ACL application fails.
- Unelevated restricted-token sessions reject deny-read overrides early
instead of running with a silently unenforceable read policy.

## Verification

- `cargo test -p codex-core
windows_restricted_token_rejects_unreadable_split_carveouts`
- `just fmt`
- `just fix -p codex-core`
- `just fix -p codex-windows-sandbox`
- GitHub Actions rerun is in progress on the pushed head.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-05-11 23:04:28 -07:00

248 lines
8.4 KiB
Rust

//! Platform-specific program resolution for MCP server execution.
//!
//! This module provides a unified interface for resolving executable paths
//! across different operating systems. The key challenge it addresses is that
//! Windows cannot execute script files (e.g., `.cmd`, `.bat`) directly through
//! `Command::new()` without their file extensions, while Unix systems handle
//! scripts natively through shebangs.
//!
//! The `resolve` function abstracts these platform differences:
//! - On Unix: Returns the program unchanged (OS handles script execution)
//! - On Windows: Uses the `which` crate to resolve full paths including extensions
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::Path;
/// Resolves a program to its executable path on Unix systems.
///
/// Unix systems handle PATH resolution and script execution natively through
/// the kernel's shebang (`#!`) mechanism, so this function simply returns
/// the program name unchanged.
#[cfg(unix)]
pub fn resolve(
program: OsString,
_env: &HashMap<OsString, OsString>,
_cwd: &Path,
) -> std::io::Result<OsString> {
Ok(program)
}
/// Resolves a program to its executable path on Windows systems.
///
/// Windows requires explicit file extensions for script execution. This function
/// uses the `which` crate to search the `PATH` environment variable and find
/// the full path to the executable, including necessary script extensions
/// (`.cmd`, `.bat`, etc.) defined in `PATHEXT`.
///
/// This enables tools like `npx`, `pnpm`, and `yarn` to work correctly on Windows
/// without requiring users to specify full paths or extensions in their configuration.
#[cfg(windows)]
pub fn resolve(
program: OsString,
env: &HashMap<OsString, OsString>,
cwd: &Path,
) -> std::io::Result<OsString> {
// Extract PATH from environment for search locations
let search_path = env.get(std::ffi::OsStr::new("PATH"));
// Attempt resolution via which crate
match which::which_in(&program, search_path, cwd) {
Ok(resolved) => {
tracing::debug!("Resolved {program:?} to {resolved:?}");
Ok(resolved.into_os_string())
}
Err(e) => {
tracing::debug!("Failed to resolve {program:?}: {e}. Using original path");
// Fallback to original program - let Command::new() handle the error
Ok(program)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::create_env_for_mcp_server;
use anyhow::Result;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use tokio::process::Command;
/// Unix: Verifies the OS handles script execution without file extensions.
#[cfg(unix)]
#[tokio::test]
async fn test_unix_executes_script_without_extension() -> Result<()> {
let env = TestExecutableEnv::new()?;
// 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;
};
assert!(
output.is_ok(),
"Unix should execute PATH-resolved scripts directly: {output:?}"
);
Ok(())
}
/// Windows: Verifies scripts fail to execute without the proper extension.
#[cfg(windows)]
#[tokio::test]
async fn test_windows_fails_without_extension() -> Result<()> {
let env = TestExecutableEnv::new()?;
let mut cmd = Command::new(&env.program_name);
cmd.envs(&env.mcp_env);
let output = cmd.output().await;
assert!(
output.is_err(),
"Windows requires .cmd/.bat extension for direct execution"
);
Ok(())
}
/// Windows: Verifies scripts with an explicit extension execute correctly.
#[cfg(windows)]
#[tokio::test]
async fn test_windows_succeeds_with_extension() -> Result<()> {
let env = TestExecutableEnv::new()?;
// Append the `.cmd` extension to the program name
let program_with_ext = format!("{}.cmd", env.program_name);
let mut cmd = Command::new(&program_with_ext);
cmd.envs(&env.mcp_env);
let output = cmd.output().await;
assert!(
output.is_ok(),
"Windows should execute scripts when the extension is provided"
);
Ok(())
}
/// Verifies program resolution enables successful execution on all platforms.
#[tokio::test]
async fn test_resolved_program_executes_successfully() -> Result<()> {
let env = TestExecutableEnv::new()?;
let program = OsString::from(&env.program_name);
// Apply platform-specific resolution
let resolved = resolve(program, &env.mcp_env, std::env::current_dir()?.as_path())?;
// Verify resolved path executes successfully
let mut cmd = Command::new(resolved);
cmd.envs(&env.mcp_env);
let output = cmd.output().await;
assert!(
output.is_ok(),
"Resolved program should execute successfully"
);
Ok(())
}
// Test fixture for creating temporary executables in a controlled environment.
struct TestExecutableEnv {
// Held to prevent the temporary directory from being deleted.
_temp_dir: TempDir,
program_name: String,
mcp_env: HashMap<OsString, OsString>,
}
impl TestExecutableEnv {
const TEST_PROGRAM: &'static str = "test_mcp_server";
fn new() -> Result<Self> {
let temp_dir = TempDir::new()?;
let dir_path = temp_dir.path();
Self::create_executable(dir_path)?;
// Build a clean environment with the temp dir in the PATH.
let mut extra_env = HashMap::new();
extra_env.insert(OsString::from("PATH"), Self::build_path_env_var(dir_path));
#[cfg(windows)]
extra_env.insert(OsString::from("PATHEXT"), Self::ensure_cmd_extension());
let mcp_env = create_env_for_mcp_server(Some(extra_env), &[])?;
Ok(Self {
_temp_dir: temp_dir,
program_name: Self::TEST_PROGRAM.to_string(),
mcp_env,
})
}
/// Creates a simple, platform-specific executable script.
fn create_executable(dir: &Path) -> Result<()> {
#[cfg(windows)]
{
let file = dir.join(format!("{}.cmd", Self::TEST_PROGRAM));
fs::write(&file, "@echo off\nexit 0")?;
}
#[cfg(unix)]
{
let file = dir.join(Self::TEST_PROGRAM);
fs::write(&file, "#!/bin/sh\nexit 0")?;
Self::set_executable(&file)?;
}
Ok(())
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)?;
Ok(())
}
/// Prepends the given directory to the system's PATH variable.
fn build_path_env_var(dir: &Path) -> OsString {
let mut path = OsString::from(dir.as_os_str());
if let Some(current) = std::env::var_os("PATH") {
let sep = if cfg!(windows) { ";" } else { ":" };
path.push(sep);
path.push(current);
}
path
}
/// Ensures `.CMD` is in the `PATHEXT` variable on Windows for script discovery.
#[cfg(windows)]
fn ensure_cmd_extension() -> OsString {
let current = std::env::var_os("PATHEXT").unwrap_or_default();
if current
.to_string_lossy()
.to_ascii_uppercase()
.contains(".CMD")
{
current
} else {
let mut path_ext = OsString::from(".CMD;");
path_ext.push(current);
path_ext
}
}
}
}