feat: add support for read-only bind mounts in the linux sandbox (#9112)

### Motivation

- Landlock alone cannot prevent writes to sensitive in-repo files like
`.git/` when the repo root is writable, so explicit mount restrictions
are required for those paths.
- The sandbox must set up any mounts before calling Landlock so Landlock
can still be applied afterwards and the two mechanisms compose
correctly.

### Description

- Add a new `linux-sandbox` helper `apply_read_only_mounts` in
`linux-sandbox/src/mounts.rs` that: unshares namespaces, maps uids/gids
when required, makes mounts private, bind-mounts targets, and remounts
them read-only.
- Wire the mount step into the sandbox flow by calling
`apply_read_only_mounts(...)` before network/seccomp and before applying
Landlock rules in `linux-sandbox/src/landlock.rs`.
This commit is contained in:
viyatb-oai
2026-01-14 08:30:46 -08:00
committed by GitHub
parent bcd7858ced
commit e1447c3009
8 changed files with 676 additions and 13 deletions

View File

@@ -6,3 +6,52 @@ This crate is responsible for producing:
- a lib crate that exposes the business logic of the executable as `run_main()` so that
- the `codex-exec` CLI can check if its arg0 is `codex-linux-sandbox` and, if so, execute as if it were `codex-linux-sandbox`
- this should also be true of the `codex` multitool CLI
## Git safety mounts (Linux)
When the sandbox policy allows workspace writes, the Linux sandbox uses a user
namespace plus a mount namespace to bind-mount sensitive subpaths read-only
before applying Landlock rules. This keeps Git and Codex metadata immutable
while still allowing writes to other workspace files, including worktree setups
where `.git` is a pointer file.
Protected subpaths under each writable root include:
- `.git` (directory or pointer file)
- the resolved `gitdir:` target when `.git` is a pointer file
- `.codex` when present
### How this plays with Landlock
Mount permissions and Landlock intersect: if a bind mount is read-only, writes
are denied even if Landlock would allow them. For that reason, the sandbox sets
up the read-only mounts *before* calling `landlock_restrict_self()` and then
applies Landlock rules on top.
### Quick manual test
Run the sandbox directly with a workspace-write policy (from a Git repository
root):
```bash
codex-linux-sandbox \
--sandbox-policy-cwd "$PWD" \
--sandbox-policy '{"type":"workspace-write"}' \
-- bash -lc '
set -euo pipefail
echo "should fail" > .git/config && exit 1 || true
echo "should fail" > .git/hooks/pre-commit && exit 1 || true
echo "should fail" > .git/index.lock && exit 1 || true
echo "should fail" > .codex/config.toml && exit 1 || true
echo "ok" > sandbox-write-test.txt
'
```
Expected behavior:
- Writes to `.git/config` fail with `Read-only file system`.
- Creating or modifying files under `.git/hooks/` fails.
- Writing `.git/index.lock` fails (since `.git` is read-only).
- Writes under `.codex/` fail when the directory exists.
- Writing a normal repo file succeeds.

View File

@@ -7,6 +7,8 @@ use codex_core::error::SandboxErr;
use codex_core::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::mounts::apply_read_only_mounts;
use landlock::ABI;
use landlock::Access;
use landlock::AccessFs;
@@ -31,6 +33,10 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
sandbox_policy: &SandboxPolicy,
cwd: &Path,
) -> Result<()> {
if !sandbox_policy.has_full_disk_write_access() {
apply_read_only_mounts(sandbox_policy, cwd)?;
}
if !sandbox_policy.has_full_network_access() {
install_network_seccomp_filter_on_current_thread()?;
}

View File

@@ -2,6 +2,8 @@
mod landlock;
#[cfg(target_os = "linux")]
mod linux_run_main;
#[cfg(target_os = "linux")]
mod mounts;
#[cfg(target_os = "linux")]
pub fn run_main() -> ! {

View File

@@ -0,0 +1,255 @@
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use codex_core::error::CodexErr;
use codex_core::error::Result;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::WritableRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
/// Apply read-only bind mounts for protected subpaths before Landlock.
///
/// This unshares mount namespaces (and user namespaces for non-root) so the
/// read-only remounts do not affect the host, then bind-mounts each protected
/// target onto itself and remounts it read-only.
pub(crate) fn apply_read_only_mounts(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result<()> {
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
let mount_targets = collect_read_only_mount_targets(&writable_roots)?;
if mount_targets.is_empty() {
return Ok(());
}
// Root can unshare the mount namespace directly; non-root needs a user
// namespace to gain capabilities for remounting.
if is_running_as_root() {
unshare_mount_namespace()?;
} else {
unshare_user_and_mount_namespaces()?;
write_user_namespace_maps()?;
}
make_mounts_private()?;
for target in mount_targets {
// Bind and remount read-only works for both files and directories.
bind_mount_read_only(target.as_path())?;
}
// Drop ambient capabilities acquired from the user namespace so the
// sandboxed command cannot remount or create new bind mounts.
if !is_running_as_root() {
drop_caps()?;
}
Ok(())
}
/// Collect read-only mount targets, resolving worktree `.git` pointer files.
fn collect_read_only_mount_targets(
writable_roots: &[WritableRoot],
) -> Result<Vec<AbsolutePathBuf>> {
let mut targets = Vec::new();
for writable_root in writable_roots {
for ro_subpath in &writable_root.read_only_subpaths {
// The policy expects these paths to exist; surface actionable errors
// rather than silently skipping protections.
if !ro_subpath.as_path().exists() {
return Err(CodexErr::UnsupportedOperation(format!(
"Sandbox expected to protect {path}, but it does not exist. Ensure the repository contains this path or create it before running Codex.",
path = ro_subpath.as_path().display()
)));
}
targets.push(ro_subpath.clone());
// Worktrees and submodules store `.git` as a pointer file; add the
// referenced gitdir as an extra read-only target.
if is_git_pointer_file(ro_subpath) {
let gitdir = resolve_gitdir_from_file(ro_subpath)?;
if !targets
.iter()
.any(|target| target.as_path() == gitdir.as_path())
{
targets.push(gitdir);
}
}
}
}
Ok(targets)
}
/// Detect a `.git` pointer file used by worktrees and submodules.
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(std::ffi::OsStr::new(".git"))
}
/// Resolve a worktree `.git` pointer file to its gitdir path.
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Result<AbsolutePathBuf> {
let contents = std::fs::read_to_string(dot_git.as_path()).map_err(CodexErr::from)?;
let trimmed = contents.trim();
let (_, gitdir_raw) = trimmed.split_once(':').ok_or_else(|| {
CodexErr::UnsupportedOperation(format!(
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
path = dot_git.as_path().display()
))
})?;
// `gitdir: <path>` may be relative to the directory containing `.git`.
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
return Err(CodexErr::UnsupportedOperation(format!(
"Expected {path} to contain a gitdir pointer, but it was empty.",
path = dot_git.as_path().display()
)));
}
let base = dot_git.as_path().parent().ok_or_else(|| {
CodexErr::UnsupportedOperation(format!(
"Unable to resolve parent directory for {path}.",
path = dot_git.as_path().display()
))
})?;
let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base)?;
if !gitdir_path.as_path().exists() {
return Err(CodexErr::UnsupportedOperation(format!(
"Resolved gitdir path {path} does not exist.",
path = gitdir_path.as_path().display()
)));
}
Ok(gitdir_path)
}
/// Unshare the mount namespace so mount changes are isolated to the sandboxed process.
fn unshare_mount_namespace() -> Result<()> {
let result = unsafe { libc::unshare(libc::CLONE_NEWNS) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Unshare user + mount namespaces so the process can remount read-only without privileges.
fn unshare_user_and_mount_namespaces() -> Result<()> {
let result = unsafe { libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
fn is_running_as_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
#[repr(C)]
struct CapUserHeader {
version: u32,
pid: i32,
}
#[repr(C)]
struct CapUserData {
effective: u32,
permitted: u32,
inheritable: u32,
}
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
/// Map the current uid/gid to root inside the user namespace.
fn write_user_namespace_maps() -> Result<()> {
write_proc_file("/proc/self/setgroups", "deny\n")?;
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
write_proc_file("/proc/self/uid_map", format!("0 {uid} 1\n"))?;
write_proc_file("/proc/self/gid_map", format!("0 {gid} 1\n"))?;
Ok(())
}
/// Drop all capabilities in the current user namespace.
fn drop_caps() -> Result<()> {
let mut header = CapUserHeader {
version: LINUX_CAPABILITY_VERSION_3,
pid: 0,
};
let data = [
CapUserData {
effective: 0,
permitted: 0,
inheritable: 0,
},
CapUserData {
effective: 0,
permitted: 0,
inheritable: 0,
},
];
// Use syscall directly to avoid libc capability symbols that are missing on musl.
let result = unsafe { libc::syscall(libc::SYS_capset, &mut header, data.as_ptr()) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Write a small procfs file, returning a sandbox error on failure.
fn write_proc_file(path: &str, contents: impl AsRef<[u8]>) -> Result<()> {
std::fs::write(path, contents)?;
Ok(())
}
/// Ensure mounts are private so remounting does not propagate outside the namespace.
fn make_mounts_private() -> Result<()> {
let root = CString::new("/").map_err(|_| {
CodexErr::UnsupportedOperation("Sandbox mount path contains NUL byte: /".to_string())
})?;
let result = unsafe {
libc::mount(
std::ptr::null(),
root.as_ptr(),
std::ptr::null(),
libc::MS_REC | libc::MS_PRIVATE,
std::ptr::null(),
)
};
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Bind-mount a path onto itself and remount read-only.
fn bind_mount_read_only(path: &Path) -> Result<()> {
let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
CodexErr::UnsupportedOperation(format!(
"Sandbox mount path contains NUL byte: {path}",
path = path.display()
))
})?;
let bind_result = unsafe {
libc::mount(
c_path.as_ptr(),
c_path.as_ptr(),
std::ptr::null(),
libc::MS_BIND,
std::ptr::null(),
)
};
if bind_result != 0 {
return Err(std::io::Error::last_os_error().into());
}
let remount_result = unsafe {
libc::mount(
c_path.as_ptr(),
c_path.as_ptr(),
std::ptr::null(),
libc::MS_BIND | libc::MS_REMOUNT | libc::MS_RDONLY,
std::ptr::null(),
)
};
if remount_result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}

View File

@@ -1,4 +1,5 @@
#![cfg(target_os = "linux")]
#![allow(clippy::unwrap_used)]
use codex_core::config::types::ShellEnvironmentPolicy;
use codex_core::error::CodexErr;
use codex_core::error::SandboxErr;
@@ -11,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use tokio::process::Command;
// At least on GitHub CI, the arm64 tests appear to need longer timeouts.
@@ -79,6 +81,51 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
}
}
#[expect(clippy::expect_used)]
async fn assert_write_blocked(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
let cwd = std::env::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(),
env: create_env_from_core_vars(),
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
arg0: None,
};
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots
.iter()
.map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap())
.collect(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
let result = process_exec_tool_call(
params,
&sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
None,
)
.await;
match result {
Ok(output) => {
if output.exit_code == 0 {
panic!("expected command to fail, but exit code was 0");
}
}
Err(CodexErr::Sandbox(SandboxErr::Denied { .. })) => {}
Err(err) => panic!("expected sandbox denial, got: {err:?}"),
}
}
#[tokio::test]
async fn test_root_read() {
run_cmd(&["ls", "-l", "/bin"], &[], SHORT_TIMEOUT_MS).await;
@@ -127,6 +174,134 @@ async fn test_writable_root() {
.await;
}
#[tokio::test]
async fn test_git_dir_write_blocked() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
Command::new("git")
.arg("init")
.arg(".")
.current_dir(repo_root)
.output()
.await
.expect("git init .");
let git_config = repo_root.join(".git").join("config");
let git_index_lock = repo_root.join(".git").join("index.lock");
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", git_config.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", git_index_lock.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_git_dir_move_blocked() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
Command::new("git")
.arg("init")
.arg(".")
.current_dir(repo_root)
.output()
.await
.expect("git init .");
let git_dir = repo_root.join(".git");
let git_dir_backup = repo_root.join(".git.bak");
assert_write_blocked(
&[
"bash",
"-lc",
&format!(
"mv {} {}",
git_dir.to_string_lossy(),
git_dir_backup.to_string_lossy()
),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_codex_dir_write_blocked() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
std::fs::create_dir_all(repo_root.join(".codex")).unwrap();
let codex_config = repo_root.join(".codex").join("config.toml");
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", codex_config.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_git_pointer_file_blocks_gitdir_writes() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
let gitdir = repo_root.join("actual-gitdir");
std::fs::create_dir_all(&gitdir).unwrap();
let gitdir_config = gitdir.join("config");
std::fs::write(&gitdir_config, "[core]\n\trepositoryformatversion = 0\n").unwrap();
std::fs::write(
repo_root.join(".git"),
format!("gitdir: {}\n", gitdir.to_string_lossy()),
)
.unwrap();
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", gitdir_config.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", repo_root.join(".git").to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
#[should_panic(expected = "Sandbox(Timeout")]
async fn test_timeout() {