Enforce workspace metadata protections in Linux sandbox (#19852)

## Summary

Enforce FileSystemSandboxPolicy protected metadata names in the Linux
bubblewrap adapter so `.git`, `.agents`, and `.codex` remain read only
inside writable workspace roots unless the policy grants an explicit
write carveout.

## Scope

1. Translate protected metadata names from FileSystemSandboxPolicy into
bubblewrap masks for existing metadata paths.
2. Represent missing protected metadata paths as guarded mount targets
so agents cannot create `.git`, `.agents`, or `.codex` under writable
roots.
3. Preserve normal git discovery for existing repos, worktrees, and
parent repos.
4. Keep explicit user write grants working when policy allows a
protected metadata path directly.

## Not in scope

1. No shell preflight UX.
2. No TUI runtime profile propagation.
3. No macOS Seatbelt changes in this PR.

## Reviewer focus

1. This should be reviewed as the Linux enforcement adapter for the
policy primitive from PR 19846.
2. macOS enforcement already landed in PR 19847.
3. The important invariant is that `FileSystemSandboxPolicy` is the
source of truth for `.git`, `.agents`, and `.codex`.

## Validation

1. `git diff` whitespace check passed.
2. `cargo fmt` check passed with the existing stable rustfmt warning
about `imports_granularity`.
3. Full Linux sandbox Cargo test suite passed on the devbox.
4. Devbox forty six case suite passed at head
`012accb703c13bd28df5b40079a9bf183036336a`.
5. Devbox summary: pass 46, fail 0.
6. The devbox suite was run through `just c sandbox linux`.
7. Focused repo test for Viyat parent repo case passed on the devbox.
This commit is contained in:
evawong-oai
2026-04-29 16:14:14 -07:00
committed by GitHub
parent 13dbcda28f
commit 74f06dcdfb
4 changed files with 1911 additions and 113 deletions

View File

@@ -26,17 +26,17 @@ 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;
const SHORT_TIMEOUT_MS: u64 = 5_000;
#[cfg(target_arch = "aarch64")]
const SHORT_TIMEOUT_MS: u64 = 5_000;
#[cfg(not(target_arch = "aarch64"))]
const LONG_TIMEOUT_MS: u64 = 1_000;
const LONG_TIMEOUT_MS: u64 = 5_000;
#[cfg(target_arch = "aarch64")]
const LONG_TIMEOUT_MS: u64 = 5_000;
#[cfg(not(target_arch = "aarch64"))]
const NETWORK_TIMEOUT_MS: u64 = 2_000;
const NETWORK_TIMEOUT_MS: u64 = 10_000;
#[cfg(target_arch = "aarch64")]
const NETWORK_TIMEOUT_MS: u64 = 10_000;
@@ -47,6 +47,14 @@ fn create_env_from_core_vars() -> HashMap<String, String> {
create_env(&policy, /*thread_id*/ None)
}
fn codex_linux_sandbox_exe() -> PathBuf {
let sandbox_program = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox"));
match sandbox_program.canonicalize() {
Ok(path) => path,
Err(_) => sandbox_program,
}
}
#[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;
@@ -111,6 +119,57 @@ async fn run_cmd_result_with_permission_profile(
use_legacy_landlock: bool,
) -> Result<codex_protocol::exec_output::ExecToolCallOutput> {
let cwd = AbsolutePathBuf::current_dir().expect("cwd should exist");
run_cmd_result_with_permission_profile_for_cwd(
cmd,
cwd,
permission_profile,
timeout_ms,
use_legacy_landlock,
)
.await
}
#[expect(clippy::expect_used)]
async fn run_cmd_result_with_cwd_and_writable_roots(
cmd: &[&str],
cwd: &std::path::Path,
writable_roots: &[PathBuf],
timeout_ms: u64,
use_legacy_landlock: bool,
network_access: bool,
) -> Result<codex_protocol::exec_output::ExecToolCallOutput> {
let writable_roots = writable_roots
.iter()
.map(|path| AbsolutePathBuf::try_from(path.as_path()).unwrap())
.collect::<Vec<_>>();
let permission_profile = PermissionProfile::workspace_write_with(
&writable_roots,
if network_access {
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
},
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
let cwd = AbsolutePathBuf::try_from(cwd).expect("cwd should be absolute");
run_cmd_result_with_permission_profile_for_cwd(
cmd,
cwd,
permission_profile,
timeout_ms,
use_legacy_landlock,
)
.await
}
async fn run_cmd_result_with_permission_profile_for_cwd(
cmd: &[&str],
cwd: AbsolutePathBuf,
permission_profile: PermissionProfile,
timeout_ms: u64,
use_legacy_landlock: bool,
) -> Result<codex_protocol::exec_output::ExecToolCallOutput> {
let sandbox_cwd = cwd.clone();
let params = ExecParams {
command: cmd.iter().copied().map(str::to_owned).collect(),
@@ -125,8 +184,7 @@ async fn run_cmd_result_with_permission_profile(
justification: None,
arg0: None,
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
let codex_linux_sandbox_exe = Some(codex_linux_sandbox_exe());
process_exec_tool_call(
params,
@@ -384,8 +442,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
arg0: None,
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe: Option<PathBuf> = Some(PathBuf::from(sandbox_program));
let codex_linux_sandbox_exe: Option<PathBuf> = Some(codex_linux_sandbox_exe());
let permission_profile = PermissionProfile::read_only();
let result = process_exec_tool_call(
params,
@@ -530,6 +587,129 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() {
assert_ne!(codex_output.exit_code, 0);
}
#[tokio::test]
async fn sandbox_keeps_parent_repo_discovery_while_blocking_child_metadata() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let git_available = std::process::Command::new("git")
.arg("--version")
.status()
.is_ok_and(|status| status.success());
let python_available = std::process::Command::new("python3")
.arg("--version")
.status()
.is_ok_and(|status| status.success());
if !git_available || !python_available {
eprintln!("skipping bwrap test: git or python3 is unavailable");
return;
}
let tmpdir = tempfile::tempdir().expect("tempdir");
let repo = tmpdir.path().join("repo");
let subdir = repo.join("sub");
std::fs::create_dir_all(&subdir).expect("create nested workspace");
assert!(
std::process::Command::new("git")
.arg("init")
.arg("-q")
.arg(&repo)
.status()
.expect("git init should run")
.success(),
"git init should create parent repo"
);
let repo = repo.to_string_lossy();
let script = format!(
r#"set -e
test "$(git rev-parse --show-toplevel)" = '{repo}'
git status --short > status.before
if grep -E '(^|[[:space:]])\.(git|codex|agents)(/|$)' status.before; then
cat status.before
exit 21
fi
"#,
);
let output = run_cmd_result_with_cwd_and_writable_roots(
&["bash", "-lc", &script],
&subdir,
std::slice::from_ref(&subdir),
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await
.expect("sandboxed command should execute");
assert_eq!(
output.exit_code, 0,
"stdout:\n{}\nstderr:\n{}",
output.stdout.text, output.stderr.text
);
let git_init_output = expect_denied(
run_cmd_result_with_cwd_and_writable_roots(
&["git", "init", "-q"],
&subdir,
std::slice::from_ref(&subdir),
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await,
"child git init should be denied",
);
assert_ne!(git_init_output.exit_code, 0);
assert!(!subdir.join(".git").exists());
let mkdir_codex_output = expect_denied(
run_cmd_result_with_cwd_and_writable_roots(
&["mkdir", ".codex"],
&subdir,
std::slice::from_ref(&subdir),
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await,
"child .codex directory creation should be denied",
);
assert_ne!(mkdir_codex_output.exit_code, 0);
assert!(!subdir.join(".codex").exists());
let script = format!(
r#"set -e
test "$(git rev-parse --show-toplevel)" = '{repo}'
printf '%s\n' 'import json, sys' 'for line in sys.stdin:' ' obj = json.loads(line)' ' print(obj.get("message", obj))' > jsonl_viewer.py
printf '%s\n' '{{"message":"ok"}}' | python3 jsonl_viewer.py | grep -q ok
"#,
);
let output = run_cmd_result_with_cwd_and_writable_roots(
&["bash", "-lc", &script],
&subdir,
std::slice::from_ref(&subdir),
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await
.expect("sandboxed command should execute");
assert_eq!(
output.exit_code, 0,
"stdout:\n{}\nstderr:\n{}",
output.stdout.text, output.stderr.text
);
assert!(subdir.join("jsonl_viewer.py").is_file());
assert!(!subdir.join(".git").exists());
assert!(!subdir.join(".codex").exists());
assert!(!subdir.join(".agents").exists());
}
#[tokio::test]
async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() {
if should_skip_bwrap_tests().await {
@@ -543,7 +723,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() {
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"))
let sandbox_helper_dir = codex_linux_sandbox_exe()
.parent()
.expect("sandbox helper should have a parent")
.to_path_buf();
@@ -611,7 +791,7 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() {
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"))
let sandbox_helper_dir = codex_linux_sandbox_exe()
.parent()
.expect("sandbox helper should have a parent")
.to_path_buf();