mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## 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
156 lines
7.3 KiB
Rust
156 lines
7.3 KiB
Rust
//! End-to-end checks that only executor startup can opt into PID inheritance.
|
|
|
|
use super::*;
|
|
use codex_exec_server::FileSystemSandboxContext;
|
|
use codex_protocol::models::PermissionProfile;
|
|
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_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[tokio::test]
|
|
async fn pid_inheritance_is_startup_only_for_process_and_filesystem_helpers() -> Result<()> {
|
|
let Some(bwrap) = codex_sandboxing::find_system_bwrap_in_path(
|
|
&PermissionProfile::read_only().file_system_sandbox_policy(),
|
|
&std::env::current_dir()?,
|
|
) else {
|
|
eprintln!("skipping PID namespace test: no system bubblewrap");
|
|
return Ok(());
|
|
};
|
|
let available = tokio::process::Command::new(&bwrap)
|
|
.args(["--unshare-user", "--ro-bind", "/", "/", "--", "/bin/true"])
|
|
.output()
|
|
.await?;
|
|
if !available.status.success() {
|
|
eprintln!(
|
|
"skipping PID namespace test: {}",
|
|
String::from_utf8_lossy(&available.stderr)
|
|
);
|
|
return Ok(());
|
|
}
|
|
tokio::time::timeout(Duration::from_secs(30), async {
|
|
let fixture = TempDir::new()?;
|
|
let wrapper = fixture.path().join("bwrap");
|
|
std::os::unix::fs::symlink(bwrap, fixture.path().join("real-bwrap"))?;
|
|
codex_utils_cargo_bin::write_executable(&wrapper, r#"#!/bin/sh
|
|
for arg in "$@"; do
|
|
[ "$arg" = "--" ] && break
|
|
if [ "$arg" = "--proc" ]; then
|
|
echo "bwrap: Can't mount proc on /newroot/proc: Operation not permitted" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
exec "${0%/*}/real-bwrap" "$@"
|
|
"#)?;
|
|
let path = format!("{}:{}", fixture.path().display(), std::env::var("PATH")?);
|
|
// System bubblewrap discovery intentionally excludes executables under the command cwd.
|
|
let workspace = TempDir::new()?;
|
|
let file = workspace.path().join("readable");
|
|
std::fs::write(&file, "ok")?;
|
|
let file_uri = url::Url::from_file_path(&file).unwrap();
|
|
let cwd = url::Url::from_directory_path(workspace.path()).unwrap();
|
|
let minimal_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Special { value: FileSystemSpecialPath::Minimal },
|
|
FileSystemAccessMode::Read,
|
|
),
|
|
FileSystemSandboxEntry::new(
|
|
AbsolutePathBuf::try_from(workspace.path())?.into(), FileSystemAccessMode::Read,
|
|
),
|
|
FileSystemSandboxEntry::new(
|
|
AbsolutePathBuf::from_absolute_path("/proc/version")?.into(), FileSystemAccessMode::Deny,
|
|
),
|
|
]);
|
|
let minimal_profile = PermissionProfile::from_runtime_permissions(
|
|
&minimal_policy, NetworkSandboxPolicy::Restricted,
|
|
);
|
|
for (inherit, profile) in [
|
|
(false, PermissionProfile::read_only()),
|
|
(true, PermissionProfile::read_only()),
|
|
(true, minimal_profile),
|
|
] {
|
|
let minimal = profile.file_system_sandbox_policy().include_platform_defaults();
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
profile, cwd.as_str().parse()?,
|
|
);
|
|
let codex_home = TempDir::new()?;
|
|
// Neither a config key nor executor/request environment variables may opt in.
|
|
std::fs::write(codex_home.path().join("config.toml"),
|
|
"linux_sandbox_pid_namespace = 'inherit'\n")?;
|
|
let mut command = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
|
command.args(["exec-server", "--listen", "stdio"])
|
|
.env("CODEX_HOME", codex_home.path())
|
|
.env("PATH", &path)
|
|
.env("CODEX_LINUX_SANDBOX_PID_NAMESPACE", "inherit")
|
|
.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped())
|
|
.kill_on_drop(true);
|
|
if inherit {
|
|
command.arg("--linux-sandbox-pid-namespace=inherit");
|
|
}
|
|
let mut child = command.spawn()?;
|
|
let mut stdin = child.stdin.take().context("executor stdin")?;
|
|
let mut stdout = BufReader::new(child.stdout.take().context("executor stdout")?);
|
|
send_json_line(&mut stdin, &serde_json::json!({
|
|
"id": 1, "method": "initialize",
|
|
"params": {"clientName": "pid-namespace-test", "resumeSessionId": null}
|
|
})).await?;
|
|
wait_for_response(&mut stdout, /*expected_id*/ 1).await?;
|
|
send_json_line(&mut stdin, &serde_json::json!({"method": "initialized", "params": {}})).await?;
|
|
send_json_line(&mut stdin, &serde_json::json!({
|
|
"id": 2, "method": "process/start", "params": {
|
|
"processId": "pid-test", "cwd": cwd, "sandbox": sandbox,
|
|
"argv": ["/bin/bash", "-lc", r#"
|
|
set -e
|
|
IFS=' ' read -r pid rest < /proc/self/stat
|
|
test "$$" = "$pid"
|
|
test "$(cat <(printf shell-ok))" = shell-ok
|
|
if [ "$MINIMAL" = true ]; then
|
|
! cat /proc/version
|
|
fi
|
|
"#],
|
|
"env": {"PATH": path, "MINIMAL": minimal.to_string(), "CODEX_LINUX_SANDBOX_PID_NAMESPACE": "inherit"},
|
|
"tty": false, "pipeStdin": false, "arg0": null
|
|
}
|
|
})).await?;
|
|
// Notifications may precede the process/start response, so consume both.
|
|
let mut started = false;
|
|
let mut exit_code = None;
|
|
while !started || exit_code.is_none() {
|
|
let mut line = String::new();
|
|
anyhow::ensure!(stdout.read_line(&mut line).await? > 0, "executor closed");
|
|
let message: serde_json::Value = serde_json::from_str(&line)?;
|
|
if message["id"] == 2 {
|
|
anyhow::ensure!(message.get("error").is_none(), "start failed: {message}");
|
|
started = true;
|
|
}
|
|
if message["method"] == "process/exited" {
|
|
exit_code = message["params"]["exitCode"].as_i64();
|
|
}
|
|
}
|
|
assert_eq!(exit_code, Some(if inherit { 0 } else { 1 }));
|
|
send_json_line(&mut stdin, &serde_json::json!({
|
|
"id": 3, "method": "fs/readFile", "params": {"path": file_uri, "sandbox": sandbox}
|
|
})).await?;
|
|
loop {
|
|
let mut line = String::new();
|
|
anyhow::ensure!(stdout.read_line(&mut line).await? > 0, "executor closed");
|
|
let message: serde_json::Value = serde_json::from_str(&line)?;
|
|
if message["id"] != 3 { continue; }
|
|
assert_eq!(message["result"], serde_json::json!({"dataBase64": "b2s="}));
|
|
break;
|
|
}
|
|
drop(stdin);
|
|
// Drain notifications while the executor shuts down.
|
|
let mut remaining = String::new();
|
|
stdout.read_to_string(&mut remaining).await?;
|
|
let output = child.wait_with_output().await?;
|
|
anyhow::ensure!(output.status.success(), "executor failed: {}", String::from_utf8_lossy(&output.stderr));
|
|
}
|
|
Ok(())
|
|
}).await?
|
|
}
|