Hide WSLg's duplicate root in restricted Linux sandboxes (#45837)

## Why

WSLg's duplicate distro root can expose filesystem contents outside the sandbox's path masks.

## What changed

- Detect the duplicate root using filesystem identity, with mount metadata as a fallback, and hide it after applying filesystem grants and denials.
- Reject explicit grants, working directories, and executable paths that use the WSLg alias, directing users to the primary filesystem paths.
- Hide host procfs when a fresh procfs cannot be mounted so process roots cannot restore access to the masked view.

## Testing

Add regression tests for duplicate-root detection, mask ordering, existing ancestor masks, alias rejection, and unrestricted filesystem behavior. Add a WSLg runtime test covering masking with and without fresh procfs, continued access to an allowed file, and rejection of an executable using the alias.

GitOrigin-RevId: 9ea31724c00fb24e3a504b6658339e8dfea35343
This commit is contained in:
Eric Traut
2026-09-16 02:07:37 +00:00
committed by copyberry
parent 0dfb28edb9
commit 8ece31a7bf
8 changed files with 620 additions and 105 deletions

View File

@@ -6,6 +6,10 @@
//! - sensitive subpaths such as `.git`, `.agents`, and `.codex` remain
//! read-only even when their parent root is writable.
//!
//! Restricted execution also hides WSLg's duplicate distro root so it cannot
//! expose filesystem contents outside the policy's path masks. Explicit grants and
//! command paths using that alias are rejected before constructing mounts.
//!
//! The overall Linux sandbox is composed of:
//! - seccomp + `PR_SET_NO_NEW_PRIVS` applied in-process, and
//! - bubblewrap used to construct the filesystem view before exec.
@@ -56,6 +60,7 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192;
pub(crate) const WSL_INTEROP_DIR: &str = "/run/WSL";
pub(crate) const WSLG_DISTRO_ROOT: &str = "/mnt/wslg/distro";
/// Options that control how bubblewrap is invoked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -69,6 +74,9 @@ pub(crate) struct BwrapOptions {
pub network_mode: BwrapNetworkMode,
/// Hide the WSL Windows interop socket from commands with restricted filesystem access.
pub mask_wsl_interop: bool,
/// Whether the host exposes WSLg's duplicate distro root, which must be hidden
/// when constructing a restricted filesystem view.
pub mask_wslg_distro: bool,
/// Optional maximum depth for expanding unreadable glob patterns with ripgrep.
///
/// Keep this uncapped by default so existing nested deny-read matches are
@@ -82,6 +90,7 @@ impl Default for BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
mask_wsl_interop: false,
mask_wslg_distro: false,
glob_scan_max_depth: None,
}
}
@@ -316,18 +325,29 @@ fn create_bwrap_flags(
command_cwd: &Path,
options: BwrapOptions,
) -> Result<BwrapArgs> {
if options.mask_wslg_distro {
let granted_paths = file_system_sandbox_policy.entries.iter().flat_map(|entry| {
FileSystemSandboxPolicy::restricted(vec![entry.clone()])
.get_readable_roots_with_cwd(sandbox_policy_cwd)
});
let executable = command
.first()
.filter(|executable| executable.contains('/'))
.map(|executable| AbsolutePathBuf::resolve_path_against_base(executable, command_cwd));
for path in granted_paths
.map(AbsolutePathBuf::into_path_buf)
.chain([sandbox_policy_cwd.to_path_buf(), command_cwd.to_path_buf()])
.chain(executable.map(AbsolutePathBuf::into_path_buf))
{
crate::wslg::ensure_supported_path(&path)?;
}
}
let BwrapArgs {
args: filesystem_args,
preserved_files,
synthetic_mount_targets,
protected_create_targets,
} = create_filesystem_args(
file_system_sandbox_policy,
sandbox_policy_cwd,
options
.glob_scan_max_depth
.or(file_system_sandbox_policy.glob_scan_max_depth),
)?;
} = create_filesystem_args(file_system_sandbox_policy, sandbox_policy_cwd, options)?;
let normalized_command_cwd = normalize_command_cwd_for_bwrap(command_cwd);
let mut args = Vec::new();
args.push("--new-session".to_string());
@@ -339,12 +359,12 @@ fn create_bwrap_flags(
// the Linux filesystem policy by launching an unrestricted Windows process.
args.push("--tmpfs".to_string());
args.push(WSL_INTEROP_DIR.to_string());
if !options.mount_proc {
// Without a fresh procfs, the root bind retains the host procfs.
// Hide it so /proc/<host-pid>/root cannot alias the interop sockets.
args.push("--tmpfs".to_string());
args.push("/proc".to_string());
}
}
if (options.mask_wsl_interop || options.mask_wslg_distro) && !options.mount_proc {
// Without a fresh procfs, the root bind retains the host procfs.
// Hide it so host process roots cannot restore the masked WSL views.
args.push("--tmpfs".to_string());
args.push("/proc".to_string());
}
// Request a user namespace explicitly rather than relying on bubblewrap's
// auto-enable behavior, which is skipped when the caller runs as uid 0.
@@ -398,7 +418,7 @@ fn create_bwrap_flags(
fn create_filesystem_args(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
glob_scan_max_depth: Option<usize>,
options: BwrapOptions,
) -> Result<BwrapArgs> {
let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd);
// Bubblewrap requires bind mount targets to exist. Skip missing writable
@@ -461,9 +481,15 @@ fn create_filesystem_args(
// to the existing matches we can see before constructing the mount overlay;
// core tool helpers still evaluate the original patterns directly at read time.
unreadable_roots.extend(
expand_unreadable_globs_with_ripgrep(&unreadable_globs, cwd, glob_scan_max_depth)?
.into_iter()
.map(AbsolutePathBuf::into_path_buf),
expand_unreadable_globs_with_ripgrep(
&unreadable_globs,
cwd,
options
.glob_scan_max_depth
.or(file_system_sandbox_policy.glob_scan_max_depth),
)?
.into_iter()
.map(AbsolutePathBuf::into_path_buf),
);
unreadable_roots.sort();
unreadable_roots.dedup();
@@ -673,6 +699,29 @@ fn create_filesystem_args(
append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?;
}
if options.mask_wslg_distro
&& !unreadable_roots.iter().any(|denied| {
let distro = Path::new(WSLG_DISTRO_ROOT);
distro.starts_with(denied)
&& !allowed_write_paths.iter().any(|root| {
root.starts_with(denied)
&& (distro.starts_with(root) || root.starts_with(distro))
})
})
{
// Use the expanded masks, including symlink targets from deny globs.
// An already hidden ancestor cannot accommodate a new child mount.
// Otherwise apply this after every grant to exclude the duplicate view.
bwrap_args.args.extend([
"--perms".to_string(),
"000".to_string(),
"--tmpfs".to_string(),
WSLG_DISTRO_ROOT.to_string(),
"--remount-ro".to_string(),
WSLG_DISTRO_ROOT.to_string(),
]);
}
Ok(bwrap_args)
}
@@ -1342,6 +1391,10 @@ fn find_first_non_existent_component(target_path: &Path) -> Option<PathBuf> {
None
}
#[cfg(test)]
#[path = "bwrap_wslg_tests.rs"]
mod wslg_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -1355,8 +1408,6 @@ mod tests {
use pretty_assertions::assert_eq;
use tempfile::TempDir;
const NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH: Option<usize> = None;
#[test]
fn default_unreadable_glob_scan_has_no_depth_cap() {
assert_eq!(BwrapOptions::default().glob_scan_max_depth, None);
@@ -1583,9 +1634,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
assert!(args.args.windows(3).any(|window| {
window == ["--bind", real_root_str.as_str(), real_root_str.as_str()]
@@ -1627,9 +1677,8 @@ mod tests {
missing_path_behavior: None,
}]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
assert!(args.args.windows(3).any(|window| {
window
@@ -1667,9 +1716,8 @@ mod tests {
missing_path_behavior: None,
}]);
let err =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect_err("protected symlinked subpath should fail closed");
let err = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect_err("protected symlinked subpath should fail closed");
let message = err.to_string();
assert!(
@@ -1710,9 +1758,8 @@ mod tests {
},
]);
let err =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect_err("deny-read path crossing writable symlink should fail closed");
let err = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect_err("deny-read path crossing writable symlink should fail closed");
let message = err.to_string();
assert!(
@@ -1743,9 +1790,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, Path::new("/"), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, Path::new("/"), BwrapOptions::default())
.expect("filesystem args");
let registry_path = path_to_string(&registry_root);
let temp_path = path_to_string(temp_root);
let registry_bind = args
@@ -1785,9 +1831,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
assert_empty_file_bound_without_perms(&args.args, &blocked);
assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".git"));
@@ -1825,9 +1870,8 @@ mod tests {
missing_path_behavior: None,
}]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let dot_git_str = path_to_string(&dot_git);
assert_empty_file_bound_without_perms(&args.args, &dot_git);
@@ -1873,7 +1917,7 @@ mod tests {
missing_path_behavior: None,
}]);
let args = create_filesystem_args(&policy, &workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
let args = create_filesystem_args(&policy, &workspace, BwrapOptions::default())
.expect("filesystem args");
assert_empty_directory_mounted_read_only(&args.args, &dot_git);
assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents"));
@@ -1910,9 +1954,8 @@ mod tests {
missing_path_behavior: None,
}]);
let args =
create_filesystem_args(&policy, &link_workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, &link_workspace, BwrapOptions::default())
.expect("filesystem args");
assert_empty_directory_mounted_read_only(&args.args, &dot_git);
assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents"));
assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex"));
@@ -1942,9 +1985,8 @@ mod tests {
/*exclude_slash_tmp*/ true,
);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let existing_root = path_to_string(&existing_root);
let missing_root = path_to_string(&missing_root);
@@ -2001,9 +2043,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let dot_git = path_to_string(&temp_dir.path().join(".git"));
let dot_agents = path_to_string(&temp_dir.path().join(".agents"));
let dot_codex = path_to_string(&temp_dir.path().join(".codex"));
@@ -2057,9 +2098,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let dot_vscode = path_to_string(&temp_dir.path().join(".vscode"));
let dot_secrets = path_to_string(&temp_dir.path().join(".secrets"));
@@ -2075,12 +2115,8 @@ mod tests {
/*exclude_slash_tmp*/ true,
);
let args = create_filesystem_args(
&sandbox_policy,
Path::new("/"),
NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH,
)
.expect("bwrap fs args");
let args = create_filesystem_args(&sandbox_policy, Path::new("/"), BwrapOptions::default())
.expect("bwrap fs args");
assert!(args.preserved_files.is_empty());
assert_eq!(
synthetic_mount_target_paths(&args),
@@ -2174,9 +2210,8 @@ mod tests {
missing_path_behavior: None,
}]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
assert_eq!(args.args[0..4], ["--tmpfs", "/", "--dev", "/dev"]);
@@ -2202,9 +2237,8 @@ mod tests {
missing_path_behavior: None,
}]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
assert!(
args.args
@@ -2244,9 +2278,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
assert!(args.args.windows(3).any(|window| {
window
@@ -2322,9 +2355,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let docs_str = path_to_string(docs.as_path());
let docs_public_str = path_to_string(docs_public.as_path());
let docs_ro_index = args
@@ -2375,9 +2407,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let blocked_str = path_to_string(blocked.as_path());
let allowed_str = path_to_string(allowed.as_path());
let blocked_none_index = args
@@ -2443,9 +2474,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let blocked_str = path_to_string(blocked.as_path());
let allowed_dir_str = path_to_string(allowed_dir.as_path());
let allowed_file_str = path_to_string(allowed_file.as_path());
@@ -2521,9 +2551,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let blocked_none_index = args
.args
.windows(4)
@@ -2568,9 +2597,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let blocked_str = path_to_string(blocked.as_path());
assert!(
@@ -2612,9 +2640,8 @@ mod tests {
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default())
.expect("filesystem args");
let blocked_file_str = path_to_string(blocked_file.as_path());
assert_eq!(args.preserved_files.len(), 1);
@@ -2645,8 +2672,15 @@ mod tests {
let policy =
default_policy_with_unreadable_glob(format!("{}/**/*.env", temp_dir.path().display()));
let args =
create_filesystem_args(&policy, temp_dir.path(), Some(2)).expect("filesystem args");
let args = create_filesystem_args(
&policy,
temp_dir.path(),
BwrapOptions {
glob_scan_max_depth: Some(2),
..Default::default()
},
)
.expect("filesystem args");
assert_file_masked(&args.args, &root_env);
assert_file_masked(&args.args, &nested_env);
@@ -2677,8 +2711,15 @@ mod tests {
let policy =
default_policy_with_unreadable_glob(format!("{}/**/*.env", link_root.display()));
let args =
create_filesystem_args(&policy, temp_dir.path(), Some(2)).expect("filesystem args");
let args = create_filesystem_args(
&policy,
temp_dir.path(),
BwrapOptions {
glob_scan_max_depth: Some(2),
..Default::default()
},
)
.expect("filesystem args");
assert_file_masked(&args.args, &real_secret);
}

View File

@@ -0,0 +1,258 @@
//! Regression coverage for excluding WSLg's duplicate filesystem view.
use super::*;
use codex_protocol::protocol::FileSystemSandboxEntry;
use pretty_assertions::assert_eq;
#[test_case::test_case(FileSystemAccessMode::Read; "read_root")]
#[test_case::test_case(FileSystemAccessMode::Write; "write_root")]
fn wslg_mask_follows_filesystem_grants_and_denials(root_access: FileSystemAccessMode) {
let temp_dir = tempfile::TempDir::new().expect("temp dir");
let denied = temp_dir.path().join("denied.txt");
fs::write(&denied, "fixture").expect("write fixture");
let denied = AbsolutePathBuf::from_absolute_path(denied).expect("absolute fixture");
let workspace =
AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute workspace");
for denied_path in [
FileSystemPath::from(denied.clone()),
FileSystemPath::GlobPattern {
pattern: format!("{}/*.txt", temp_dir.path().display()),
},
] {
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: root_access,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: workspace.clone().into(),
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: denied_path,
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
},
]);
for mount_proc in [true, false] {
for network_mode in [BwrapNetworkMode::FullAccess, BwrapNetworkMode::Isolated] {
let args = create_bwrap_command_args(
vec!["/bin/true".to_string()],
&policy,
temp_dir.path(),
temp_dir.path(),
BwrapOptions {
mount_proc,
network_mode,
mask_wslg_distro: true,
..Default::default()
},
)
.expect("create restricted bwrap args")
.args;
let mask = args
.windows(6)
.position(|args| {
args == [
"--perms",
"000",
"--tmpfs",
WSLG_DISTRO_ROOT,
"--remount-ro",
WSLG_DISTRO_ROOT,
]
})
.expect("unreadable, read-only WSLg mask");
let last_grant = args
.iter()
.rposition(|arg| arg == "--bind" || arg == "--ro-bind")
.expect("filesystem grant");
let denial = args
.iter()
.rposition(|arg| arg == &path_to_string(denied.as_path()))
.expect("denied file mask");
assert!(last_grant < mask && denial < mask);
assert!(args.windows(2).any(|args| {
args == [if mount_proc { "--proc" } else { "--tmpfs" }, "/proc"]
}));
}
}
}
}
#[test]
fn wslg_mask_is_omitted_when_policy_already_hides_an_ancestor() {
for denied in ["/mnt", "/mnt/wslg", WSLG_DISTRO_ROOT] {
let mut policy = FileSystemSandboxPolicy::read_only();
policy.entries.push(FileSystemSandboxEntry {
path: AbsolutePathBuf::from_absolute_path(denied)
.expect("absolute denied directory")
.into(),
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
});
let mut commands = Vec::new();
for mask_wslg_distro in [false, true] {
commands.push(
create_bwrap_command_args(
vec!["/bin/true".to_string()],
&policy,
Path::new("/"),
Path::new("/"),
BwrapOptions {
mask_wslg_distro,
..Default::default()
},
)
.expect("create bwrap args with denied ancestor")
.args,
);
}
assert_eq!(commands[0], commands[1]);
}
}
#[test]
fn unrestricted_filesystem_preserves_wslg_with_or_without_network_isolation() {
for network_mode in [BwrapNetworkMode::FullAccess, BwrapNetworkMode::Isolated] {
let mut commands = Vec::new();
for mask_wslg_distro in [false, true] {
commands.push(
create_bwrap_command_args(
vec!["/bin/true".to_string()],
&FileSystemSandboxPolicy::unrestricted(),
Path::new("/"),
Path::new("/"),
BwrapOptions {
network_mode,
mask_wslg_distro,
..Default::default()
},
)
.expect("create unrestricted bwrap args")
.args,
);
}
assert_eq!(commands[0], commands[1]);
}
}
#[test_case::test_case("grant_read"; "read_grant")]
#[test_case::test_case("grant_write"; "write_grant")]
#[test_case::test_case("policy_cwd"; "policy_cwd")]
#[test_case::test_case("command_cwd"; "command_cwd")]
#[test_case::test_case("executable"; "executable")]
fn explicit_wslg_alias_paths_are_rejected(source: &str) {
let alias = Path::new(WSLG_DISTRO_ROOT).join("project");
let mut policy = FileSystemSandboxPolicy::read_only();
if source.starts_with("grant_") {
policy.entries.push(FileSystemSandboxEntry {
path: AbsolutePathBuf::from_absolute_path(&alias)
.expect("absolute alias")
.into(),
access: if source == "grant_write" {
FileSystemAccessMode::Write
} else {
FileSystemAccessMode::Read
},
missing_path_behavior: None,
});
}
let executable = if source == "executable" {
alias.as_path()
} else {
Path::new("/bin/true")
};
let policy_cwd = if source == "policy_cwd" {
alias.as_path()
} else {
Path::new("/")
};
let command_cwd = if source == "command_cwd" {
alias.as_path()
} else {
Path::new("/")
};
let error = create_bwrap_command_args(
vec![path_to_string(executable)],
&policy,
policy_cwd,
command_cwd,
BwrapOptions {
mask_wslg_distro: true,
..Default::default()
},
)
.expect_err("explicit alias must be rejected before mounts are built");
assert_eq!(
error.to_string(),
format!(
"Fatal error: restricted sandboxes do not support paths under {WSLG_DISTRO_ROOT}: {}; use the corresponding path under the primary filesystem root instead",
alias.display()
)
);
}
#[test]
fn glob_ancestor_mask_in_no_rg_fallback() {
// Isolate PATH in a child test process instead of mutating the test runner's
// environment. This exercises the supported missing-rg branch end to end.
const CHILD: &str = "CODEX_WSLG_GLOB_TEST_CHILD";
if std::env::var_os(CHILD).is_none() {
let output = Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"bwrap::wslg_tests::glob_ancestor_mask_in_no_rg_fallback",
"--nocapture",
])
.env(CHILD, "1")
.env("PATH", "")
.output()
.expect("run isolated test");
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
let temp = tempfile::tempdir().expect("temp directory");
// '/' is an ancestor on every test host; no WSL mount is needed. Only
// inspect arguments: never launch this synthetic filesystem policy.
let relative_root: PathBuf =
std::iter::repeat_n("..", temp.path().components().count() - 1).collect();
std::os::unix::fs::symlink(relative_root, temp.path().join("alias")).expect("relative symlink");
let mut policy = FileSystemSandboxPolicy::read_only();
policy.entries.push(FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: format!("{}/alias*", temp.path().display()),
},
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
});
let args = create_bwrap_command_args(
vec!["/bin/true".to_string()],
&policy,
Path::new("/"),
Path::new("/"),
BwrapOptions {
mask_wslg_distro: true,
mount_proc: false,
..Default::default()
},
)
.expect("expanded ancestor mask should suffice")
.args;
assert!(
args.windows(4)
.any(|args| args == ["--tmpfs", "/", "--remount-ro", "/"])
);
assert!(!args.iter().any(|arg| arg == WSLG_DISTRO_ROOT));
assert!(args.windows(2).any(|args| args == ["--tmpfs", "/proc"]));
}

View File

@@ -23,6 +23,8 @@ mod linux_run_main;
mod proxy_lifecycle;
#[cfg(target_os = "linux")]
mod proxy_routing;
#[cfg(target_os = "linux")]
mod wslg;
/// Exit status returned when bundled bubblewrap fails digest verification.
#[cfg(target_os = "linux")]

View File

@@ -23,6 +23,7 @@ use std::time::Duration;
use crate::bwrap::BwrapNetworkMode;
use crate::bwrap::BwrapOptions;
use crate::bwrap::WSL_INTEROP_DIR;
use crate::bwrap::WSLG_DISTRO_ROOT;
use crate::bwrap::create_bwrap_command_args;
use crate::landlock::apply_permission_profile_to_current_thread;
use crate::launcher::exec_bwrap;
@@ -303,6 +304,31 @@ pub fn run_main() -> ! {
} else {
(None, Vec::new())
};
let options = BwrapOptions {
mount_proc: !no_proc,
network_mode: bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy),
mask_wsl_interop: !file_system_sandbox_policy.has_full_disk_write_access()
&& Path::new(WSL_INTEROP_DIR).is_dir(),
mask_wslg_distro: (!file_system_sandbox_policy.has_full_disk_write_access()
|| !file_system_sandbox_policy
.get_unreadable_globs_with_cwd(&sandbox_policy_cwd)
.is_empty())
&& crate::wslg::is_duplicate_root(Path::new(WSLG_DISTRO_ROOT))
.unwrap_or_else(|err| exit_with_bwrap_build_error(err.into())),
..Default::default()
};
if options.mask_wslg_distro
&& let Some(executable) = command
.first()
.filter(|executable| executable.contains('/'))
{
let executable = command_cwd
.as_deref()
.unwrap_or(&sandbox_policy_cwd)
.join(executable);
crate::wslg::ensure_supported_path(&executable)
.unwrap_or_else(|err| exit_with_bwrap_build_error(err));
}
let inner = build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: &sandbox_policy_cwd,
command_cwd: command_cwd.as_deref(),
@@ -315,10 +341,9 @@ pub fn run_main() -> ! {
&sandbox_policy_cwd,
command_cwd.as_deref(),
&file_system_sandbox_policy,
bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy),
options,
inner,
proxy_controls,
!no_proc,
);
}
@@ -418,30 +443,21 @@ fn run_bwrap_with_proc_fallback(
sandbox_policy_cwd: &Path,
command_cwd: Option<&Path>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_mode: BwrapNetworkMode,
mut options: BwrapOptions,
inner: Vec<String>,
proxy_controls: Vec<File>,
mount_proc: bool,
) -> ! {
let mut mount_proc = mount_proc;
let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd);
if mount_proc
&& !preflight_proc_mount_support(network_mode)
if options.mount_proc
&& !preflight_proc_mount_support(options.network_mode)
.unwrap_or_else(|err| exit_with_bwrap_build_error(err))
{
// Keep the retry silent so sandbox-internal diagnostics do not leak into the
// child process stderr stream.
mount_proc = false;
options.mount_proc = false;
}
let options = BwrapOptions {
mount_proc,
network_mode,
mask_wsl_interop: !file_system_sandbox_policy.has_full_disk_write_access()
&& Path::new(WSL_INTEROP_DIR).is_dir(),
..Default::default()
};
let mut bwrap_args = build_bwrap_argv(
inner,
file_system_sandbox_policy,

View File

@@ -0,0 +1,66 @@
//! Identify WSLg's duplicate root without relying on filtered environment variables
//! or kernel branding. An unrelated directory at the same path is left untouched.
//! Explicit alias paths are rejected before building the restricted filesystem.
use std::fs;
use std::io;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
pub(crate) fn is_duplicate_root(path: &Path) -> io::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
let root = fs::metadata("/")?;
Ok(metadata.is_dir() && (metadata.dev(), metadata.ino()) == (root.dev(), root.ino()))
}
Err(err)
if matches!(
err.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) =>
{
Ok(false)
}
Err(_) => {
// Mount metadata remains readable when an ancestor is not searchable.
// If neither observation is available, fail rather than guess that
// an inaccessible duplicate can safely remain in the sandbox.
let mountinfo = fs::read_to_string("/proc/self/mountinfo")?;
let root = mount_identity(&mountinfo, "/")?
.ok_or_else(|| io::Error::other("root mount identity is unavailable"))?;
Ok(Some(root) == mount_identity(&mountinfo, &path.to_string_lossy())?)
}
}
}
pub(crate) fn ensure_supported_path(path: &Path) -> codex_protocol::error::Result<()> {
let alias = crate::bwrap::WSLG_DISTRO_ROOT;
if path.starts_with(alias)
|| fs::canonicalize(path).is_ok_and(|resolved| resolved.starts_with(alias))
{
return Err(codex_protocol::error::CodexErr::Fatal(format!(
"restricted sandboxes do not support paths under {alias}: {}; use the corresponding path under the primary filesystem root instead",
path.display()
)));
}
Ok(())
}
fn mount_identity<'a>(mountinfo: &'a str, path: &str) -> io::Result<Option<(&'a str, &'a str)>> {
let mut identities = mountinfo.lines().filter_map(|line| {
let mut fields = line.split_ascii_whitespace();
let device = fields.nth(2)?;
let root = fields.next()?;
let mountpoint = fields.next()?;
(mountpoint == path).then_some((device, root))
});
let identity = identities.next();
if identities.any(|other| Some(other) != identity) {
return Err(io::Error::other("mount identity is ambiguous"));
}
Ok(identity)
}
#[cfg(test)]
#[path = "wslg_tests.rs"]
mod tests;

View File

@@ -0,0 +1,49 @@
use super::is_duplicate_root;
use super::mount_identity;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
#[test]
fn ordinary_paths_do_not_identify_a_duplicate_root() {
let temp = tempfile::tempdir().expect("temporary directory");
let file = temp.path().join("file");
fs::write(&file, "fixture").expect("write file");
let link = temp.path().join("link");
std::os::unix::fs::symlink("/", &link).expect("create symlink");
let missing = temp.path().join("missing");
let not_a_directory = file.join("child");
for path in [temp.path(), &file, &link, &missing, &not_a_directory] {
assert!(!is_duplicate_root(path).expect("inspect path"));
}
assert!(is_duplicate_root(Path::new("/")).expect("inspect root"));
}
#[test]
fn mount_identity_distinguishes_roots_on_the_same_device() {
let mounts = "36 25 8:16 / / rw - ext4 /dev/sdb rw\n\
39 25 8:16 / /mnt/wslg/distro rw - ext4 /dev/sdb rw\n\
40 25 8:16 /other /unrelated rw - ext4 /dev/sdb rw\n\
41 25 0:45 / /different-device rw - tmpfs tmpfs rw\n";
assert_eq!(
mount_identity(mounts, "/").unwrap(),
mount_identity(mounts, "/mnt/wslg/distro").unwrap()
);
assert_ne!(
mount_identity(mounts, "/").unwrap(),
mount_identity(mounts, "/unrelated").unwrap()
);
assert_ne!(
mount_identity(mounts, "/").unwrap(),
mount_identity(mounts, "/different-device").unwrap()
);
assert_eq!(mount_identity(mounts, "/missing").unwrap(), None);
}
#[test]
fn conflicting_stacked_mounts_are_ambiguous() {
let mounts = "36 25 8:16 / / rw - ext4 /dev/sdb rw\n\
39 25 8:16 / /mnt/wslg/distro rw - ext4 /dev/sdb rw\n\
40 39 0:45 / /mnt/wslg/distro rw - tmpfs tmpfs rw\n";
assert!(mount_identity(mounts, "/mnt/wslg/distro").is_err());
}

View File

@@ -25,6 +25,9 @@ use std::process::Output;
use std::time::Duration;
use tempfile::NamedTempFile;
#[path = "wslg_tests.rs"]
mod wslg_tests;
// At least on GitHub CI, the arm64 tests appear to need longer timeouts.
#[cfg(not(target_arch = "aarch64"))]

View File

@@ -0,0 +1,80 @@
//! WSLg runtime coverage for the duplicate-view mask and procfs fallback.
use super::NETWORK_TIMEOUT_MS;
use super::codex_linux_sandbox_exe;
use super::should_skip_bwrap_tests;
use codex_protocol::models::PermissionProfile;
use pretty_assertions::assert_eq;
use std::os::unix::fs::MetadataExt as _;
use std::time::Duration;
#[tokio::test]
async fn wslg_duplicate_view_is_masked_with_and_without_fresh_procfs() {
let root = std::fs::metadata("/").expect("inspect root");
let has_wslg_root = std::fs::symlink_metadata("/mnt/wslg/distro").is_ok_and(|metadata| {
metadata.is_dir() && (metadata.dev(), metadata.ino()) == (root.dev(), root.ino())
});
if !has_wslg_root || should_skip_bwrap_tests().await {
eprintln!("skipping WSLg test: WSLg or bwrap prerequisites are unavailable");
return;
}
let workspace = tempfile::tempdir().expect("create workspace");
let allowed = workspace.path().join("allowed.txt");
std::fs::write(&allowed, "allowed\n").expect("write allowed fixture");
let profile = serde_json::to_string(&PermissionProfile::read_only())
.expect("serialize read-only profile");
for no_proc in [false, true] {
let mut command = tokio::process::Command::new(codex_linux_sandbox_exe());
command
.arg("--sandbox-policy-cwd")
.arg(workspace.path())
.args(["--permission-profile", &profile]);
if no_proc {
command.arg("--no-proc");
}
// Inspect the protection itself, without accessing data through the alias.
let script = if no_proc {
"stat -c %a /mnt/wslg/distro && stat -f -c %T /mnt/wslg/distro && cat \"$1\" && test ! -e /proc/1"
} else {
"stat -c %a /mnt/wslg/distro && stat -f -c %T /mnt/wslg/distro && cat \"$1\""
};
let output = tokio::time::timeout(
Duration::from_millis(NETWORK_TIMEOUT_MS),
command
.args(["--", "/bin/sh", "-c", script, "sh"])
.arg(&allowed)
.kill_on_drop(true)
.output(),
)
.await
.expect("WSLg sandbox check should finish")
.expect("sandbox helper should start");
assert!(
output.status.success(),
"WSLg mask check failed (no_proc={no_proc}): {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, b"0\ntmpfs\nallowed\n");
}
// Exercise the public helper boundary: production wraps the requested
// executable in another helper command before constructing bwrap args.
let output = tokio::process::Command::new(codex_linux_sandbox_exe())
.arg("--sandbox-policy-cwd")
.arg(workspace.path())
.args([
"--permission-profile",
&profile,
"--",
"/mnt/wslg/distro/bin/true",
])
.output()
.await
.expect("sandbox helper should start");
assert_eq!(output.status.code(), Some(1));
assert_eq!(
String::from_utf8(output.stderr).expect("UTF-8 diagnostic"),
"error building bubblewrap command: Fatal error: restricted sandboxes do not support paths under /mnt/wslg/distro: /mnt/wslg/distro/bin/true; use the corresponding path under the primary filesystem root instead\n"
);
}