Mount a minimal /dev in full-filesystem Bubblewrap sandboxes (#37349)

## Why

Binding the full host filesystem into a network-isolated Bubblewrap sandbox also
inherits the host's device tree.

## What changed

- Overlay `/dev` with Bubblewrap's minimal device filesystem while preserving
  the root bind's `nodev` behavior.
- Bind `/dev/shm` back into the sandbox when available so host and sandbox
  processes can continue to share memory files.

## Testing

Add a managed-proxy integration test that verifies standard device nodes remain
usable, `/dev` is not the host directory, and `/dev/shm` writes are shared with
the host.

GitOrigin-RevId: 1d91fe44affd792d2777adae8f2cce6d85d14103
This commit is contained in:
viyatb-oai authored and copyberry committed 2026-08-07 01:30:07 +00:00
1 parent 4bb7ee3472
commit 912524d6fe
2 files changed
+90

No files matched your search

+12
View File
@@ -271,6 +271,13 @@ fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOption
"--bind".to_string(), "--bind".to_string(),
"/".to_string(), "/".to_string(),
"/".to_string(), "/".to_string(),
// Preserve nodev on the root bind while exposing only standard devices.
"--dev".to_string(),
"/dev".to_string(),
// Restore shared memory without exposing other host devices.
"--bind-try".to_string(),
"/dev/shm".to_string(),
"/dev/shm".to_string(),
// Always enter a fresh user namespace so root inside a container does // Always enter a fresh user namespace so root inside a container does
// not need ambient CAP_SYS_ADMIN to create the remaining namespaces. // not need ambient CAP_SYS_ADMIN to create the remaining namespaces.
"--unshare-user".to_string(), "--unshare-user".to_string(),
@@ -1401,6 +1408,11 @@ mod tests {
"--bind".to_string(), "--bind".to_string(),
"/".to_string(), "/".to_string(),
"/".to_string(), "/".to_string(),
"--dev".to_string(),
"/dev".to_string(),
"--bind-try".to_string(),
"/dev/shm".to_string(),
"/dev/shm".to_string(),
"--unshare-user".to_string(), "--unshare-user".to_string(),
"--unshare-pid".to_string(), "--unshare-pid".to_string(),
"--unshare-net".to_string(), "--unshare-net".to_string(),
@@ -17,9 +17,11 @@ use std::io::Read;
use std::io::Write; use std::io::Write;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::net::TcpListener; use std::net::TcpListener;
use std::os::unix::fs::MetadataExt;
use std::process::Output; use std::process::Output;
use std::process::Stdio; use std::process::Stdio;
use std::time::Duration; use std::time::Duration;
use tempfile::NamedTempFile;
use tokio::process::Command; use tokio::process::Command;
const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found"; const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found";
@@ -169,6 +171,82 @@ fn linux_sandbox_command(
cmd cmd
} }
#[tokio::test]
async fn managed_proxy_full_filesystem_uses_minimal_dev_nodes() {
if let Some(skip_reason) = managed_proxy_skip_reason().await {
eprintln!("skipping managed proxy test: {skip_reason}");
return;
}
let host_dev = std::fs::metadata("/dev").expect("host /dev should exist");
let host_dev_id = format!("{}:{}", host_dev.dev(), host_dev.ino());
let shared_memory_file = match NamedTempFile::new_in("/dev/shm") {
Ok(file) => {
std::fs::write(file.path(), "host-before").expect("seed /dev/shm file");
Some(file)
}
Err(err) => {
eprintln!("skipping /dev/shm interoperability check: {err}");
None
}
};
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
if let Some(file) = &shared_memory_file {
env.insert(
"CODEX_TEST_SHM_PATH".to_string(),
file.path().to_string_lossy().into_owned(),
);
}
let output = run_linux_sandbox_direct(
&[
"bash",
"-c",
concat!(
"set -e; ",
"for node in null zero urandom; do test -c \"/dev/$node\"; done; ",
"printf test >/dev/null; ",
"head -c 1 /dev/zero >/dev/null; ",
"head -c 1 /dev/urandom >/dev/null; ",
"if command -v python3 >/dev/null 2>&1; then ",
"python3 -c 'import os; assert len(os.urandom(16)) == 16'; ",
"fi; ",
"if [ -n \"${CODEX_TEST_SHM_PATH:-}\" ]; then ",
"test \"$(cat \"$CODEX_TEST_SHM_PATH\")\" = host-before; ",
"printf sandbox-after >\"$CODEX_TEST_SHM_PATH\"; ",
"fi; ",
"stat -c '%d:%i' /dev",
),
],
&PermissionProfile::Disabled,
/*allow_network_for_proxy*/ true,
env,
NETWORK_TIMEOUT_MS,
)
.await;
assert_eq!(
output.status.success(),
true,
"standard devices should be usable; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert_ne!(
String::from_utf8_lossy(&output.stdout).trim(),
host_dev_id,
"/dev should be synthetic rather than inherited from the host"
);
if let Some(file) = shared_memory_file {
assert_eq!(
std::fs::read_to_string(file.path()).expect("read /dev/shm file"),
"sandbox-after"
);
}
}
#[tokio::test] #[tokio::test]
async fn managed_proxy_bridges_release_command_output_after_exit() { async fn managed_proxy_bridges_release_command_output_after_exit() {
if let Some(skip_reason) = managed_proxy_skip_reason().await { if let Some(skip_reason) = managed_proxy_skip_reason().await {