Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Bolin
17de02504d feat: landlock respect git in writable root 2025-08-01 16:48:08 -07:00
5 changed files with 97 additions and 25 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -675,6 +675,7 @@ dependencies = [
"bytes",
"chrono",
"codex-apply-patch",
"codex-linux-sandbox",
"codex-login",
"codex-mcp-client",
"core_test_support",

View File

@@ -259,7 +259,7 @@ disk, but attempts to write a file or access the network will be blocked.
A more relaxed policy is `workspace-write`. When specified, the current working directory for the Codex task will be writable (as well as `$TMPDIR` on macOS). Note that the CLI defaults to using the directory where it was spawned as `cwd`, though this can be overridden using `--cwd/-C`.
On macOS (and soon Linux), all writable roots (including `cwd`) that contain a `.git/` folder _as an immediate child_ will configure the `.git/` folder to be read-only while the rest of the Git repository will be writable. This means that commands like `git commit` will fail, by default (as it entails writing to `.git/`), and will require Codex to ask for permission.
On macOS and Linux, all writable roots (including `cwd`) that contain a `.git/` folder _as an immediate child_ will configure the `.git/` folder to be read-only while the rest of the Git repository will be writable. This means that commands like `git commit` will fail, by default (as it entails writing to `.git/`), and will require Codex to ask for permission.
```toml
# same as `--sandbox workspace-write`

View File

@@ -76,3 +76,4 @@ tempfile = "3"
tokio-test = "0.4"
walkdir = "2.5.0"
wiremock = "0.6"
codex-linux-sandbox = { path = "../linux-sandbox" }

View File

@@ -1,11 +1,16 @@
#![cfg(target_os = "macos")]
#![cfg(any(target_os = "macos", target_os = "linux"))]
#![expect(clippy::expect_used)]
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
#[cfg(target_os = "linux")]
use assert_cmd::cargo::cargo_bin;
#[cfg(target_os = "linux")]
use codex_core::exec::spawn_command_under_linux_sandbox;
use codex_core::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use codex_core::seatbelt::spawn_command_under_seatbelt;
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
use codex_core::spawn::StdioPolicy;
@@ -32,6 +37,38 @@ impl TestScenario {
return;
}
#[cfg(target_os = "linux")]
{
// If the sandbox helper is not functional in this environment,
// skip the test rather than fail.
let exe = cargo_bin("codex-linux-sandbox");
let mut child = match spawn_command_under_linux_sandbox(
exe,
vec!["/usr/bin/true".to_string()],
&SandboxPolicy::ReadOnly,
std::env::current_dir().expect("should be able to get current dir"),
StdioPolicy::RedirectForShellTool,
HashMap::new(),
)
.await
{
Ok(child) => child,
Err(_) => {
eprintln!("codex-linux-sandbox unavailable, skipping test.");
return;
}
};
if !child
.wait()
.await
.expect("should be able to wait for child process")
.success()
{
eprintln!("codex-linux-sandbox failed, skipping test.");
return;
}
}
assert_eq!(
touch(&self.file_outside_repo, policy).await,
expectations.file_outside_repo_is_writable
@@ -175,21 +212,46 @@ fn create_test_scenario(tmp: &TempDir) -> TestScenario {
/// Note that `path` must be absolute.
async fn touch(path: &Path, policy: &SandboxPolicy) -> bool {
assert!(path.is_absolute(), "Path must be absolute: {path:?}");
let mut child = spawn_command_under_seatbelt(
vec![
"/usr/bin/touch".to_string(),
path.to_string_lossy().to_string(),
],
policy,
std::env::current_dir().expect("should be able to get current dir"),
StdioPolicy::RedirectForShellTool,
HashMap::new(),
)
.await
.expect("should be able to spawn command under seatbelt");
child
.wait()
#[cfg(target_os = "macos")]
{
let mut child = spawn_command_under_seatbelt(
vec![
"/usr/bin/touch".to_string(),
path.to_string_lossy().to_string(),
],
policy,
std::env::current_dir().expect("should be able to get current dir"),
StdioPolicy::RedirectForShellTool,
HashMap::new(),
)
.await
.expect("should be able to wait for child process")
.success()
.expect("should be able to spawn command under seatbelt");
child
.wait()
.await
.expect("should be able to wait for child process")
.success()
}
#[cfg(target_os = "linux")]
{
let exe = cargo_bin("codex-linux-sandbox");
let mut child = spawn_command_under_linux_sandbox(
exe,
vec![
"/usr/bin/touch".to_string(),
path.to_string_lossy().to_string(),
],
policy,
std::env::current_dir().expect("should be able to get current dir"),
StdioPolicy::RedirectForShellTool,
HashMap::new(),
)
.await
.expect("should be able to spawn command under linux sandbox");
child
.wait()
.await
.expect("should be able to wait for child process")
.success()
}
}

View File

@@ -6,6 +6,7 @@ use codex_core::error::CodexErr;
use codex_core::error::Result;
use codex_core::error::SandboxErr;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::WritableRoot;
use landlock::ABI;
use landlock::Access;
@@ -36,11 +37,7 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
}
if !sandbox_policy.has_full_disk_write_access() {
let writable_roots = sandbox_policy
.get_writable_roots_with_cwd(cwd)
.into_iter()
.map(|writable_root| writable_root.root)
.collect();
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
install_filesystem_landlock_rules_on_current_thread(writable_roots)?;
}
@@ -56,7 +53,9 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
///
/// # Errors
/// Returns [`CodexErr::Sandbox`] variants when the ruleset fails to apply.
fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathBuf>) -> Result<()> {
fn install_filesystem_landlock_rules_on_current_thread(
writable_roots: Vec<WritableRoot>,
) -> Result<()> {
let abi = ABI::V5;
let access_rw = AccessFs::from_all(abi);
let access_ro = AccessFs::from_read(abi);
@@ -70,7 +69,16 @@ fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathB
.set_no_new_privs(true);
if !writable_roots.is_empty() {
ruleset = ruleset.add_rules(landlock::path_beneath_rules(&writable_roots, access_rw))?;
let roots: Vec<PathBuf> = writable_roots.iter().map(|wr| wr.root.clone()).collect();
ruleset = ruleset.add_rules(landlock::path_beneath_rules(&roots, access_rw))?;
let read_only: Vec<PathBuf> = writable_roots
.into_iter()
.flat_map(|wr| wr.read_only_subpaths.into_iter())
.collect();
if !read_only.is_empty() {
ruleset = ruleset.add_rules(landlock::path_beneath_rules(&read_only, access_ro))?;
}
}
let status = ruleset.restrict_self()?;