Skip to content

Commit 7aa8f51

Browse files
viyatb-oaicopyberry
authored andcommitted
Reject sandbox-writable bubblewrap executables from PATH (#51211)
## Why Bubblewrap discovery probes executables before confinement. Excluding only the command's current directory leaves candidates in other writable roots eligible to run outside the sandbox. ## What changed - Filter canonical `PATH` candidates using the filesystem policy and effective user permissions, including writable ancestors, symlinked roots, and full-disk write access. Preserve protected system installations while rejecting replaceable paths. - Select the launcher with the command's permissions before the proc-mount preflight, retaining the bundled bubblewrap fallback. - Use the effective permission profile and policy working directory for startup warning probes. ## Testing Add unit coverage for writable roots, symlinks, read-only carveouts, replaceable parents, and full-disk policies. Add Linux integration tests showing writable `bwrap` candidates are neither probed nor launched, including when the command runs in a workspace subdirectory or uses managed networking with full-disk write access. GitOrigin-RevId: 188c9f45e727eaace062f5abb1205dc23fdf3c51
1 parent 4d15794 commit 7aa8f51

15 files changed

Lines changed: 531 additions & 47 deletions

File tree

‎codex-rs/app-server/src/lib.rs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -686,9 +686,10 @@ pub async fn run_main_with_transport_options(
686686
range: None,
687687
});
688688
}
689-
if let Some(warning) =
690-
codex_core::config::system_bwrap_warning(config.permissions.permission_profile())
691-
{
689+
if let Some(warning) = codex_core::config::system_bwrap_warning(
690+
&config.permissions.effective_permission_profile(),
691+
&config.cwd,
692+
) {
692693
config_warnings.push(ConfigWarningNotification {
693694
summary: warning,
694695
details: None,

‎codex-rs/cli/tests/exec_server/pid_namespace_tests.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ use pretty_assertions::assert_eq;
1414

1515
#[tokio::test]
1616
async fn pid_inheritance_is_startup_only_for_process_and_filesystem_helpers() -> Result<()> {
17-
let Some(bwrap) = codex_sandboxing::find_system_bwrap_in_path() else {
17+
let Some(bwrap) = codex_sandboxing::find_system_bwrap_in_path(
18+
&PermissionProfile::read_only().file_system_sandbox_policy(),
19+
&std::env::current_dir()?,
20+
) else {
1821
eprintln!("skipping PID namespace test: no system bubblewrap");
1922
return Ok(());
2023
};

‎codex-rs/exec-server/tests/exec_process.rs‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,10 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory(
211211
shell_name: &str,
212212
) -> Result<()> {
213213
if use_sandbox
214-
&& let Some(warning) =
215-
codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only())
214+
&& let Some(warning) = codex_sandboxing::system_bwrap_warning(
215+
&PermissionProfile::read_only(),
216+
&std::env::current_dir()?,
217+
)
216218
{
217219
eprintln!("skipping sandbox test: {warning}");
218220
return Ok(());
@@ -485,8 +487,10 @@ async fn shell_snapshot_v2_capture_failure_falls_back_and_retries(
485487
failure: CaptureFailure,
486488
) -> Result<()> {
487489
if use_remote
488-
&& let Some(warning) =
489-
codex_sandboxing::system_bwrap_warning(&PermissionProfile::workspace_write())
490+
&& let Some(warning) = codex_sandboxing::system_bwrap_warning(
491+
&PermissionProfile::workspace_write(),
492+
&std::env::current_dir()?,
493+
)
490494
{
491495
eprintln!("skipping sandbox test: {warning}");
492496
return Ok(());
@@ -636,7 +640,10 @@ async fn shell_snapshot_v2_capture_failure_falls_back_and_retries(
636640
#[cfg(unix)]
637641
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
638642
async fn remote_sandboxed_process_preserves_custom_arg0() -> Result<()> {
639-
if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) {
643+
if let Some(warning) = codex_sandboxing::system_bwrap_warning(
644+
&PermissionProfile::read_only(),
645+
&std::env::current_dir()?,
646+
) {
640647
eprintln!("skipping bwrap test: {warning}");
641648
return Ok(());
642649
}
@@ -740,7 +747,10 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
740747
#[cfg(target_os = "linux")]
741748
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
742749
async fn remote_process_keeps_sandbox_helper_visible_with_restricted_reads() -> Result<()> {
743-
if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) {
750+
if let Some(warning) = codex_sandboxing::system_bwrap_warning(
751+
&PermissionProfile::read_only(),
752+
&std::env::current_dir()?,
753+
) {
744754
eprintln!("skipping bwrap test: {warning}");
745755
return Ok(());
746756
}
@@ -802,7 +812,10 @@ async fn remote_process_keeps_sandbox_helper_visible_with_restricted_reads() ->
802812
#[cfg(target_os = "linux")]
803813
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
804814
async fn remote_tty_process_uses_configured_sandbox_helper_with_hostile_path() -> Result<()> {
805-
if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) {
815+
if let Some(warning) = codex_sandboxing::system_bwrap_warning(
816+
&PermissionProfile::read_only(),
817+
&std::env::current_dir()?,
818+
) {
806819
eprintln!("skipping bwrap test: {warning}");
807820
return Ok(());
808821
}
@@ -873,7 +886,10 @@ async fn remote_tty_process_uses_configured_sandbox_helper_with_hostile_path() -
873886
#[cfg(unix)]
874887
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
875888
async fn remote_process_preserves_empty_workspace_roots() -> Result<()> {
876-
if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) {
889+
if let Some(warning) = codex_sandboxing::system_bwrap_warning(
890+
&PermissionProfile::read_only(),
891+
&std::env::current_dir()?,
892+
) {
877893
eprintln!("skipping bwrap test: {warning}");
878894
return Ok(());
879895
}

‎codex-rs/exec-server/tests/exec_process/shell_snapshot.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,10 @@ async fn shell_snapshot_concurrent_replays_keep_independent_readers(
108108
let deny_fd_path = sandbox_mode == SnapshotSandbox::DenyFdPath;
109109
let use_sandbox = sandbox_mode != SnapshotSandbox::None;
110110
if use_sandbox
111-
&& let Some(warning) =
112-
codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only())
111+
&& let Some(warning) = codex_sandboxing::system_bwrap_warning(
112+
&PermissionProfile::read_only(),
113+
&std::env::current_dir()?,
114+
)
113115
{
114116
eprintln!("skipping sandbox test: {warning}");
115117
return Ok(());

‎codex-rs/exec/src/lib.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,8 +1177,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
11771177
// is using.
11781178
event_processor.print_config_summary(&config, &prompt_summary, &session_configured);
11791179
if !json_mode
1180-
&& let Some(message) =
1181-
codex_core::config::system_bwrap_warning(config.permissions.permission_profile())
1180+
&& let Some(message) = codex_core::config::system_bwrap_warning(
1181+
&config.permissions.effective_permission_profile(),
1182+
&config.cwd,
1183+
)
11821184
{
11831185
event_processor.process_warning(message);
11841186
}

‎codex-rs/linux-sandbox/src/launcher.rs‎

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::bundled_bwrap;
1111
use crate::bundled_bwrap::BundledBwrapLauncher;
1212
use crate::exec_util::argv_to_cstrings;
1313
use crate::exec_util::make_files_inheritable;
14+
use codex_protocol::permissions::FileSystemSandboxPolicy;
1415
use codex_sandboxing::find_system_bwrap_in_path;
1516
use codex_utils_absolute_path::AbsolutePathBuf;
1617

@@ -35,6 +36,8 @@ struct SystemBwrapCapabilities {
3536
supports_ro_bind_fd: bool,
3637
}
3738

39+
static LAUNCHER: OnceLock<BubblewrapLauncher> = OnceLock::new();
40+
3841
pub(crate) fn exec_bwrap(mut argv: Vec<String>, preserved_files: Vec<File>) -> ! {
3942
if argv
4043
.iter()
@@ -129,22 +132,28 @@ fn translate_legacy_bwrap_fd_mounts(argv: &mut Vec<String>) -> Result<(), String
129132
Ok(())
130133
}
131134

132-
fn preferred_bwrap_launcher() -> BubblewrapLauncher {
133-
static LAUNCHER: OnceLock<BubblewrapLauncher> = OnceLock::new();
134-
LAUNCHER
135-
.get_or_init(|| {
136-
if let Some(path) = find_system_bwrap_in_path()
137-
&& let Some(launcher) = system_bwrap_launcher_for_path(&path)
138-
{
139-
return BubblewrapLauncher::System(launcher);
140-
}
135+
pub(crate) fn initialize_bwrap_launcher(
136+
file_system_policy: &FileSystemSandboxPolicy,
137+
sandbox_policy_cwd: &Path,
138+
) {
139+
LAUNCHER.get_or_init(|| {
140+
if let Some(path) = find_system_bwrap_in_path(file_system_policy, sandbox_policy_cwd)
141+
&& let Some(launcher) = system_bwrap_launcher_for_path(&path)
142+
{
143+
return BubblewrapLauncher::System(launcher);
144+
}
141145

142-
match bundled_bwrap::launcher() {
143-
Some(launcher) => BubblewrapLauncher::Bundled(launcher),
144-
None => BubblewrapLauncher::Unavailable,
145-
}
146-
})
147-
.clone()
146+
match bundled_bwrap::launcher() {
147+
Some(launcher) => BubblewrapLauncher::Bundled(launcher),
148+
None => BubblewrapLauncher::Unavailable,
149+
}
150+
});
151+
}
152+
153+
fn preferred_bwrap_launcher() -> &'static BubblewrapLauncher {
154+
LAUNCHER.get().unwrap_or_else(|| {
155+
panic!("bubblewrap launcher must be initialized with the command's permissions")
156+
})
148157
}
149158

150159
fn system_bwrap_launcher_for_path(system_bwrap_path: &Path) -> Option<SystemBwrapLauncher> {

‎codex-rs/linux-sandbox/src/linux_run_main.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use crate::bwrap::WSLG_DISTRO_ROOT;
2727
use crate::bwrap::create_bwrap_command_args;
2828
use crate::landlock::apply_permission_profile_to_current_thread;
2929
use crate::launcher::exec_bwrap;
30+
use crate::launcher::initialize_bwrap_launcher;
3031
use crate::launcher::preferred_bwrap_supports_argv0;
3132
use crate::proxy_routing::activate_proxy_routes_in_netns;
3233
use crate::proxy_routing::prepare_host_proxy_route_spec;
@@ -428,6 +429,9 @@ fn run_bwrap_with_proc_fallback(
428429
) -> ! {
429430
let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd);
430431

432+
// Select before the preflight, whose synthetic policy omits writable roots.
433+
initialize_bwrap_launcher(file_system_sandbox_policy, sandbox_policy_cwd);
434+
431435
if options.mount_proc
432436
&& !preflight_proc_mount_support(options)
433437
.unwrap_or_else(|err| exit_with_bwrap_build_error(err))
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
//! Writable PATH candidates must never run while constructing the sandbox.
2+
3+
#![cfg(target_os = "linux")]
4+
5+
use codex_protocol::models::PermissionProfile;
6+
use codex_protocol::permissions::NetworkSandboxPolicy;
7+
use codex_utils_absolute_path::AbsolutePathBuf;
8+
use codex_utils_cargo_bin::cargo_bin;
9+
use codex_utils_cargo_bin::write_executable;
10+
use pretty_assertions::assert_eq;
11+
use std::os::unix::fs::PermissionsExt;
12+
use std::process::Command;
13+
14+
#[test]
15+
fn child_workdir_does_not_probe_or_launch_writable_parent_bwrap() {
16+
// Bazel can mount the default temp directory noexec.
17+
let temp = tempfile::tempdir_in(std::env::current_dir().expect("current directory"))
18+
.or_else(|_| tempfile::tempdir())
19+
.expect("create fixture");
20+
let workspace = temp.path().join("workspace");
21+
let cwd = workspace.join("service");
22+
let bin = workspace.join("bin");
23+
let marker = temp.path().join("outside-workspace");
24+
std::fs::create_dir_all(&cwd).expect("create cwd");
25+
std::fs::create_dir_all(&bin).expect("create bin");
26+
let fake_bwrap = bin.join("bwrap");
27+
std::fs::write(
28+
&fake_bwrap,
29+
"#!/bin/sh\n: > \"$BWRAP_PROBE_MARKER\"\nprintf '%s\\n' '--as-pid-1 --perms --argv0 --ro-bind-fd'\n",
30+
)
31+
.expect("write fake bwrap");
32+
std::fs::set_permissions(&fake_bwrap, std::fs::Permissions::from_mode(0o755))
33+
.expect("make fake bwrap executable");
34+
let policy = PermissionProfile::read_only()
35+
.file_system_sandbox_policy()
36+
.with_additional_writable_roots(
37+
&workspace,
38+
&[AbsolutePathBuf::try_from(workspace.clone()).expect("absolute workspace")],
39+
);
40+
let profile =
41+
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted);
42+
let profile_json = serde_json::to_string(&profile).expect("serialize profile");
43+
let trusted_path = std::env::var_os("PATH").expect("PATH is set");
44+
let run = |search_path: &std::ffi::OsStr, script: &str| {
45+
Command::new(cargo_bin("codex-linux-sandbox").expect("resolve sandbox binary"))
46+
.arg("--sandbox-policy-cwd")
47+
.arg(&workspace)
48+
.arg("--command-cwd")
49+
.arg(&cwd)
50+
.arg("--permission-profile")
51+
.arg(&profile_json)
52+
.args(["--", "/bin/sh", "-c", script])
53+
.current_dir(&cwd)
54+
.env("PATH", search_path)
55+
.env("BWRAP_PROBE_MARKER", &marker)
56+
.output()
57+
.expect("run sandbox")
58+
};
59+
let baseline = run(&trusted_path, "printf sandboxed");
60+
let stderr = String::from_utf8_lossy(&baseline.stderr);
61+
if !baseline.status.success()
62+
&& [
63+
"bubblewrap is unavailable",
64+
"No permissions to create a new namespace",
65+
"setting up uid map: Permission denied",
66+
]
67+
.iter()
68+
.any(|message| stderr.contains(message))
69+
{
70+
eprintln!(
71+
"sandbox unavailable: {}",
72+
String::from_utf8_lossy(&baseline.stderr)
73+
);
74+
return;
75+
}
76+
assert_eq!(baseline.status.success(), true, "{baseline:?}");
77+
let denied_write = run(&trusted_path, ": > \"$BWRAP_PROBE_MARKER\"");
78+
assert_eq!(denied_write.status.success(), false, "{denied_write:?}");
79+
assert!(
80+
!marker.exists(),
81+
"sandbox must deny the outside-workspace write"
82+
);
83+
let hostile_path = std::env::join_paths(
84+
[bin]
85+
.into_iter()
86+
.chain(std::env::split_paths(&trusted_path)),
87+
)
88+
.expect("join PATH");
89+
let output = run(&hostile_path, "printf sandboxed");
90+
assert!(!marker.exists(), "writable bwrap ran before confinement");
91+
assert_eq!(output.status.success(), true, "{output:?}");
92+
assert_eq!(output.stdout, b"sandboxed");
93+
}
94+
95+
#[test]
96+
fn full_disk_write_with_managed_network_does_not_probe_writable_bwrap() {
97+
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
98+
let bin = temp.path().join("bin");
99+
let cwd = temp.path().join("cwd");
100+
std::fs::create_dir(&bin).unwrap();
101+
std::fs::create_dir(&cwd).unwrap();
102+
let bwrap = bin.join("bwrap");
103+
write_executable(&bwrap, "#!/bin/sh\n: > \"$BWRAP_PROBE_MARKER\"\nexit 1\n").unwrap();
104+
let run = |profile: &PermissionProfile, marker: &std::path::Path| {
105+
Command::new(cargo_bin("codex-linux-sandbox").expect("resolve sandbox binary"))
106+
.arg("--sandbox-policy-cwd")
107+
.arg(&cwd)
108+
.arg("--permission-profile")
109+
.arg(serde_json::to_string(profile).unwrap())
110+
.args(["--managed-network", "{}", "--", "/bin/true"])
111+
.current_dir(&cwd)
112+
.env_clear()
113+
.env("PATH", &bin)
114+
.env("HTTP_PROXY", "http://127.0.0.1:9")
115+
.env("BWRAP_PROBE_MARKER", marker)
116+
.output()
117+
.unwrap()
118+
};
119+
// Confirm this host reaches the capability probe without needing working
120+
// namespaces or bundled bubblewrap. Both invocations use managed networking.
121+
let control_marker = temp.path().join("control");
122+
let control = run(&PermissionProfile::read_only(), &control_marker);
123+
assert!(
124+
control_marker.exists(),
125+
"probe was not reached: {control:?}"
126+
);
127+
128+
let marker = temp.path().join("unrestricted");
129+
let output = run(&PermissionProfile::Disabled, &marker);
130+
assert!(
131+
!marker.exists(),
132+
"writable bwrap ran before network confinement: {output:?}"
133+
);
134+
}

‎codex-rs/linux-sandbox/tests/suite/managed_proxy.rs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,10 @@ async fn proc_mount_denial_preserves_legacy_fallback_and_explicit_pid_inheritanc
315315
return;
316316
}
317317

318-
let Some(system_bwrap) = codex_sandboxing::find_system_bwrap_in_path() else {
318+
let Some(system_bwrap) = codex_sandboxing::find_system_bwrap_in_path(
319+
&PermissionProfile::read_only().file_system_sandbox_policy(),
320+
&std::env::current_dir().expect("current directory"),
321+
) else {
319322
eprintln!("skipping PID namespace test: no system bubblewrap is available");
320323
return;
321324
};
@@ -461,7 +464,10 @@ async fn unsupported_system_bwrap_falls_back_to_bundled_bwrap() {
461464
return;
462465
}
463466

464-
let Some(system_bwrap) = codex_sandboxing::find_system_bwrap_in_path() else {
467+
let Some(system_bwrap) = codex_sandboxing::find_system_bwrap_in_path(
468+
&PermissionProfile::read_only().file_system_sandbox_policy(),
469+
&std::env::current_dir().expect("current directory"),
470+
) else {
465471
eprintln!("skipping system bwrap fallback test: no system bubblewrap is available");
466472
return;
467473
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Aggregates all former standalone integration tests as modules.
22
mod bundled_bwrap;
3+
mod bwrap_path;
34
mod landlock;
45
mod managed_proxy;

0 commit comments

Comments
 (0)