Files
codex/codex-rs/linux-sandbox/tests/suite/landlock.rs
Michael Bolin 789f387982 permissions: remove legacy read-only access modes (#19449)
## Why

`ReadOnlyAccess` was a transitional legacy shape on `SandboxPolicy`:
`FullAccess` meant the historical read-only/workspace-write modes could
read the full filesystem, while `Restricted` tried to carry partial
readable roots. The partial-read model now belongs in
`FileSystemSandboxPolicy` and `PermissionProfile`, so keeping it on
`SandboxPolicy` makes every legacy projection reintroduce lossy
read-root bookkeeping and creates unnecessary noise in the rest of the
permissions migration.

This PR makes the legacy policy model narrower and explicit:
`SandboxPolicy::ReadOnly` and `SandboxPolicy::WorkspaceWrite` represent
the old full-read sandbox modes only. Split readable roots, deny-read
globs, and platform-default/minimal read behavior stay in the runtime
permissions model.

## What changed

- Removes `ReadOnlyAccess` from
`codex_protocol::protocol::SandboxPolicy`, including the generated
`access` and `readOnlyAccess` API fields.
- Updates legacy policy/profile conversions so restricted filesystem
reads are represented only by `FileSystemSandboxPolicy` /
`PermissionProfile` entries.
- Keeps app-server v2 compatible with legacy `fullAccess` read-access
payloads by accepting and ignoring that no-op shape, while rejecting
legacy `restricted` read-access payloads instead of silently widening
them to full-read legacy policies.
- Carries Windows sandbox platform-default read behavior with an
explicit override flag instead of depending on
`ReadOnlyAccess::Restricted`.
- Refreshes generated app-server schema/types and updates tests/docs for
the simplified legacy policy shape.

## Verification

- `cargo check -p codex-app-server-protocol --tests`
- `cargo check -p codex-windows-sandbox --tests`
- `cargo test -p codex-app-server-protocol sandbox_policy_`


---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/19449).
* #19395
* #19394
* #19393
* #19392
* #19391
* __->__ #19449
2026-04-24 17:16:58 -07:00

771 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![cfg(target_os = "linux")]
#![allow(clippy::unwrap_used)]
use codex_config::types::ShellEnvironmentPolicy;
use codex_core::exec::ExecCapturePolicy;
use codex_core::exec::ExecParams;
use codex_core::exec::process_exec_tool_call;
use codex_core::exec_env::create_env;
use codex_core::sandboxing::SandboxPermissions;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::error::SandboxErr;
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 codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::NamedTempFile;
// At least on GitHub CI, the arm64 tests appear to need longer timeouts.
#[cfg(not(target_arch = "aarch64"))]
const SHORT_TIMEOUT_MS: u64 = 200;
#[cfg(target_arch = "aarch64")]
const SHORT_TIMEOUT_MS: u64 = 5_000;
#[cfg(not(target_arch = "aarch64"))]
const LONG_TIMEOUT_MS: u64 = 1_000;
#[cfg(target_arch = "aarch64")]
const LONG_TIMEOUT_MS: u64 = 5_000;
#[cfg(not(target_arch = "aarch64"))]
const NETWORK_TIMEOUT_MS: u64 = 2_000;
#[cfg(target_arch = "aarch64")]
const NETWORK_TIMEOUT_MS: u64 = 10_000;
const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build.";
fn create_env_from_core_vars() -> HashMap<String, String> {
let policy = ShellEnvironmentPolicy::default();
create_env(&policy, /*thread_id*/ None)
}
#[expect(clippy::print_stdout)]
async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
let output = run_cmd_output(cmd, writable_roots, timeout_ms).await;
if output.exit_code != 0 {
println!("stdout:\n{}", output.stdout.text);
println!("stderr:\n{}", output.stderr.text);
panic!("exit code: {}", output.exit_code);
}
}
#[expect(clippy::expect_used)]
async fn run_cmd_output(
cmd: &[&str],
writable_roots: &[PathBuf],
timeout_ms: u64,
) -> codex_protocol::exec_output::ExecToolCallOutput {
run_cmd_result_with_writable_roots(
cmd,
writable_roots,
timeout_ms,
/*use_legacy_landlock*/ false,
/*network_access*/ false,
)
.await
.expect("sandboxed command should execute")
}
async fn run_cmd_result_with_writable_roots(
cmd: &[&str],
writable_roots: &[PathBuf],
timeout_ms: u64,
use_legacy_landlock: bool,
network_access: bool,
) -> Result<codex_protocol::exec_output::ExecToolCallOutput> {
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots
.iter()
.map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap())
.collect(),
network_access,
// Exclude tmp-related folders from writable roots because we need a
// folder that is writable by tests but that we intentionally disallow
// writing to in the sandbox.
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
run_cmd_result_with_policies(
cmd,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
timeout_ms,
use_legacy_landlock,
)
.await
}
#[expect(clippy::expect_used)]
async fn run_cmd_result_with_policies(
cmd: &[&str],
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
timeout_ms: u64,
use_legacy_landlock: bool,
) -> Result<codex_protocol::exec_output::ExecToolCallOutput> {
let cwd = AbsolutePathBuf::current_dir().expect("cwd should exist");
let sandbox_cwd = cwd.clone();
let params = ExecParams {
command: cmd.iter().copied().map(str::to_owned).collect(),
cwd,
expiration: timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env: create_env_from_core_vars(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
process_exec_tool_call(
params,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
&sandbox_cwd,
&codex_linux_sandbox_exe,
use_legacy_landlock,
/*stdout_stream*/ None,
)
.await
}
fn is_bwrap_unavailable_output(output: &codex_protocol::exec_output::ExecToolCallOutput) -> bool {
output.stderr.text.contains(BWRAP_UNAVAILABLE_ERR)
|| (output
.stderr
.text
.contains("Can't mount proc on /newroot/proc")
&& (output.stderr.text.contains("Operation not permitted")
|| output.stderr.text.contains("Permission denied")
|| output.stderr.text.contains("Invalid argument")))
}
async fn should_skip_bwrap_tests() -> bool {
match run_cmd_result_with_writable_roots(
&["bash", "-lc", "true"],
&[],
NETWORK_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await
{
Ok(output) => is_bwrap_unavailable_output(&output),
Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => {
is_bwrap_unavailable_output(&output)
}
// Probe timeouts are not actionable for the bwrap-specific assertions below;
// skip rather than fail the whole suite.
Err(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => true,
Err(err) => panic!("bwrap availability probe failed unexpectedly: {err:?}"),
}
}
fn expect_denied(
result: Result<codex_protocol::exec_output::ExecToolCallOutput>,
context: &str,
) -> codex_protocol::exec_output::ExecToolCallOutput {
match result {
Ok(output) => {
assert_ne!(output.exit_code, 0, "{context}: expected nonzero exit code");
output
}
Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output,
Err(err) => panic!("{context}: {err:?}"),
}
}
#[tokio::test]
async fn test_root_read() {
run_cmd(&["ls", "-l", "/bin"], &[], SHORT_TIMEOUT_MS).await;
}
#[tokio::test]
#[should_panic]
async fn test_root_write() {
let tmpfile = NamedTempFile::new().unwrap();
let tmpfile_path = tmpfile.path().to_string_lossy();
run_cmd(
&["bash", "-lc", &format!("echo blah > {tmpfile_path}")],
&[],
SHORT_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_dev_null_write() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let output = run_cmd_result_with_writable_roots(
&["bash", "-lc", "echo blah > /dev/null"],
&[],
// We have seen timeouts when running this test in CI on GitHub,
// so we are using a generous timeout until we can diagnose further.
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await
.expect("sandboxed command should execute");
assert_eq!(output.exit_code, 0);
}
#[tokio::test]
async fn bwrap_populates_minimal_dev_nodes() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let output = run_cmd_result_with_writable_roots(
&[
"bash",
"-lc",
"for node in null zero full random urandom tty; do [ -c \"/dev/$node\" ] || { echo \"missing /dev/$node\" >&2; exit 1; }; done",
],
&[],
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await
.expect("sandboxed command should execute");
assert_eq!(output.exit_code, 0);
}
#[tokio::test]
async fn bwrap_preserves_writable_dev_shm_bind_mount() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
if !std::path::Path::new("/dev/shm").exists() {
eprintln!("skipping bwrap test: /dev/shm is unavailable in this environment");
return;
}
let target_file = match NamedTempFile::new_in("/dev/shm") {
Ok(file) => file,
Err(err) => {
eprintln!("skipping bwrap test: failed to create /dev/shm temp file: {err}");
return;
}
};
let target_path = target_file.path().to_path_buf();
std::fs::write(&target_path, "host-before").expect("seed /dev/shm file");
let output = run_cmd_result_with_writable_roots(
&[
"bash",
"-lc",
&format!("printf sandbox-after > {}", target_path.to_string_lossy()),
],
&[PathBuf::from("/dev/shm")],
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await
.expect("sandboxed command should execute");
assert_eq!(output.exit_code, 0);
assert_eq!(
std::fs::read_to_string(&target_path).expect("read /dev/shm file"),
"sandbox-after"
);
}
#[tokio::test]
async fn test_writable_root() {
let tmpdir = tempfile::tempdir().unwrap();
let file_path = tmpdir.path().join("test");
run_cmd(
&[
"bash",
"-lc",
&format!("echo blah > {}", file_path.to_string_lossy()),
],
&[tmpdir.path().to_path_buf()],
// We have seen timeouts when running this test in CI on GitHub,
// so we are using a generous timeout until we can diagnose further.
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn sandbox_ignores_missing_writable_roots_under_bwrap() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let tempdir = tempfile::tempdir().expect("tempdir");
let existing_root = tempdir.path().join("existing");
let missing_root = tempdir.path().join("missing");
std::fs::create_dir(&existing_root).expect("create existing root");
let output = run_cmd_result_with_writable_roots(
&["bash", "-lc", "printf sandbox-ok"],
&[existing_root, missing_root],
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await
.expect("sandboxed command should execute");
assert_eq!(output.exit_code, 0);
assert_eq!(output.stdout.text, "sandbox-ok");
}
#[tokio::test]
async fn test_no_new_privs_is_enabled() {
let output = run_cmd_output(
&["bash", "-lc", "grep '^NoNewPrivs:' /proc/self/status"],
&[],
// We have seen timeouts when running this test in CI on GitHub,
// so we are using a generous timeout until we can diagnose further.
LONG_TIMEOUT_MS,
)
.await;
let line = output
.stdout
.text
.lines()
.find(|line| line.starts_with("NoNewPrivs:"))
.unwrap_or("");
assert_eq!(line.trim(), "NoNewPrivs:\t1");
}
#[tokio::test]
#[should_panic(expected = "Sandbox(Timeout")]
async fn test_timeout() {
run_cmd(&["sleep", "2"], &[], /*timeout_ms*/ 50).await;
}
/// Helper that runs `cmd` under the Linux sandbox and asserts that the command
/// does NOT succeed (i.e. returns a nonzero exit code) **unless** the binary
/// is missing in which case we silently treat it as an accepted skip so the
/// suite remains green on leaner CI images.
#[expect(clippy::expect_used)]
async fn assert_network_blocked(cmd: &[&str]) {
let cwd = AbsolutePathBuf::current_dir().expect("cwd should exist");
let sandbox_cwd = cwd.clone();
let params = ExecParams {
command: cmd.iter().copied().map(str::to_owned).collect(),
cwd,
// Give the tool a generous 2-second timeout so even slow DNS timeouts
// do not stall the suite.
expiration: NETWORK_TIMEOUT_MS.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env: create_env_from_core_vars(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,
};
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe: Option<PathBuf> = Some(PathBuf::from(sandbox_program));
let result = process_exec_tool_call(
params,
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
&sandbox_cwd,
&codex_linux_sandbox_exe,
/*use_legacy_landlock*/ false,
/*stdout_stream*/ None,
)
.await;
let output = match result {
Ok(output) => output,
Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output,
_ => {
panic!("expected sandbox denied error, got: {result:?}");
}
};
dbg!(&output.stderr.text);
dbg!(&output.stdout.text);
dbg!(&output.exit_code);
// A completely missing binary exits with 127. Anything else should also
// be nonzero (EPERM from seccomp will usually bubble up as 1, 2, 13…)
// If—*and only if*—the command exits 0 we consider the sandbox breached.
if output.exit_code == 0 {
panic!(
"Network sandbox FAILED - {cmd:?} exited 0\nstdout:\n{}\nstderr:\n{}",
output.stdout.text, output.stderr.text
);
}
}
#[tokio::test]
async fn sandbox_blocks_curl() {
assert_network_blocked(&["curl", "-I", "http://openai.com"]).await;
}
#[tokio::test]
async fn sandbox_blocks_wget() {
assert_network_blocked(&["wget", "-qO-", "http://openai.com"]).await;
}
#[tokio::test]
async fn sandbox_blocks_ping() {
// ICMP requires raw socket should be denied quickly with EPERM.
assert_network_blocked(&["ping", "-c", "1", "8.8.8.8"]).await;
}
#[tokio::test]
async fn sandbox_blocks_nc() {
// Zerolength connection attempt to localhost.
assert_network_blocked(&["nc", "-z", "127.0.0.1", "80"]).await;
}
#[tokio::test]
async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let tmpdir = tempfile::tempdir().expect("tempdir");
let dot_git = tmpdir.path().join(".git");
let dot_codex = tmpdir.path().join(".codex");
std::fs::create_dir_all(&dot_git).expect("create .git");
std::fs::create_dir_all(&dot_codex).expect("create .codex");
let git_target = dot_git.join("config");
let codex_target = dot_codex.join("config.toml");
let git_output = expect_denied(
run_cmd_result_with_writable_roots(
&[
"bash",
"-lc",
&format!("echo denied > {}", git_target.to_string_lossy()),
],
&[tmpdir.path().to_path_buf()],
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await,
".git write should be denied under bubblewrap",
);
let codex_output = expect_denied(
run_cmd_result_with_writable_roots(
&[
"bash",
"-lc",
&format!("echo denied > {}", codex_target.to_string_lossy()),
],
&[tmpdir.path().to_path_buf()],
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await,
".codex write should be denied under bubblewrap",
);
assert_ne!(git_output.exit_code, 0);
assert_ne!(codex_output.exit_code, 0);
}
#[tokio::test]
async fn sandbox_blocks_codex_symlink_replacement_attack() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
use std::os::unix::fs::symlink;
let tmpdir = tempfile::tempdir().expect("tempdir");
let decoy = tmpdir.path().join("decoy-codex");
std::fs::create_dir_all(&decoy).expect("create decoy dir");
let dot_codex = tmpdir.path().join(".codex");
symlink(&decoy, &dot_codex).expect("create .codex symlink");
let codex_target = dot_codex.join("config.toml");
let codex_output = expect_denied(
run_cmd_result_with_writable_roots(
&[
"bash",
"-lc",
&format!("echo denied > {}", codex_target.to_string_lossy()),
],
&[tmpdir.path().to_path_buf()],
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await,
".codex symlink replacement should be denied",
);
assert_ne!(codex_output.exit_code, 0);
}
#[tokio::test]
async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let tmpdir = tempfile::tempdir().expect("tempdir");
let blocked = tmpdir.path().join("blocked");
std::fs::create_dir_all(&blocked).expect("create blocked dir");
let blocked_target = blocked.join("secret.txt");
// These tests bypass the usual legacy-policy bridge, so explicitly keep
// the sandbox helper binary and minimal runtime paths readable.
let sandbox_helper_dir = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox"))
.parent()
.expect("sandbox helper should have a parent")
.to_path_buf();
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir")],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(sandbox_helper_dir.as_path())
.expect("absolute helper dir"),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir"),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(blocked.as_path()).expect("absolute blocked dir"),
},
access: FileSystemAccessMode::None,
},
]);
let output = expect_denied(
run_cmd_result_with_policies(
&[
"bash",
"-lc",
&format!("echo denied > {}", blocked_target.to_string_lossy()),
],
sandbox_policy,
file_system_sandbox_policy,
NetworkSandboxPolicy::Enabled,
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
)
.await,
"explicit split-policy carveout should be denied under bubblewrap",
);
assert_ne!(output.exit_code, 0);
}
#[tokio::test]
async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let tmpdir = tempfile::tempdir().expect("tempdir");
let blocked = tmpdir.path().join("blocked");
let allowed = blocked.join("allowed");
std::fs::create_dir_all(&allowed).expect("create blocked/allowed dir");
let allowed_target = allowed.join("note.txt");
// These tests bypass the usual legacy-policy bridge, so explicitly keep
// the sandbox helper binary and minimal runtime paths readable.
let sandbox_helper_dir = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox"))
.parent()
.expect("sandbox helper should have a parent")
.to_path_buf();
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir")],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(sandbox_helper_dir.as_path())
.expect("absolute helper dir"),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir"),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(blocked.as_path()).expect("absolute blocked dir"),
},
access: FileSystemAccessMode::None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(allowed.as_path()).expect("absolute allowed dir"),
},
access: FileSystemAccessMode::Write,
},
]);
let output = run_cmd_result_with_policies(
&[
"bash",
"-lc",
&format!(
"printf allowed > {} && cat {}",
allowed_target.to_string_lossy(),
allowed_target.to_string_lossy()
),
],
sandbox_policy,
file_system_sandbox_policy,
NetworkSandboxPolicy::Enabled,
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
)
.await
.expect("nested writable carveout should execute under bubblewrap");
assert_eq!(output.exit_code, 0);
assert_eq!(output.stdout.text.trim(), "allowed");
}
#[tokio::test]
async fn sandbox_blocks_root_read_carveouts_under_bwrap() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let tmpdir = tempfile::tempdir().expect("tempdir");
let blocked = tmpdir.path().join("blocked");
std::fs::create_dir_all(&blocked).expect("create blocked dir");
let blocked_target = blocked.join("secret.txt");
std::fs::write(&blocked_target, "secret").expect("seed blocked file");
let sandbox_policy = SandboxPolicy::ReadOnly {
network_access: true,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(blocked.as_path()).expect("absolute blocked dir"),
},
access: FileSystemAccessMode::None,
},
]);
let output = expect_denied(
run_cmd_result_with_policies(
&[
"bash",
"-lc",
&format!("cat {}", blocked_target.to_string_lossy()),
],
sandbox_policy,
file_system_sandbox_policy,
NetworkSandboxPolicy::Enabled,
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
)
.await,
"root-read carveout should be denied under bubblewrap",
);
assert_ne!(output.exit_code, 0);
}
#[tokio::test]
async fn sandbox_blocks_ssh() {
// Force ssh to attempt a real TCP connection but fail quickly. `BatchMode`
// avoids password prompts, and `ConnectTimeout` keeps the hang time low.
assert_network_blocked(&[
"ssh",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=1",
"github.com",
])
.await;
}
#[tokio::test]
async fn sandbox_blocks_getent() {
assert_network_blocked(&["getent", "ahosts", "openai.com"]).await;
}
#[tokio::test]
async fn sandbox_blocks_dev_tcp_redirection() {
// This syntax is only supported by bash and zsh. We try bash first.
// Fallback generic socket attempt using /bin/sh with bashstyle /dev/tcp. Not
// all images ship bash, so we guard against 127 as well.
assert_network_blocked(&["bash", "-c", "echo hi > /dev/tcp/127.0.0.1/80"]).await;
}