mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
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:
@@ -10,6 +10,10 @@ Note that `codex-core` makes some assumptions about certain helper utilities bei
|
||||
|
||||
Expects `/usr/bin/sandbox-exec` to be present.
|
||||
|
||||
When using the workspace-write sandbox policy, the Seatbelt profile allows
|
||||
writes under the configured writable roots while keeping `.git` (directory or
|
||||
pointer file), the resolved `gitdir:` target, and `.codex` read-only.
|
||||
|
||||
### Linux
|
||||
|
||||
Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details.
|
||||
|
||||
@@ -174,6 +174,16 @@ mod tests {
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn assert_seatbelt_denied(stderr: &[u8], path: &Path) {
|
||||
let stderr = String::from_utf8_lossy(stderr);
|
||||
let expected = format!("bash: {}: Operation not permitted\n", path.display());
|
||||
assert!(
|
||||
stderr == expected
|
||||
|| stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted"),
|
||||
"unexpected stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
|
||||
// Create a temporary workspace with two writable roots: one containing
|
||||
@@ -290,10 +300,7 @@ mod tests {
|
||||
"command to write {} should fail under seatbelt",
|
||||
&config_toml.display()
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
format!("bash: {}: Operation not permitted\n", config_toml.display()),
|
||||
);
|
||||
assert_seatbelt_denied(&output.stderr, &config_toml);
|
||||
|
||||
// Create a similar Seatbelt command that tries to write to a file in
|
||||
// the .git folder, which should also be blocked.
|
||||
@@ -324,13 +331,7 @@ mod tests {
|
||||
"command to write {} should fail under seatbelt",
|
||||
&pre_commit_hook.display()
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
format!(
|
||||
"bash: {}: Operation not permitted\n",
|
||||
pre_commit_hook.display()
|
||||
),
|
||||
);
|
||||
assert_seatbelt_denied(&output.stderr, &pre_commit_hook);
|
||||
|
||||
// Verify that writing a file to the folder containing .git and .codex is allowed.
|
||||
let allowed_file = vulnerable_root_canonical.join("allowed.txt");
|
||||
@@ -351,6 +352,12 @@ mod tests {
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("execute seatbelt command");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success()
|
||||
&& stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted")
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"command to write {} should succeed under seatbelt",
|
||||
@@ -364,6 +371,91 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_seatbelt_args_with_read_only_git_pointer_file() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let worktree_root = tmp.path().join("worktree_root");
|
||||
fs::create_dir_all(&worktree_root).expect("create worktree_root");
|
||||
let gitdir = worktree_root.join("actual-gitdir");
|
||||
fs::create_dir_all(&gitdir).expect("create gitdir");
|
||||
let gitdir_config = gitdir.join("config");
|
||||
let gitdir_config_contents = "[core]\n";
|
||||
fs::write(&gitdir_config, gitdir_config_contents).expect("write gitdir config");
|
||||
|
||||
let dot_git = worktree_root.join(".git");
|
||||
let dot_git_contents = format!("gitdir: {}\n", gitdir.to_string_lossy());
|
||||
fs::write(&dot_git, &dot_git_contents).expect("write .git pointer");
|
||||
|
||||
let cwd = tmp.path().join("cwd");
|
||||
fs::create_dir_all(&cwd).expect("create cwd");
|
||||
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
let shell_command: Vec<String> = [
|
||||
"bash",
|
||||
"-c",
|
||||
"echo 'pwned!' > \"$1\"",
|
||||
"bash",
|
||||
dot_git.to_string_lossy().as_ref(),
|
||||
]
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let args = create_seatbelt_command_args(shell_command, &policy, &cwd);
|
||||
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&args)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("execute seatbelt command");
|
||||
|
||||
assert_eq!(
|
||||
dot_git_contents,
|
||||
String::from_utf8_lossy(&fs::read(&dot_git).expect("read .git pointer")),
|
||||
".git pointer file should not be modified under seatbelt"
|
||||
);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"command to write {} should fail under seatbelt",
|
||||
dot_git.display()
|
||||
);
|
||||
assert_seatbelt_denied(&output.stderr, &dot_git);
|
||||
|
||||
let shell_command_gitdir: Vec<String> = [
|
||||
"bash",
|
||||
"-c",
|
||||
"echo 'pwned!' > \"$1\"",
|
||||
"bash",
|
||||
gitdir_config.to_string_lossy().as_ref(),
|
||||
]
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let gitdir_args = create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd);
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&gitdir_args)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("execute seatbelt command");
|
||||
|
||||
assert_eq!(
|
||||
gitdir_config_contents,
|
||||
String::from_utf8_lossy(&fs::read(&gitdir_config).expect("read gitdir config")),
|
||||
"gitdir config should contain its original contents because it should not have been modified"
|
||||
);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"command to write {} should fail under seatbelt",
|
||||
gitdir_config.display()
|
||||
);
|
||||
assert_seatbelt_denied(&output.stderr, &gitdir_config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_seatbelt_args_for_cwd_as_git_repo() {
|
||||
// Create a temporary workspace with two writable roots: one containing
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()?;
|
||||
}
|
||||
|
||||
@@ -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() -> ! {
|
||||
|
||||
255
codex-rs/linux-sandbox/src/mounts.rs
Normal file
255
codex-rs/linux-sandbox/src/mounts.rs
Normal 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(())
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! between user and agent.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -505,12 +506,26 @@ impl SandboxPolicy {
|
||||
roots
|
||||
.into_iter()
|
||||
.map(|writable_root| {
|
||||
let mut subpaths = Vec::new();
|
||||
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
|
||||
#[allow(clippy::expect_used)]
|
||||
let top_level_git = writable_root
|
||||
.join(".git")
|
||||
.expect(".git is a valid relative path");
|
||||
if top_level_git.as_path().is_dir() {
|
||||
// This applies to typical repos (directory .git), worktrees/submodules
|
||||
// (file .git with gitdir pointer), and bare repos when the gitdir is the
|
||||
// writable root itself.
|
||||
let top_level_git_is_file = top_level_git.as_path().is_file();
|
||||
let top_level_git_is_dir = top_level_git.as_path().is_dir();
|
||||
if top_level_git_is_dir || top_level_git_is_file {
|
||||
if top_level_git_is_file
|
||||
&& is_git_pointer_file(&top_level_git)
|
||||
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
|
||||
&& !subpaths
|
||||
.iter()
|
||||
.any(|subpath| subpath.as_path() == gitdir.as_path())
|
||||
{
|
||||
subpaths.push(gitdir);
|
||||
}
|
||||
subpaths.push(top_level_git);
|
||||
}
|
||||
#[allow(clippy::expect_used)]
|
||||
@@ -531,6 +546,71 @@ impl SandboxPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
|
||||
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
|
||||
}
|
||||
|
||||
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
|
||||
let contents = match std::fs::read_to_string(dot_git.as_path()) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to read {path} for gitdir pointer: {err}",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let trimmed = contents.trim();
|
||||
let (_, gitdir_raw) = match trimmed.split_once(':') {
|
||||
Some(parts) => parts,
|
||||
None => {
|
||||
error!(
|
||||
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let gitdir_raw = gitdir_raw.trim();
|
||||
if gitdir_raw.is_empty() {
|
||||
error!(
|
||||
"Expected {path} to contain a gitdir pointer, but it was empty.",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let base = match dot_git.as_path().parent() {
|
||||
Some(base) => base,
|
||||
None => {
|
||||
error!(
|
||||
"Unable to resolve parent directory for {path}.",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let gitdir_path = match AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to resolve gitdir path {gitdir_raw} from {path}: {err}",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !gitdir_path.as_path().exists() {
|
||||
error!(
|
||||
"Resolved gitdir path {path} does not exist.",
|
||||
path = gitdir_path.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(gitdir_path)
|
||||
}
|
||||
|
||||
/// Event Queue Entry - events from agent
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Event {
|
||||
|
||||
Reference in New Issue
Block a user