mirror of
https://github.com/openai/codex.git
synced 2026-05-03 02:46:39 +00:00
Compare commits
9 Commits
dev/mzeng/
...
codex/bugb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a6d857af7 | ||
|
|
19d6493b7a | ||
|
|
a7ce8ab3ad | ||
|
|
b733879c4b | ||
|
|
d9a8d4d98a | ||
|
|
1972bd8ff0 | ||
|
|
1a1337e276 | ||
|
|
c5ddf213fe | ||
|
|
90d5f4304f |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2122,6 +2122,7 @@ dependencies = [
|
||||
"codex-rmcp-client",
|
||||
"codex-rollout-trace",
|
||||
"codex-sandboxing",
|
||||
"codex-shell-command",
|
||||
"codex-state",
|
||||
"codex-stdio-to-uds",
|
||||
"codex-terminal-detection",
|
||||
|
||||
@@ -43,6 +43,7 @@ codex-responses-api-proxy = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-rollout-trace = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-stdio-to-uds = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
|
||||
@@ -35,6 +35,10 @@ use crate::exit_status::handle_exit_status;
|
||||
#[cfg(target_os = "macos")]
|
||||
use seatbelt::DenialLogger;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const LINUX_SANDBOX_FORWARDED_SIGNALS: &[libc::c_int] =
|
||||
&[libc::SIGHUP, libc::SIGINT, libc::SIGQUIT, libc::SIGTERM];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn run_command_under_seatbelt(
|
||||
command: SeatbeltCommand,
|
||||
@@ -142,6 +146,13 @@ async fn run_command_under_sandbox(
|
||||
// sandbox policy. In the future, we could add a CLI option to set them
|
||||
// separately.
|
||||
let sandbox_policy_cwd = cwd.clone();
|
||||
if let Some(reason) = codex_shell_command::preserved_path_write_forbidden_reason(
|
||||
&command,
|
||||
cwd.as_path(),
|
||||
&config.permissions.file_system_sandbox_policy(),
|
||||
) {
|
||||
anyhow::bail!("{reason}");
|
||||
}
|
||||
|
||||
let env = create_env(
|
||||
&config.permissions.shell_environment_policy,
|
||||
@@ -261,6 +272,9 @@ async fn run_command_under_sandbox(
|
||||
denial_logger.on_child_spawn(&child);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let status = wait_for_debug_sandbox_child(&mut child).await?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let status = child.wait().await?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -438,6 +452,27 @@ async fn spawn_debug_sandbox_child(
|
||||
cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let parent_pid = unsafe { libc::getpid() };
|
||||
// SAFETY: `pre_exec` runs in the child immediately before exec. The
|
||||
// closure only adjusts the child signal mask, installs a parent-death
|
||||
// signal, and checks the inherited parent pid to close the fork/exec
|
||||
// race.
|
||||
unsafe {
|
||||
cmd.pre_exec(move || {
|
||||
block_linux_sandbox_forwarded_signals()?;
|
||||
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if libc::getppid() != parent_pid {
|
||||
libc::raise(libc::SIGTERM);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
@@ -445,6 +480,68 @@ async fn spawn_debug_sandbox_child(
|
||||
.spawn()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn wait_for_debug_sandbox_child(
|
||||
child: &mut Child,
|
||||
) -> std::io::Result<std::process::ExitStatus> {
|
||||
let child_pid = child.id().map(|pid| pid as libc::pid_t);
|
||||
tokio::select! {
|
||||
status = child.wait() => status,
|
||||
signal = recv_linux_sandbox_forwarded_signal() => {
|
||||
let signal = signal?;
|
||||
if let Some(child_pid) = child_pid {
|
||||
signal_debug_sandbox_child(child_pid, signal)?;
|
||||
}
|
||||
child.wait().await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn recv_linux_sandbox_forwarded_signal() -> std::io::Result<libc::c_int> {
|
||||
use tokio::signal::unix::SignalKind;
|
||||
use tokio::signal::unix::signal;
|
||||
|
||||
let mut sighup = signal(SignalKind::hangup())?;
|
||||
let mut sigint = signal(SignalKind::interrupt())?;
|
||||
let mut sigquit = signal(SignalKind::quit())?;
|
||||
let mut sigterm = signal(SignalKind::terminate())?;
|
||||
|
||||
let signal = tokio::select! {
|
||||
_ = sighup.recv() => libc::SIGHUP,
|
||||
_ = sigint.recv() => libc::SIGINT,
|
||||
_ = sigquit.recv() => libc::SIGQUIT,
|
||||
_ = sigterm.recv() => libc::SIGTERM,
|
||||
};
|
||||
Ok(signal)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn signal_debug_sandbox_child(pid: libc::pid_t, signal: libc::c_int) -> std::io::Result<()> {
|
||||
if unsafe { libc::kill(pid, signal) } < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() != Some(libc::ESRCH) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn block_linux_sandbox_forwarded_signals() -> std::io::Result<()> {
|
||||
let mut blocked: libc::sigset_t = unsafe { std::mem::zeroed() };
|
||||
unsafe {
|
||||
libc::sigemptyset(&mut blocked);
|
||||
for signal in LINUX_SANDBOX_FORWARDED_SIGNALS {
|
||||
libc::sigaddset(&mut blocked, *signal);
|
||||
}
|
||||
if libc::sigprocmask(libc::SIG_BLOCK, &blocked, std::ptr::null_mut()) < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_stdio_bridge {
|
||||
use std::io::Read;
|
||||
|
||||
@@ -531,6 +531,14 @@ impl ShellHandler {
|
||||
prefix_rule,
|
||||
})
|
||||
.await;
|
||||
let exec_approval_requirement = codex_shell_command::preserved_path_write_forbidden_reason(
|
||||
&exec_params.command,
|
||||
&exec_params.cwd,
|
||||
&file_system_sandbox_policy,
|
||||
)
|
||||
.map_or(exec_approval_requirement, |reason| {
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Forbidden { reason }
|
||||
});
|
||||
|
||||
let req = ShellRequest {
|
||||
command: exec_params.command.clone(),
|
||||
|
||||
@@ -15,15 +15,18 @@ use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::Metadata;
|
||||
use std::io;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::permissions::is_preserved_path_name;
|
||||
use codex_protocol::protocol::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::WritableRoot;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -101,6 +104,121 @@ impl BwrapNetworkMode {
|
||||
pub(crate) struct BwrapArgs {
|
||||
pub args: Vec<String>,
|
||||
pub preserved_files: Vec<File>,
|
||||
pub synthetic_mount_targets: Vec<SyntheticMountTarget>,
|
||||
pub protected_create_targets: Vec<ProtectedCreateTarget>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct FileIdentity {
|
||||
dev: u64,
|
||||
ino: u64,
|
||||
}
|
||||
|
||||
impl FileIdentity {
|
||||
fn from_metadata(metadata: &Metadata) -> Self {
|
||||
Self {
|
||||
dev: metadata.dev(),
|
||||
ino: metadata.ino(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum SyntheticMountTargetKind {
|
||||
EmptyFile,
|
||||
EmptyDirectory,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct SyntheticMountTarget {
|
||||
path: PathBuf,
|
||||
kind: SyntheticMountTargetKind,
|
||||
// If an empty preserved path was already present, remember its inode so
|
||||
// cleanup does not delete a real pre-existing file or directory.
|
||||
pre_existing_path: Option<FileIdentity>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ProtectedCreateTarget {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl ProtectedCreateTarget {
|
||||
pub(crate) fn missing(path: &Path) -> Self {
|
||||
Self {
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl SyntheticMountTarget {
|
||||
pub(crate) fn missing(path: &Path) -> Self {
|
||||
Self {
|
||||
path: path.to_path_buf(),
|
||||
kind: SyntheticMountTargetKind::EmptyFile,
|
||||
pre_existing_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn missing_empty_directory(path: &Path) -> Self {
|
||||
Self {
|
||||
path: path.to_path_buf(),
|
||||
kind: SyntheticMountTargetKind::EmptyDirectory,
|
||||
pre_existing_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn existing_empty_file(path: &Path, metadata: &Metadata) -> Self {
|
||||
Self {
|
||||
path: path.to_path_buf(),
|
||||
kind: SyntheticMountTargetKind::EmptyFile,
|
||||
pre_existing_path: Some(FileIdentity::from_metadata(metadata)),
|
||||
}
|
||||
}
|
||||
|
||||
fn existing_empty_directory(path: &Path, metadata: &Metadata) -> Self {
|
||||
Self {
|
||||
path: path.to_path_buf(),
|
||||
kind: SyntheticMountTargetKind::EmptyDirectory,
|
||||
pre_existing_path: Some(FileIdentity::from_metadata(metadata)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn preserves_pre_existing_path(&self) -> bool {
|
||||
self.pre_existing_path.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub(crate) fn kind(&self) -> SyntheticMountTargetKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub(crate) fn should_remove_after_bwrap(&self, metadata: &Metadata) -> bool {
|
||||
match self.kind {
|
||||
SyntheticMountTargetKind::EmptyFile => {
|
||||
if !metadata.file_type().is_file() || metadata.len() != 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
SyntheticMountTargetKind::EmptyDirectory => {
|
||||
if !metadata.file_type().is_dir() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.pre_existing_path {
|
||||
Some(pre_existing_path) => pre_existing_path != FileIdentity::from_metadata(metadata),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a command with bubblewrap so the filesystem is read-only by default,
|
||||
@@ -126,6 +244,8 @@ pub(crate) fn create_bwrap_command_args(
|
||||
Ok(BwrapArgs {
|
||||
args: command,
|
||||
preserved_files: Vec::new(),
|
||||
synthetic_mount_targets: Vec::new(),
|
||||
protected_create_targets: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
Ok(create_bwrap_flags_full_filesystem(command, options))
|
||||
@@ -165,6 +285,8 @@ fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOption
|
||||
BwrapArgs {
|
||||
args,
|
||||
preserved_files: Vec::new(),
|
||||
synthetic_mount_targets: Vec::new(),
|
||||
protected_create_targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +301,8 @@ fn create_bwrap_flags(
|
||||
let BwrapArgs {
|
||||
args: filesystem_args,
|
||||
preserved_files,
|
||||
synthetic_mount_targets,
|
||||
protected_create_targets,
|
||||
} = create_filesystem_args(
|
||||
file_system_sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
@@ -216,6 +340,8 @@ fn create_bwrap_flags(
|
||||
Ok(BwrapArgs {
|
||||
args,
|
||||
preserved_files,
|
||||
synthetic_mount_targets,
|
||||
protected_create_targets,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -256,6 +382,7 @@ fn create_filesystem_args(
|
||||
writable_roots.push(WritableRoot {
|
||||
root: AbsolutePathBuf::from_absolute_path("/")?,
|
||||
read_only_subpaths: Vec::new(),
|
||||
preserved_path_names: Vec::new(),
|
||||
});
|
||||
}
|
||||
let mut unreadable_roots = file_system_sandbox_policy
|
||||
@@ -274,7 +401,7 @@ fn create_filesystem_args(
|
||||
unreadable_roots.sort();
|
||||
unreadable_roots.dedup();
|
||||
|
||||
let mut args = if file_system_sandbox_policy.has_full_disk_read_access() {
|
||||
let args = if file_system_sandbox_policy.has_full_disk_read_access() {
|
||||
// Read-only root, then mount a minimal device tree.
|
||||
// In bubblewrap (`bubblewrap.c`, `SETUP_MOUNT_DEV`), `--dev /dev`
|
||||
// creates the standard minimal nodes: null, zero, full, random,
|
||||
@@ -347,7 +474,12 @@ fn create_filesystem_args(
|
||||
|
||||
args
|
||||
};
|
||||
let mut preserved_files = Vec::new();
|
||||
let mut bwrap_args = BwrapArgs {
|
||||
args,
|
||||
preserved_files: Vec::new(),
|
||||
synthetic_mount_targets: Vec::new(),
|
||||
protected_create_targets: Vec::new(),
|
||||
};
|
||||
let mut allowed_write_paths = Vec::with_capacity(writable_roots.len());
|
||||
for writable_root in &writable_roots {
|
||||
let root = writable_root.root.as_path();
|
||||
@@ -378,12 +510,7 @@ fn create_filesystem_args(
|
||||
unreadable_ancestors_of_writable_roots.sort_by_key(|path| path_depth(path));
|
||||
|
||||
for unreadable_root in &unreadable_ancestors_of_writable_roots {
|
||||
append_unreadable_root_args(
|
||||
&mut args,
|
||||
&mut preserved_files,
|
||||
unreadable_root,
|
||||
&allowed_write_paths,
|
||||
)?;
|
||||
append_unreadable_root_args(&mut bwrap_args, unreadable_root, &allowed_write_paths)?;
|
||||
}
|
||||
|
||||
for writable_root in &sorted_writable_roots {
|
||||
@@ -397,13 +524,13 @@ fn create_filesystem_args(
|
||||
.filter(|unreadable_root| root.starts_with(unreadable_root))
|
||||
.max_by_key(|unreadable_root| path_depth(unreadable_root))
|
||||
{
|
||||
append_mount_target_parent_dir_args(&mut args, root, masking_root);
|
||||
append_mount_target_parent_dir_args(&mut bwrap_args.args, root, masking_root);
|
||||
}
|
||||
|
||||
let mount_root = symlink_target.as_deref().unwrap_or(root);
|
||||
args.push("--bind".to_string());
|
||||
args.push(path_to_string(mount_root));
|
||||
args.push(path_to_string(mount_root));
|
||||
bwrap_args.args.push("--bind".to_string());
|
||||
bwrap_args.args.push(path_to_string(mount_root));
|
||||
bwrap_args.args.push(path_to_string(mount_root));
|
||||
|
||||
let mut read_only_subpaths: Vec<PathBuf> = writable_root
|
||||
.read_only_subpaths
|
||||
@@ -414,9 +541,16 @@ fn create_filesystem_args(
|
||||
if let Some(target) = &symlink_target {
|
||||
read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target);
|
||||
}
|
||||
append_protected_create_targets_for_writable_root(
|
||||
&mut bwrap_args,
|
||||
writable_root,
|
||||
root,
|
||||
symlink_target.as_deref(),
|
||||
&read_only_subpaths,
|
||||
);
|
||||
read_only_subpaths.sort_by_key(|path| path_depth(path));
|
||||
for subpath in read_only_subpaths {
|
||||
append_read_only_subpath_args(&mut args, &subpath, &allowed_write_paths)?;
|
||||
append_read_only_subpath_args(&mut bwrap_args, &subpath, &allowed_write_paths)?;
|
||||
}
|
||||
let mut nested_unreadable_roots: Vec<PathBuf> = unreadable_roots
|
||||
.iter()
|
||||
@@ -429,12 +563,7 @@ fn create_filesystem_args(
|
||||
}
|
||||
nested_unreadable_roots.sort_by_key(|path| path_depth(path));
|
||||
for unreadable_root in nested_unreadable_roots {
|
||||
append_unreadable_root_args(
|
||||
&mut args,
|
||||
&mut preserved_files,
|
||||
&unreadable_root,
|
||||
&allowed_write_paths,
|
||||
)?;
|
||||
append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,18 +579,33 @@ fn create_filesystem_args(
|
||||
.collect();
|
||||
rootless_unreadable_roots.sort_by_key(|path| path_depth(path));
|
||||
for unreadable_root in rootless_unreadable_roots {
|
||||
append_unreadable_root_args(
|
||||
&mut args,
|
||||
&mut preserved_files,
|
||||
&unreadable_root,
|
||||
&allowed_write_paths,
|
||||
)?;
|
||||
append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?;
|
||||
}
|
||||
|
||||
Ok(BwrapArgs {
|
||||
args,
|
||||
preserved_files,
|
||||
})
|
||||
Ok(bwrap_args)
|
||||
}
|
||||
|
||||
fn append_protected_create_targets_for_writable_root(
|
||||
bwrap_args: &mut BwrapArgs,
|
||||
writable_root: &WritableRoot,
|
||||
root: &Path,
|
||||
symlink_target: Option<&Path>,
|
||||
read_only_subpaths: &[PathBuf],
|
||||
) {
|
||||
for name in &writable_root.preserved_path_names {
|
||||
let mut path = root.join(name);
|
||||
if let Some(target) = symlink_target
|
||||
&& let Ok(relative_path) = path.strip_prefix(root)
|
||||
{
|
||||
path = target.join(relative_path);
|
||||
}
|
||||
if read_only_subpaths.iter().any(|subpath| subpath == &path) || path.exists() {
|
||||
continue;
|
||||
}
|
||||
bwrap_args
|
||||
.protected_create_targets
|
||||
.push(ProtectedCreateTarget::missing(&path));
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_unreadable_globs_with_ripgrep(
|
||||
@@ -786,7 +930,7 @@ fn append_mount_target_parent_dir_args(args: &mut Vec<String>, mount_target: &Pa
|
||||
}
|
||||
|
||||
fn append_read_only_subpath_args(
|
||||
args: &mut Vec<String>,
|
||||
bwrap_args: &mut BwrapArgs,
|
||||
subpath: &Path,
|
||||
allowed_write_paths: &[PathBuf],
|
||||
) -> Result<()> {
|
||||
@@ -804,28 +948,107 @@ fn append_read_only_subpath_args(
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(metadata) = transient_empty_preserved_path_metadata(subpath)
|
||||
&& is_within_allowed_write_paths(subpath, allowed_write_paths)
|
||||
{
|
||||
// Another concurrent bwrap setup can leave an empty mount target at
|
||||
// a missing preserved path. Treat it like the missing case instead of
|
||||
// binding that transient host path as the stable source.
|
||||
match metadata {
|
||||
EmptyPreservedPathMetadata::File(metadata) => {
|
||||
append_existing_empty_file_bind_data_args(bwrap_args, subpath, &metadata)?;
|
||||
}
|
||||
EmptyPreservedPathMetadata::Directory(metadata) => {
|
||||
append_existing_empty_directory_args(bwrap_args, subpath, &metadata);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !subpath.exists() {
|
||||
if let Some(first_missing_component) = find_first_non_existent_component(subpath)
|
||||
&& is_within_allowed_write_paths(&first_missing_component, allowed_write_paths)
|
||||
{
|
||||
args.push("--ro-bind".to_string());
|
||||
args.push("/dev/null".to_string());
|
||||
args.push(path_to_string(&first_missing_component));
|
||||
append_missing_read_only_subpath_args(bwrap_args, &first_missing_component)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_within_allowed_write_paths(subpath, allowed_write_paths) {
|
||||
args.push("--ro-bind".to_string());
|
||||
args.push(path_to_string(subpath));
|
||||
args.push(path_to_string(subpath));
|
||||
bwrap_args.args.push("--ro-bind".to_string());
|
||||
bwrap_args.args.push(path_to_string(subpath));
|
||||
bwrap_args.args.push(path_to_string(subpath));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_empty_file_bind_data_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> {
|
||||
if bwrap_args.preserved_files.is_empty() {
|
||||
bwrap_args.preserved_files.push(File::open("/dev/null")?);
|
||||
}
|
||||
let null_fd = bwrap_args.preserved_files[0].as_raw_fd().to_string();
|
||||
bwrap_args.args.push("--ro-bind-data".to_string());
|
||||
bwrap_args.args.push(null_fd);
|
||||
bwrap_args.args.push(path_to_string(path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_empty_directory_args(bwrap_args: &mut BwrapArgs, path: &Path) {
|
||||
bwrap_args.args.push("--perms".to_string());
|
||||
bwrap_args.args.push("555".to_string());
|
||||
bwrap_args.args.push("--tmpfs".to_string());
|
||||
bwrap_args.args.push(path_to_string(path));
|
||||
bwrap_args.args.push("--remount-ro".to_string());
|
||||
bwrap_args.args.push(path_to_string(path));
|
||||
}
|
||||
|
||||
fn append_missing_read_only_subpath_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> {
|
||||
if path.file_name().is_some_and(is_preserved_path_name) {
|
||||
append_empty_directory_args(bwrap_args, path);
|
||||
bwrap_args
|
||||
.synthetic_mount_targets
|
||||
.push(SyntheticMountTarget::missing_empty_directory(path));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
append_missing_empty_file_bind_data_args(bwrap_args, path)
|
||||
}
|
||||
|
||||
fn append_missing_empty_file_bind_data_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> {
|
||||
append_empty_file_bind_data_args(bwrap_args, path)?;
|
||||
bwrap_args
|
||||
.synthetic_mount_targets
|
||||
.push(SyntheticMountTarget::missing(path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_existing_empty_file_bind_data_args(
|
||||
bwrap_args: &mut BwrapArgs,
|
||||
path: &Path,
|
||||
metadata: &Metadata,
|
||||
) -> Result<()> {
|
||||
append_empty_file_bind_data_args(bwrap_args, path)?;
|
||||
bwrap_args
|
||||
.synthetic_mount_targets
|
||||
.push(SyntheticMountTarget::existing_empty_file(path, metadata));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_existing_empty_directory_args(
|
||||
bwrap_args: &mut BwrapArgs,
|
||||
path: &Path,
|
||||
metadata: &Metadata,
|
||||
) {
|
||||
append_empty_directory_args(bwrap_args, path);
|
||||
bwrap_args
|
||||
.synthetic_mount_targets
|
||||
.push(SyntheticMountTarget::existing_empty_directory(
|
||||
path, metadata,
|
||||
));
|
||||
}
|
||||
|
||||
fn append_unreadable_root_args(
|
||||
args: &mut Vec<String>,
|
||||
preserved_files: &mut Vec<File>,
|
||||
bwrap_args: &mut BwrapArgs,
|
||||
unreadable_root: &Path,
|
||||
allowed_write_paths: &[PathBuf],
|
||||
) -> Result<()> {
|
||||
@@ -850,24 +1073,16 @@ fn append_unreadable_root_args(
|
||||
if let Some(first_missing_component) = find_first_non_existent_component(unreadable_root)
|
||||
&& is_within_allowed_write_paths(&first_missing_component, allowed_write_paths)
|
||||
{
|
||||
args.push("--ro-bind".to_string());
|
||||
args.push("/dev/null".to_string());
|
||||
args.push(path_to_string(&first_missing_component));
|
||||
append_missing_empty_file_bind_data_args(bwrap_args, &first_missing_component)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
append_existing_unreadable_path_args(
|
||||
args,
|
||||
preserved_files,
|
||||
unreadable_root,
|
||||
allowed_write_paths,
|
||||
)
|
||||
append_existing_unreadable_path_args(bwrap_args, unreadable_root, allowed_write_paths)
|
||||
}
|
||||
|
||||
fn append_existing_unreadable_path_args(
|
||||
args: &mut Vec<String>,
|
||||
preserved_files: &mut Vec<File>,
|
||||
bwrap_args: &mut BwrapArgs,
|
||||
unreadable_root: &Path,
|
||||
allowed_write_paths: &[PathBuf],
|
||||
) -> Result<()> {
|
||||
@@ -877,40 +1092,37 @@ fn append_existing_unreadable_path_args(
|
||||
.map(PathBuf::as_path)
|
||||
.filter(|path| *path != unreadable_root && path.starts_with(unreadable_root))
|
||||
.collect();
|
||||
args.push("--perms".to_string());
|
||||
bwrap_args.args.push("--perms".to_string());
|
||||
// Execute-only perms let the process traverse into explicitly
|
||||
// re-opened writable descendants while still hiding the denied
|
||||
// directory contents. Plain denied directories with no writable child
|
||||
// mounts stay at `000`.
|
||||
args.push(if writable_descendants.is_empty() {
|
||||
bwrap_args.args.push(if writable_descendants.is_empty() {
|
||||
"000".to_string()
|
||||
} else {
|
||||
"111".to_string()
|
||||
});
|
||||
args.push("--tmpfs".to_string());
|
||||
args.push(path_to_string(unreadable_root));
|
||||
bwrap_args.args.push("--tmpfs".to_string());
|
||||
bwrap_args.args.push(path_to_string(unreadable_root));
|
||||
// Recreate any writable descendants inside the tmpfs before remounting
|
||||
// the denied parent read-only. Otherwise bubblewrap cannot mkdir the
|
||||
// nested mount targets after the parent has been frozen.
|
||||
writable_descendants.sort_by_key(|path| path_depth(path));
|
||||
for writable_descendant in writable_descendants {
|
||||
append_mount_target_parent_dir_args(args, writable_descendant, unreadable_root);
|
||||
append_mount_target_parent_dir_args(
|
||||
&mut bwrap_args.args,
|
||||
writable_descendant,
|
||||
unreadable_root,
|
||||
);
|
||||
}
|
||||
args.push("--remount-ro".to_string());
|
||||
args.push(path_to_string(unreadable_root));
|
||||
bwrap_args.args.push("--remount-ro".to_string());
|
||||
bwrap_args.args.push(path_to_string(unreadable_root));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if preserved_files.is_empty() {
|
||||
preserved_files.push(File::open("/dev/null")?);
|
||||
}
|
||||
let null_fd = preserved_files[0].as_raw_fd().to_string();
|
||||
args.push("--perms".to_string());
|
||||
args.push("000".to_string());
|
||||
args.push("--ro-bind-data".to_string());
|
||||
args.push(null_fd);
|
||||
args.push(path_to_string(unreadable_root));
|
||||
Ok(())
|
||||
bwrap_args.args.push("--perms".to_string());
|
||||
bwrap_args.args.push("000".to_string());
|
||||
append_empty_file_bind_data_args(bwrap_args, unreadable_root)
|
||||
}
|
||||
|
||||
/// Returns true when `path` is under any allowed writable root.
|
||||
@@ -920,6 +1132,35 @@ fn is_within_allowed_write_paths(path: &Path, allowed_write_paths: &[PathBuf]) -
|
||||
.any(|root| path.starts_with(root))
|
||||
}
|
||||
|
||||
enum EmptyPreservedPathMetadata {
|
||||
File(Metadata),
|
||||
Directory(Metadata),
|
||||
}
|
||||
|
||||
fn transient_empty_preserved_path_metadata(path: &Path) -> Option<EmptyPreservedPathMetadata> {
|
||||
if !path.file_name().is_some_and(is_preserved_path_name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_file() && metadata.len() == 0 {
|
||||
return Some(EmptyPreservedPathMetadata::File(metadata));
|
||||
}
|
||||
|
||||
if metadata.file_type().is_dir() && directory_is_empty(path) {
|
||||
return Some(EmptyPreservedPathMetadata::Directory(metadata));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn directory_is_empty(path: &Path) -> bool {
|
||||
let Ok(mut entries) = fs::read_dir(path) else {
|
||||
return false;
|
||||
};
|
||||
entries.next().is_none()
|
||||
}
|
||||
|
||||
fn first_writable_symlink_component_in_path(
|
||||
target_path: &Path,
|
||||
allowed_write_paths: &[PathBuf],
|
||||
@@ -997,6 +1238,7 @@ fn find_first_non_existent_component(target_path: &Path) -> Option<PathBuf> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use codex_protocol::protocol::FileSystemAccessMode;
|
||||
use codex_protocol::protocol::FileSystemPath;
|
||||
use codex_protocol::protocol::FileSystemSandboxEntry;
|
||||
@@ -1358,6 +1600,132 @@ mod tests {
|
||||
assert!(message.contains(&real_linked_private_str), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_read_only_subpath_uses_empty_file_bind_data() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let workspace = temp_dir.path().join("workspace");
|
||||
let blocked = workspace.join("blocked");
|
||||
std::fs::create_dir_all(&workspace).expect("create workspace");
|
||||
|
||||
let workspace_root =
|
||||
AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace");
|
||||
let blocked_root = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: workspace_root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: blocked_root },
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
|
||||
let args =
|
||||
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
|
||||
.expect("filesystem args");
|
||||
|
||||
assert_empty_file_bound_without_perms(&args.args, &blocked);
|
||||
assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".git"));
|
||||
assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents"));
|
||||
assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex"));
|
||||
assert_eq!(args.preserved_files.len(), 1);
|
||||
assert_eq!(
|
||||
synthetic_mount_target_paths(&args),
|
||||
vec![
|
||||
workspace.join(".git"),
|
||||
workspace.join(".agents"),
|
||||
workspace.join(".codex"),
|
||||
blocked.clone(),
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
!blocked.exists(),
|
||||
"missing path mask should not materialize host-side preserved paths at arg construction time",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_empty_preserved_file_uses_empty_file_bind_data() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let workspace = temp_dir.path().join("workspace");
|
||||
let dot_git = workspace.join(".git");
|
||||
std::fs::create_dir_all(&workspace).expect("create workspace");
|
||||
File::create(&dot_git).expect("create empty .git file");
|
||||
|
||||
let workspace_root =
|
||||
AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: workspace_root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
}]);
|
||||
|
||||
let args =
|
||||
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
|
||||
.expect("filesystem args");
|
||||
let dot_git_str = path_to_string(&dot_git);
|
||||
|
||||
assert_empty_file_bound_without_perms(&args.args, &dot_git);
|
||||
assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents"));
|
||||
assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex"));
|
||||
assert_eq!(
|
||||
synthetic_mount_target_paths(&args),
|
||||
vec![
|
||||
dot_git.clone(),
|
||||
workspace.join(".agents"),
|
||||
workspace.join(".codex"),
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
!args
|
||||
.args
|
||||
.windows(3)
|
||||
.any(|window| window == ["--ro-bind", dot_git_str.as_str(), dot_git_str.as_str()]),
|
||||
"transient empty preserved file should not be treated as a stable bind source",
|
||||
);
|
||||
let metadata = std::fs::symlink_metadata(&dot_git).expect("stat .git");
|
||||
assert!(
|
||||
!args.synthetic_mount_targets[0].should_remove_after_bwrap(&metadata),
|
||||
"pre-existing empty preserved files must not be cleaned up as synthetic targets",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_child_git_under_parent_repo_uses_read_only_empty_directory() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let repo = temp_dir.path().join("repo");
|
||||
let workspace = repo.join("workspace");
|
||||
let dot_git = workspace.join(".git");
|
||||
std::fs::create_dir_all(repo.join(".git")).expect("create parent .git");
|
||||
std::fs::create_dir_all(&workspace).expect("create workspace");
|
||||
|
||||
let workspace_root =
|
||||
AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: workspace_root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
}]);
|
||||
|
||||
let args = create_filesystem_args(&policy, &workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
|
||||
.expect("filesystem args");
|
||||
assert_empty_directory_mounted_read_only(&args.args, &dot_git);
|
||||
assert!(
|
||||
synthetic_mount_target_paths(&args).contains(&dot_git),
|
||||
"missing child .git should be a transient mount target",
|
||||
);
|
||||
assert_eq!(
|
||||
protected_create_target_paths(&args),
|
||||
Vec::<PathBuf>::new(),
|
||||
"missing child .git should fail at creation time through the read-only mount",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_missing_writable_roots() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
@@ -1411,6 +1779,18 @@ mod tests {
|
||||
NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH,
|
||||
)
|
||||
.expect("bwrap fs args");
|
||||
assert!(args.preserved_files.is_empty());
|
||||
assert_eq!(
|
||||
synthetic_mount_target_paths(&args),
|
||||
vec![
|
||||
PathBuf::from("/.git"),
|
||||
PathBuf::from("/.agents"),
|
||||
PathBuf::from("/.codex"),
|
||||
PathBuf::from("/dev/.git"),
|
||||
PathBuf::from("/dev/.agents"),
|
||||
PathBuf::from("/dev/.codex"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
args.args,
|
||||
vec![
|
||||
@@ -1425,17 +1805,50 @@ mod tests {
|
||||
"--bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
// Mask the default protected .codex subpath under that writable
|
||||
// Mask the default preserved paths under that writable
|
||||
// root. Because the root is `/` in this test, the carveout path
|
||||
// appears as `/.codex`.
|
||||
"--ro-bind".to_string(),
|
||||
"/dev/null".to_string(),
|
||||
// appears as `/.codex`, `/.agents`, and `/.git`.
|
||||
"--perms".to_string(),
|
||||
"555".to_string(),
|
||||
"--tmpfs".to_string(),
|
||||
"/.git".to_string(),
|
||||
"--remount-ro".to_string(),
|
||||
"/.git".to_string(),
|
||||
"--perms".to_string(),
|
||||
"555".to_string(),
|
||||
"--tmpfs".to_string(),
|
||||
"/.agents".to_string(),
|
||||
"--remount-ro".to_string(),
|
||||
"/.agents".to_string(),
|
||||
"--perms".to_string(),
|
||||
"555".to_string(),
|
||||
"--tmpfs".to_string(),
|
||||
"/.codex".to_string(),
|
||||
"--remount-ro".to_string(),
|
||||
"/.codex".to_string(),
|
||||
// Rebind /dev after the root bind so device nodes remain
|
||||
// writable/usable inside the writable root.
|
||||
"--bind".to_string(),
|
||||
"/dev".to_string(),
|
||||
"/dev".to_string(),
|
||||
"--perms".to_string(),
|
||||
"555".to_string(),
|
||||
"--tmpfs".to_string(),
|
||||
"/dev/.git".to_string(),
|
||||
"--remount-ro".to_string(),
|
||||
"/dev/.git".to_string(),
|
||||
"--perms".to_string(),
|
||||
"555".to_string(),
|
||||
"--tmpfs".to_string(),
|
||||
"/dev/.agents".to_string(),
|
||||
"--remount-ro".to_string(),
|
||||
"/dev/.agents".to_string(),
|
||||
"--perms".to_string(),
|
||||
"555".to_string(),
|
||||
"--tmpfs".to_string(),
|
||||
"/dev/.codex".to_string(),
|
||||
"--remount-ro".to_string(),
|
||||
"/dev/.codex".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1899,6 +2312,7 @@ mod tests {
|
||||
let blocked_file_str = path_to_string(blocked_file.as_path());
|
||||
|
||||
assert_eq!(args.preserved_files.len(), 1);
|
||||
assert!(args.synthetic_mount_targets.is_empty());
|
||||
assert!(args.args.windows(5).any(|window| {
|
||||
window[0] == "--perms"
|
||||
&& window[1] == "000"
|
||||
@@ -2002,4 +2416,52 @@ mod tests {
|
||||
"expected file mask for {path}: {args:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Assert that `path` is backed by an fd-supplied empty file without
|
||||
/// changing the next mount operation's permissions.
|
||||
fn assert_empty_file_bound_without_perms(args: &[String], path: &Path) {
|
||||
let path = path_to_string(path);
|
||||
assert!(
|
||||
args.windows(3)
|
||||
.any(|window| { window[0] == "--ro-bind-data" && window[2] == path }),
|
||||
"expected empty file bind for {path}: {args:#?}"
|
||||
);
|
||||
assert!(
|
||||
!args.windows(5).any(|window| {
|
||||
window[0] == "--perms"
|
||||
&& window[1] == "000"
|
||||
&& window[2] == "--ro-bind-data"
|
||||
&& window[4] == path
|
||||
}),
|
||||
"missing path bind should not set explicit file perms for {path}: {args:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_empty_directory_mounted_read_only(args: &[String], path: &Path) {
|
||||
let path = path_to_string(path);
|
||||
assert!(
|
||||
args.windows(4)
|
||||
.any(|window| window == ["--perms", "555", "--tmpfs", path.as_str()]),
|
||||
"expected empty directory mount for {path}: {args:#?}"
|
||||
);
|
||||
assert!(
|
||||
args.windows(2)
|
||||
.any(|window| window == ["--remount-ro", path.as_str()]),
|
||||
"expected read-only remount for {path}: {args:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn synthetic_mount_target_paths(args: &BwrapArgs) -> Vec<PathBuf> {
|
||||
args.synthetic_mount_targets
|
||||
.iter()
|
||||
.map(|target| target.path().to_path_buf())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn protected_create_target_paths(args: &BwrapArgs) -> Vec<PathBuf> {
|
||||
args.protected_create_targets
|
||||
.iter()
|
||||
.map(|target| target.path().to_path_buf())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
use clap::Parser;
|
||||
use std::ffi::CString;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::fd::FromRawFd;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicI32;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::bwrap::BwrapNetworkMode;
|
||||
use crate::bwrap::BwrapOptions;
|
||||
@@ -20,6 +26,29 @@ use codex_protocol::protocol::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
|
||||
|
||||
static BWRAP_CHILD_PID: AtomicI32 = AtomicI32::new(0);
|
||||
static PENDING_FORWARDED_SIGNAL: AtomicI32 = AtomicI32::new(0);
|
||||
|
||||
const FORWARDED_SIGNALS: &[libc::c_int] =
|
||||
&[libc::SIGHUP, libc::SIGINT, libc::SIGQUIT, libc::SIGTERM];
|
||||
const SYNTHETIC_MOUNT_MARKER_SYNTHETIC: &[u8] = b"synthetic\n";
|
||||
const SYNTHETIC_MOUNT_MARKER_EXISTING: &[u8] = b"existing\n";
|
||||
const PROTECTED_CREATE_MARKER: &[u8] = b"protected-create\n";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SyntheticMountTargetRegistration {
|
||||
target: crate::bwrap::SyntheticMountTarget,
|
||||
marker_file: PathBuf,
|
||||
marker_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProtectedCreateTargetRegistration {
|
||||
target: crate::bwrap::ProtectedCreateTarget,
|
||||
marker_file: PathBuf,
|
||||
marker_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
/// CLI surface for the Linux sandbox helper.
|
||||
///
|
||||
@@ -443,7 +472,7 @@ fn run_bwrap_with_proc_fallback(
|
||||
options,
|
||||
);
|
||||
apply_inner_command_argv0(&mut bwrap_args.args);
|
||||
exec_bwrap(bwrap_args.args, bwrap_args.preserved_files);
|
||||
run_or_exec_bwrap(bwrap_args);
|
||||
}
|
||||
|
||||
fn bwrap_network_mode(
|
||||
@@ -480,6 +509,8 @@ fn build_bwrap_argv(
|
||||
crate::bwrap::BwrapArgs {
|
||||
args: argv,
|
||||
preserved_files: bwrap_args.preserved_files,
|
||||
synthetic_mount_targets: bwrap_args.synthetic_mount_targets,
|
||||
protected_create_targets: bwrap_args.protected_create_targets,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,6 +599,547 @@ fn resolve_true_command() -> String {
|
||||
"true".to_string()
|
||||
}
|
||||
|
||||
fn run_or_exec_bwrap(bwrap_args: crate::bwrap::BwrapArgs) -> ! {
|
||||
if bwrap_args.synthetic_mount_targets.is_empty()
|
||||
&& bwrap_args.protected_create_targets.is_empty()
|
||||
{
|
||||
exec_bwrap(bwrap_args.args, bwrap_args.preserved_files);
|
||||
}
|
||||
run_bwrap_in_child_with_synthetic_mount_cleanup(bwrap_args);
|
||||
}
|
||||
|
||||
fn run_bwrap_in_child_with_synthetic_mount_cleanup(bwrap_args: crate::bwrap::BwrapArgs) -> ! {
|
||||
let crate::bwrap::BwrapArgs {
|
||||
args,
|
||||
preserved_files,
|
||||
synthetic_mount_targets,
|
||||
protected_create_targets,
|
||||
} = bwrap_args;
|
||||
let setup_signal_mask = ForwardedSignalMask::block();
|
||||
let synthetic_mount_registrations = register_synthetic_mount_targets(&synthetic_mount_targets);
|
||||
let protected_create_registrations =
|
||||
register_protected_create_targets(&protected_create_targets);
|
||||
let parent_pid = unsafe { libc::getpid() };
|
||||
let pid = unsafe { libc::fork() };
|
||||
if pid < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!("failed to fork for bubblewrap: {err}");
|
||||
}
|
||||
|
||||
if pid == 0 {
|
||||
reset_forwarded_signal_handlers_to_default();
|
||||
setup_signal_mask.restore();
|
||||
let setpgid_res = unsafe { libc::setpgid(0, 0) };
|
||||
if setpgid_res < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!("failed to place bubblewrap child in its own process group: {err}");
|
||||
}
|
||||
terminate_with_parent(parent_pid);
|
||||
exec_bwrap(args, preserved_files);
|
||||
}
|
||||
|
||||
install_bwrap_signal_forwarders(pid);
|
||||
setup_signal_mask.restore();
|
||||
let status = wait_for_bwrap_child(pid);
|
||||
let cleanup_signal_mask = ForwardedSignalMask::block();
|
||||
BWRAP_CHILD_PID.store(0, Ordering::SeqCst);
|
||||
cleanup_synthetic_mount_targets(&synthetic_mount_registrations);
|
||||
let protected_create_violation =
|
||||
cleanup_protected_create_targets(&protected_create_registrations);
|
||||
cleanup_signal_mask.restore();
|
||||
exit_with_wait_status_or_policy_violation(status, protected_create_violation);
|
||||
}
|
||||
|
||||
struct ForwardedSignalMask {
|
||||
previous: libc::sigset_t,
|
||||
}
|
||||
|
||||
impl ForwardedSignalMask {
|
||||
fn block() -> Self {
|
||||
let mut blocked: libc::sigset_t = unsafe { std::mem::zeroed() };
|
||||
let mut previous: libc::sigset_t = unsafe { std::mem::zeroed() };
|
||||
unsafe {
|
||||
libc::sigemptyset(&mut blocked);
|
||||
for signal in FORWARDED_SIGNALS {
|
||||
libc::sigaddset(&mut blocked, *signal);
|
||||
}
|
||||
if libc::sigprocmask(libc::SIG_BLOCK, &blocked, &mut previous) < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!("failed to block bubblewrap forwarded signals: {err}");
|
||||
}
|
||||
}
|
||||
Self { previous }
|
||||
}
|
||||
|
||||
fn restore(&self) {
|
||||
let mut restored = self.previous;
|
||||
unsafe {
|
||||
for signal in FORWARDED_SIGNALS {
|
||||
libc::sigdelset(&mut restored, *signal);
|
||||
}
|
||||
if libc::sigprocmask(libc::SIG_SETMASK, &restored, std::ptr::null_mut()) < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!("failed to restore bubblewrap forwarded signals: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_with_parent(parent_pid: libc::pid_t) {
|
||||
let res = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) };
|
||||
if res < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!("failed to set bubblewrap child parent-death signal: {err}");
|
||||
}
|
||||
if unsafe { libc::getppid() } != parent_pid {
|
||||
unsafe {
|
||||
libc::raise(libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn install_bwrap_signal_forwarders(pid: libc::pid_t) {
|
||||
BWRAP_CHILD_PID.store(pid, Ordering::SeqCst);
|
||||
for signal in FORWARDED_SIGNALS {
|
||||
let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
|
||||
action.sa_sigaction = forward_signal_to_bwrap_child as *const () as libc::sighandler_t;
|
||||
unsafe {
|
||||
libc::sigemptyset(&mut action.sa_mask);
|
||||
if libc::sigaction(*signal, &action, std::ptr::null_mut()) < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!("failed to install bubblewrap signal forwarder for {signal}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
replay_pending_forwarded_signal(pid);
|
||||
}
|
||||
|
||||
extern "C" fn forward_signal_to_bwrap_child(signal: libc::c_int) {
|
||||
PENDING_FORWARDED_SIGNAL.store(signal, Ordering::SeqCst);
|
||||
let pid = BWRAP_CHILD_PID.load(Ordering::SeqCst);
|
||||
if pid > 0 {
|
||||
send_signal_to_bwrap_child(pid, signal);
|
||||
}
|
||||
}
|
||||
|
||||
fn replay_pending_forwarded_signal(pid: libc::pid_t) {
|
||||
let signal = PENDING_FORWARDED_SIGNAL.swap(0, Ordering::SeqCst);
|
||||
if signal > 0 {
|
||||
send_signal_to_bwrap_child(pid, signal);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_signal_to_bwrap_child(pid: libc::pid_t, signal: libc::c_int) {
|
||||
unsafe {
|
||||
libc::kill(-pid, signal);
|
||||
libc::kill(pid, signal);
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_forwarded_signal_handlers_to_default() {
|
||||
for signal in FORWARDED_SIGNALS {
|
||||
unsafe {
|
||||
if libc::signal(*signal, libc::SIG_DFL) == libc::SIG_ERR {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!("failed to reset bubblewrap signal handler for {signal}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_bwrap_child(pid: libc::pid_t) -> libc::c_int {
|
||||
loop {
|
||||
let mut status: libc::c_int = 0;
|
||||
let wait_res = unsafe { libc::waitpid(pid, &mut status as *mut libc::c_int, 0) };
|
||||
if wait_res >= 0 {
|
||||
return status;
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EINTR) {
|
||||
continue;
|
||||
}
|
||||
panic!("waitpid failed for bubblewrap child: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn register_synthetic_mount_targets(
|
||||
targets: &[crate::bwrap::SyntheticMountTarget],
|
||||
) -> Vec<SyntheticMountTargetRegistration> {
|
||||
with_synthetic_mount_registry_lock(|| {
|
||||
targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
let marker_dir = synthetic_mount_marker_dir(target.path());
|
||||
fs::create_dir_all(&marker_dir).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to create synthetic bubblewrap mount marker directory {}: {err}",
|
||||
marker_dir.display()
|
||||
)
|
||||
});
|
||||
let target = if target.preserves_pre_existing_path()
|
||||
&& synthetic_mount_marker_dir_has_active_synthetic_owner(&marker_dir)
|
||||
{
|
||||
match target.kind() {
|
||||
crate::bwrap::SyntheticMountTargetKind::EmptyFile => {
|
||||
crate::bwrap::SyntheticMountTarget::missing(target.path())
|
||||
}
|
||||
crate::bwrap::SyntheticMountTargetKind::EmptyDirectory => {
|
||||
crate::bwrap::SyntheticMountTarget::missing_empty_directory(
|
||||
target.path(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
target.clone()
|
||||
};
|
||||
let marker_file = marker_dir.join(std::process::id().to_string());
|
||||
fs::write(&marker_file, synthetic_mount_marker_contents(&target)).unwrap_or_else(
|
||||
|err| {
|
||||
panic!(
|
||||
"failed to register synthetic bubblewrap mount target {}: {err}",
|
||||
target.path().display()
|
||||
)
|
||||
},
|
||||
);
|
||||
SyntheticMountTargetRegistration {
|
||||
target,
|
||||
marker_file,
|
||||
marker_dir,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn register_protected_create_targets(
|
||||
targets: &[crate::bwrap::ProtectedCreateTarget],
|
||||
) -> Vec<ProtectedCreateTargetRegistration> {
|
||||
with_synthetic_mount_registry_lock(|| {
|
||||
targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
let marker_dir = synthetic_mount_marker_dir(target.path());
|
||||
fs::create_dir_all(&marker_dir).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to create protected create marker directory {}: {err}",
|
||||
marker_dir.display()
|
||||
)
|
||||
});
|
||||
let marker_file = marker_dir.join(std::process::id().to_string());
|
||||
fs::write(&marker_file, PROTECTED_CREATE_MARKER).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to register protected create target {}: {err}",
|
||||
target.path().display()
|
||||
)
|
||||
});
|
||||
ProtectedCreateTargetRegistration {
|
||||
target: target.clone(),
|
||||
marker_file,
|
||||
marker_dir,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn synthetic_mount_marker_contents(target: &crate::bwrap::SyntheticMountTarget) -> &'static [u8] {
|
||||
if target.preserves_pre_existing_path() {
|
||||
SYNTHETIC_MOUNT_MARKER_EXISTING
|
||||
} else {
|
||||
SYNTHETIC_MOUNT_MARKER_SYNTHETIC
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_mount_marker_dir_has_active_synthetic_owner(marker_dir: &Path) -> bool {
|
||||
synthetic_mount_marker_dir_has_active_process_matching(marker_dir, |path| {
|
||||
match fs::read(path) {
|
||||
Ok(contents) => contents == SYNTHETIC_MOUNT_MARKER_SYNTHETIC,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(err) => panic!(
|
||||
"failed to read synthetic bubblewrap mount marker {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn synthetic_mount_marker_dir_has_active_process(marker_dir: &Path) -> bool {
|
||||
synthetic_mount_marker_dir_has_active_process_matching(marker_dir, |_| true)
|
||||
}
|
||||
|
||||
fn synthetic_mount_marker_dir_has_active_process_matching(
|
||||
marker_dir: &Path,
|
||||
matches_marker: impl Fn(&Path) -> bool,
|
||||
) -> bool {
|
||||
let entries = match fs::read_dir(marker_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return false,
|
||||
Err(err) => panic!(
|
||||
"failed to read synthetic bubblewrap mount marker directory {}: {err}",
|
||||
marker_dir.display()
|
||||
),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read synthetic bubblewrap mount marker in {}: {err}",
|
||||
marker_dir.display()
|
||||
)
|
||||
});
|
||||
let path = entry.path();
|
||||
let Some(pid) = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| name.parse::<libc::pid_t>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !process_is_active(pid) {
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => panic!(
|
||||
"failed to remove stale synthetic bubblewrap mount marker {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches_marker = matches_marker(&path);
|
||||
if matches_marker {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn cleanup_synthetic_mount_targets(targets: &[SyntheticMountTargetRegistration]) {
|
||||
with_synthetic_mount_registry_lock(|| {
|
||||
for target in targets.iter().rev() {
|
||||
match fs::remove_file(&target.marker_file) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => panic!(
|
||||
"failed to unregister synthetic bubblewrap mount target {}: {err}",
|
||||
target.target.path().display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
for target in targets.iter().rev() {
|
||||
if synthetic_mount_marker_dir_has_active_process(&target.marker_dir) {
|
||||
continue;
|
||||
}
|
||||
remove_synthetic_mount_target(&target.target);
|
||||
match fs::remove_dir(&target.marker_dir) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {}
|
||||
Err(err) => panic!(
|
||||
"failed to remove synthetic bubblewrap mount marker directory {}: {err}",
|
||||
target.marker_dir.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn cleanup_protected_create_targets(targets: &[ProtectedCreateTargetRegistration]) -> bool {
|
||||
with_synthetic_mount_registry_lock(|| {
|
||||
for target in targets.iter().rev() {
|
||||
match fs::remove_file(&target.marker_file) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => panic!(
|
||||
"failed to unregister protected create target {}: {err}",
|
||||
target.target.path().display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let mut violation = false;
|
||||
for target in targets.iter().rev() {
|
||||
if synthetic_mount_marker_dir_has_active_process(&target.marker_dir) {
|
||||
if target.target.path().exists() {
|
||||
violation = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
violation |= remove_protected_create_target(&target.target);
|
||||
match fs::remove_dir(&target.marker_dir) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {}
|
||||
Err(err) => panic!(
|
||||
"failed to remove protected create marker directory {}: {err}",
|
||||
target.marker_dir.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
violation
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_protected_create_target(target: &crate::bwrap::ProtectedCreateTarget) -> bool {
|
||||
let path = target.path();
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return false,
|
||||
Err(err) => panic!(
|
||||
"failed to inspect protected create target {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
};
|
||||
|
||||
if metadata.is_dir() {
|
||||
fs::remove_dir_all(path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to remove protected create target directory {}: {err}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
} else {
|
||||
fs::remove_file(path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to remove protected create target file {}: {err}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
eprintln!(
|
||||
"sandbox blocked creation of preserved workspace metadata path {}",
|
||||
path.display()
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn remove_synthetic_mount_target(target: &crate::bwrap::SyntheticMountTarget) {
|
||||
let path = target.path();
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(err) => panic!(
|
||||
"failed to inspect synthetic bubblewrap mount target {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
};
|
||||
if !target.should_remove_after_bwrap(&metadata) {
|
||||
return;
|
||||
}
|
||||
match target.kind() {
|
||||
crate::bwrap::SyntheticMountTargetKind::EmptyFile => match fs::remove_file(path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => panic!(
|
||||
"failed to remove synthetic bubblewrap mount target {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
},
|
||||
crate::bwrap::SyntheticMountTargetKind::EmptyDirectory => match fs::remove_dir(path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {}
|
||||
Err(err) => panic!(
|
||||
"failed to remove synthetic bubblewrap mount target {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn process_is_active(pid: libc::pid_t) -> bool {
|
||||
let result = unsafe { libc::kill(pid, 0) };
|
||||
if result == 0 {
|
||||
return true;
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
!matches!(err.raw_os_error(), Some(libc::ESRCH))
|
||||
}
|
||||
|
||||
fn with_synthetic_mount_registry_lock<T>(f: impl FnOnce() -> T) -> T {
|
||||
let registry_root = synthetic_mount_registry_root();
|
||||
fs::create_dir_all(®istry_root).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to create synthetic bubblewrap mount registry {}: {err}",
|
||||
registry_root.display()
|
||||
)
|
||||
});
|
||||
let lock_path = registry_root.join("lock");
|
||||
let lock_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to open synthetic bubblewrap mount registry lock {}: {err}",
|
||||
lock_path.display()
|
||||
)
|
||||
});
|
||||
if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!(
|
||||
"failed to lock synthetic bubblewrap mount registry {}: {err}",
|
||||
lock_path.display()
|
||||
);
|
||||
}
|
||||
let result = f();
|
||||
if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN) } < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!(
|
||||
"failed to unlock synthetic bubblewrap mount registry {}: {err}",
|
||||
lock_path.display()
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn synthetic_mount_marker_dir(path: &Path) -> PathBuf {
|
||||
synthetic_mount_registry_root().join(format!("{:016x}", hash_path(path)))
|
||||
}
|
||||
|
||||
fn synthetic_mount_registry_root() -> PathBuf {
|
||||
std::env::temp_dir().join("codex-bwrap-synthetic-mount-targets")
|
||||
}
|
||||
|
||||
fn hash_path(path: &Path) -> u64 {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in path.as_os_str().as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn exit_with_wait_status(status: libc::c_int) -> ! {
|
||||
if libc::WIFEXITED(status) {
|
||||
std::process::exit(libc::WEXITSTATUS(status));
|
||||
}
|
||||
|
||||
if libc::WIFSIGNALED(status) {
|
||||
let signal = libc::WTERMSIG(status);
|
||||
unsafe {
|
||||
libc::signal(signal, libc::SIG_DFL);
|
||||
libc::kill(libc::getpid(), signal);
|
||||
}
|
||||
std::process::exit(128 + signal);
|
||||
}
|
||||
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fn exit_with_wait_status_or_policy_violation(
|
||||
status: libc::c_int,
|
||||
protected_create_violation: bool,
|
||||
) -> ! {
|
||||
if protected_create_violation && libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
exit_with_wait_status(status);
|
||||
}
|
||||
|
||||
/// Run a short-lived bubblewrap preflight in a child process and capture stderr.
|
||||
///
|
||||
/// Strategy:
|
||||
@@ -581,6 +1153,16 @@ fn resolve_true_command() -> String {
|
||||
/// command, and reads are bounded to a fixed max size.
|
||||
fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> String {
|
||||
const MAX_PREFLIGHT_STDERR_BYTES: u64 = 64 * 1024;
|
||||
let crate::bwrap::BwrapArgs {
|
||||
args,
|
||||
preserved_files,
|
||||
synthetic_mount_targets,
|
||||
protected_create_targets,
|
||||
} = bwrap_args;
|
||||
let setup_signal_mask = ForwardedSignalMask::block();
|
||||
let synthetic_mount_registrations = register_synthetic_mount_targets(&synthetic_mount_targets);
|
||||
let protected_create_registrations =
|
||||
register_protected_create_targets(&protected_create_targets);
|
||||
|
||||
let mut pipe_fds = [0; 2];
|
||||
let pipe_res = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
|
||||
@@ -598,6 +1180,8 @@ fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> Str
|
||||
}
|
||||
|
||||
if pid == 0 {
|
||||
reset_forwarded_signal_handlers_to_default();
|
||||
setup_signal_mask.restore();
|
||||
// Child: redirect stderr to the pipe, then run bubblewrap.
|
||||
unsafe {
|
||||
close_fd_or_panic(read_fd, "close read end in bubblewrap child");
|
||||
@@ -608,9 +1192,11 @@ fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> Str
|
||||
close_fd_or_panic(write_fd, "close write end in bubblewrap child");
|
||||
}
|
||||
|
||||
exec_bwrap(bwrap_args.args, bwrap_args.preserved_files);
|
||||
exec_bwrap(args, preserved_files);
|
||||
}
|
||||
|
||||
install_bwrap_signal_forwarders(pid);
|
||||
setup_signal_mask.restore();
|
||||
// Parent: close the write end and read stderr while the child runs.
|
||||
close_fd_or_panic(write_fd, "close write end in bubblewrap parent");
|
||||
|
||||
@@ -622,12 +1208,12 @@ fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> Str
|
||||
panic!("failed to read bubblewrap stderr: {err}");
|
||||
}
|
||||
|
||||
let mut status: libc::c_int = 0;
|
||||
let wait_res = unsafe { libc::waitpid(pid, &mut status as *mut libc::c_int, 0) };
|
||||
if wait_res < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
panic!("waitpid failed for bubblewrap child: {err}");
|
||||
}
|
||||
wait_for_bwrap_child(pid);
|
||||
let cleanup_signal_mask = ForwardedSignalMask::block();
|
||||
BWRAP_CHILD_PID.store(0, Ordering::SeqCst);
|
||||
cleanup_synthetic_mount_targets(&synthetic_mount_registrations);
|
||||
cleanup_protected_create_targets(&protected_create_registrations);
|
||||
cleanup_signal_mask.restore();
|
||||
|
||||
String::from_utf8_lossy(&stderr_bytes).into_owned()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#[cfg(test)]
|
||||
use super::*;
|
||||
#[cfg(test)]
|
||||
use crate::linux_run_main::install_bwrap_signal_forwarders;
|
||||
#[cfg(test)]
|
||||
use crate::linux_run_main::wait_for_bwrap_child;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::FileSystemSandboxPolicy;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::NetworkSandboxPolicy;
|
||||
@@ -255,6 +259,180 @@ fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() {
|
||||
assert!(argv.iter().any(|arg| arg == "--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_synthetic_mount_targets_removes_only_empty_mount_targets() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let empty_file = temp_dir.path().join(".git");
|
||||
let empty_dir = temp_dir.path().join(".agents");
|
||||
let non_empty_file = temp_dir.path().join("non-empty");
|
||||
let missing_file = temp_dir.path().join(".missing");
|
||||
std::fs::write(&empty_file, "").expect("write empty file");
|
||||
std::fs::create_dir(&empty_dir).expect("create empty dir");
|
||||
std::fs::write(&non_empty_file, "keep").expect("write nonempty file");
|
||||
|
||||
let registrations = register_synthetic_mount_targets(&[
|
||||
crate::bwrap::SyntheticMountTarget::missing(&empty_file),
|
||||
crate::bwrap::SyntheticMountTarget::missing_empty_directory(&empty_dir),
|
||||
crate::bwrap::SyntheticMountTarget::missing(&non_empty_file),
|
||||
crate::bwrap::SyntheticMountTarget::missing(&missing_file),
|
||||
]);
|
||||
cleanup_synthetic_mount_targets(®istrations);
|
||||
|
||||
assert!(!empty_file.exists());
|
||||
assert!(!empty_dir.exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&non_empty_file).expect("read nonempty file"),
|
||||
"keep"
|
||||
);
|
||||
assert!(!missing_file.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_synthetic_mount_targets_waits_for_other_active_registrations() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let empty_file = temp_dir.path().join(".git");
|
||||
std::fs::write(&empty_file, "").expect("write empty file");
|
||||
let target = crate::bwrap::SyntheticMountTarget::missing(&empty_file);
|
||||
|
||||
let registrations = register_synthetic_mount_targets(std::slice::from_ref(&target));
|
||||
let active_marker = registrations[0].marker_dir.join("1");
|
||||
std::fs::write(&active_marker, "").expect("write active marker");
|
||||
|
||||
cleanup_synthetic_mount_targets(®istrations);
|
||||
assert!(empty_file.exists());
|
||||
|
||||
std::fs::remove_file(active_marker).expect("remove active marker");
|
||||
let registrations = register_synthetic_mount_targets(std::slice::from_ref(&target));
|
||||
cleanup_synthetic_mount_targets(®istrations);
|
||||
|
||||
assert!(!empty_file.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_synthetic_mount_targets_removes_transient_file_after_concurrent_owner_exits() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let empty_file = temp_dir.path().join(".git");
|
||||
let first_target = crate::bwrap::SyntheticMountTarget::missing(&empty_file);
|
||||
|
||||
let first_registrations = register_synthetic_mount_targets(&[first_target]);
|
||||
std::fs::write(&empty_file, "").expect("write transient empty file");
|
||||
let active_marker = first_registrations[0].marker_dir.join("1");
|
||||
std::fs::write(&active_marker, SYNTHETIC_MOUNT_MARKER_SYNTHETIC).expect("write active marker");
|
||||
let metadata = std::fs::symlink_metadata(&empty_file).expect("stat empty file");
|
||||
let second_target =
|
||||
crate::bwrap::SyntheticMountTarget::existing_empty_file(&empty_file, &metadata);
|
||||
let second_registrations = register_synthetic_mount_targets(&[second_target]);
|
||||
|
||||
cleanup_synthetic_mount_targets(&first_registrations);
|
||||
assert!(empty_file.exists());
|
||||
|
||||
std::fs::remove_file(active_marker).expect("remove active marker");
|
||||
cleanup_synthetic_mount_targets(&second_registrations);
|
||||
|
||||
assert!(!empty_file.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_synthetic_mount_targets_preserves_real_pre_existing_empty_file() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let empty_file = temp_dir.path().join(".git");
|
||||
std::fs::write(&empty_file, "").expect("write pre-existing empty file");
|
||||
let metadata = std::fs::symlink_metadata(&empty_file).expect("stat empty file");
|
||||
let first_target =
|
||||
crate::bwrap::SyntheticMountTarget::existing_empty_file(&empty_file, &metadata);
|
||||
let second_target =
|
||||
crate::bwrap::SyntheticMountTarget::existing_empty_file(&empty_file, &metadata);
|
||||
|
||||
let first_registrations = register_synthetic_mount_targets(&[first_target]);
|
||||
let second_registrations = register_synthetic_mount_targets(&[second_target]);
|
||||
|
||||
cleanup_synthetic_mount_targets(&first_registrations);
|
||||
cleanup_synthetic_mount_targets(&second_registrations);
|
||||
|
||||
assert!(empty_file.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_protected_create_targets_removes_created_path_and_reports_violation() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let dot_git = temp_dir.path().join(".git");
|
||||
let target = crate::bwrap::ProtectedCreateTarget::missing(&dot_git);
|
||||
|
||||
let registrations = register_protected_create_targets(&[target]);
|
||||
std::fs::create_dir(&dot_git).expect("create protected path");
|
||||
let violation = cleanup_protected_create_targets(®istrations);
|
||||
|
||||
assert!(violation);
|
||||
assert!(!dot_git.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_protected_create_targets_waits_for_other_active_registrations() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let dot_git = temp_dir.path().join(".git");
|
||||
let target = crate::bwrap::ProtectedCreateTarget::missing(&dot_git);
|
||||
|
||||
let registrations = register_protected_create_targets(std::slice::from_ref(&target));
|
||||
let active_marker = registrations[0].marker_dir.join("1");
|
||||
std::fs::write(&active_marker, PROTECTED_CREATE_MARKER).expect("write active marker");
|
||||
std::fs::write(&dot_git, "").expect("create protected path");
|
||||
|
||||
let violation = cleanup_protected_create_targets(®istrations);
|
||||
assert!(violation);
|
||||
assert!(dot_git.exists());
|
||||
|
||||
std::fs::remove_file(active_marker).expect("remove active marker");
|
||||
let registrations = register_protected_create_targets(std::slice::from_ref(&target));
|
||||
let violation = cleanup_protected_create_targets(®istrations);
|
||||
|
||||
assert!(violation);
|
||||
assert!(!dot_git.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwrap_signal_forwarder_terminates_child_and_keeps_parent_alive() {
|
||||
let supervisor_pid = unsafe { libc::fork() };
|
||||
assert!(supervisor_pid >= 0, "failed to fork supervisor");
|
||||
|
||||
if supervisor_pid == 0 {
|
||||
run_bwrap_signal_forwarder_test_supervisor();
|
||||
}
|
||||
|
||||
let status = wait_for_bwrap_child(supervisor_pid);
|
||||
assert!(libc::WIFEXITED(status), "supervisor status: {status}");
|
||||
assert_eq!(libc::WEXITSTATUS(status), 0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn run_bwrap_signal_forwarder_test_supervisor() -> ! {
|
||||
let child_pid = unsafe { libc::fork() };
|
||||
if child_pid < 0 {
|
||||
unsafe {
|
||||
libc::_exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if child_pid == 0 {
|
||||
loop {
|
||||
unsafe {
|
||||
libc::pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
install_bwrap_signal_forwarders(child_pid);
|
||||
unsafe {
|
||||
libc::raise(libc::SIGTERM);
|
||||
}
|
||||
|
||||
let status = wait_for_bwrap_child(child_pid);
|
||||
let child_terminated_by_sigterm =
|
||||
libc::WIFSIGNALED(status) && libc::WTERMSIG(status) == libc::SIGTERM;
|
||||
unsafe {
|
||||
libc::_exit(if child_terminated_by_sigterm { 0 } else { 1 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_proxy_inner_command_includes_route_spec() {
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
|
||||
@@ -19,6 +19,60 @@ use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::WritableRoot;
|
||||
|
||||
const PRESERVED_GIT_PATH_NAME: &str = ".git";
|
||||
const PRESERVED_AGENTS_PATH_NAME: &str = ".agents";
|
||||
const PRESERVED_CODEX_PATH_NAME: &str = ".codex";
|
||||
|
||||
/// Top-level workspace metadata paths that stay protected under writable roots.
|
||||
pub const PRESERVED_PATH_NAMES: &[&str] = &[
|
||||
PRESERVED_GIT_PATH_NAME,
|
||||
PRESERVED_AGENTS_PATH_NAME,
|
||||
PRESERVED_CODEX_PATH_NAME,
|
||||
];
|
||||
|
||||
/// Returns true when a path basename is one of the preserved workspace metadata names.
|
||||
pub fn is_preserved_path_name(name: &OsStr) -> bool {
|
||||
PRESERVED_PATH_NAMES
|
||||
.iter()
|
||||
.any(|preserved| name == OsStr::new(preserved))
|
||||
}
|
||||
|
||||
pub fn is_preserved_directory_path_name(name: &OsStr) -> bool {
|
||||
name == OsStr::new(PRESERVED_AGENTS_PATH_NAME) || name == OsStr::new(PRESERVED_CODEX_PATH_NAME)
|
||||
}
|
||||
|
||||
/// Returns the preserved workspace metadata name when an agent write to `path`
|
||||
/// should be blocked before execution.
|
||||
pub fn forbidden_agent_preserved_path_write(
|
||||
path: &Path,
|
||||
cwd: &Path,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
) -> Option<&'static str> {
|
||||
if !matches!(
|
||||
file_system_sandbox_policy.kind,
|
||||
FileSystemSandboxKind::Restricted
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target = resolve_candidate_path(path, cwd)?;
|
||||
let (preserved_path, preserved_name) = first_preserved_component(target.as_path())?;
|
||||
if has_explicit_write_entry_for_preserved_path(
|
||||
file_system_sandbox_policy,
|
||||
&preserved_path,
|
||||
target.as_path(),
|
||||
cwd,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !file_system_sandbox_policy.can_write_path_with_cwd(target.as_path(), cwd) {
|
||||
return Some(preserved_name);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
|
||||
)]
|
||||
@@ -453,7 +507,7 @@ impl FileSystemSandboxPolicy {
|
||||
for writable_root in writable_roots {
|
||||
for protected_path in default_read_only_subpaths_for_writable_root(
|
||||
writable_root,
|
||||
/*protect_missing_dot_codex*/ false,
|
||||
/*protect_missing_preserved_paths*/ false,
|
||||
) {
|
||||
append_default_read_only_path_if_no_explicit_rule(
|
||||
&mut file_system_policy.entries,
|
||||
@@ -474,27 +528,17 @@ impl FileSystemSandboxPolicy {
|
||||
/// into split filesystem policy.
|
||||
pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
|
||||
let mut file_system_policy = Self::from(sandbox_policy);
|
||||
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy {
|
||||
if let SandboxPolicy::WorkspaceWrite { .. } = sandbox_policy {
|
||||
let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
|
||||
prune_read_entries_under_writable_roots(
|
||||
&mut file_system_policy.entries,
|
||||
&legacy_writable_roots,
|
||||
);
|
||||
|
||||
if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
|
||||
for writable_root in legacy_writable_roots {
|
||||
for protected_path in default_read_only_subpaths_for_writable_root(
|
||||
&cwd_root, /*protect_missing_dot_codex*/ true,
|
||||
) {
|
||||
append_default_read_only_path_if_no_explicit_rule(
|
||||
&mut file_system_policy.entries,
|
||||
protected_path,
|
||||
);
|
||||
}
|
||||
}
|
||||
for writable_root in writable_roots {
|
||||
for protected_path in default_read_only_subpaths_for_writable_root(
|
||||
writable_root,
|
||||
/*protect_missing_dot_codex*/ false,
|
||||
&writable_root.root,
|
||||
/*protect_missing_preserved_paths*/ true,
|
||||
) {
|
||||
append_default_read_only_path_if_no_explicit_rule(
|
||||
&mut file_system_policy.entries,
|
||||
@@ -568,7 +612,28 @@ impl FileSystemSandboxPolicy {
|
||||
}
|
||||
|
||||
pub fn can_write_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
|
||||
self.resolve_access_with_cwd(path, cwd).can_write()
|
||||
if !self.resolve_access_with_cwd(path, cwd).can_write() {
|
||||
return false;
|
||||
}
|
||||
if self.has_full_disk_write_access() {
|
||||
return true;
|
||||
}
|
||||
!self.is_preserved_path_write_denied(path, cwd)
|
||||
}
|
||||
|
||||
fn is_preserved_path_write_denied(&self, path: &Path, cwd: &Path) -> bool {
|
||||
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(target) = resolve_candidate_path(path, cwd) else {
|
||||
return true;
|
||||
};
|
||||
let Some((preserved_path, _)) = first_preserved_component(target.as_path()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
!has_explicit_write_entry_for_preserved_path(self, &preserved_path, target.as_path(), cwd)
|
||||
}
|
||||
|
||||
pub fn with_additional_readable_roots(
|
||||
@@ -687,14 +752,13 @@ impl FileSystemSandboxPolicy {
|
||||
.iter()
|
||||
.filter(|path| normalize_effective_absolute_path((*path).clone()) == root)
|
||||
.collect();
|
||||
let protect_missing_dot_codex = AbsolutePathBuf::from_absolute_path(cwd)
|
||||
.ok()
|
||||
.is_some_and(|cwd| normalize_effective_absolute_path(cwd) == root);
|
||||
let mut read_only_subpaths: Vec<AbsolutePathBuf> =
|
||||
default_read_only_subpaths_for_writable_root(&root, protect_missing_dot_codex)
|
||||
.into_iter()
|
||||
.filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path))
|
||||
.collect();
|
||||
default_read_only_subpaths_for_writable_root(
|
||||
&root, /*protect_missing_preserved_paths*/ true,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path))
|
||||
.collect();
|
||||
// Narrower explicit non-write entries carve out broader writable roots.
|
||||
// More specific write entries still remain writable because they appear
|
||||
// as separate WritableRoot values and are checked independently.
|
||||
@@ -754,6 +818,10 @@ impl FileSystemSandboxPolicy {
|
||||
}),
|
||||
);
|
||||
WritableRoot {
|
||||
preserved_path_names: default_preserved_path_names_for_writable_root(
|
||||
&root,
|
||||
&resolved_entries,
|
||||
),
|
||||
root,
|
||||
// Preserve literal in-root protected paths like `.git` and
|
||||
// `.codex` so downstream sandboxes can still detect and mask
|
||||
@@ -1298,18 +1366,20 @@ fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
fn default_read_only_subpaths_for_writable_root(
|
||||
pub(crate) fn default_read_only_subpaths_for_writable_root(
|
||||
writable_root: &AbsolutePathBuf,
|
||||
protect_missing_dot_codex: bool,
|
||||
protect_missing_preserved_paths: bool,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
|
||||
let top_level_git = writable_root.join(".git");
|
||||
let top_level_git = writable_root.join(PRESERVED_GIT_PATH_NAME);
|
||||
// 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 {
|
||||
let should_protect_top_level_git =
|
||||
top_level_git_is_dir || top_level_git_is_file || protect_missing_preserved_paths;
|
||||
if should_protect_top_level_git {
|
||||
if top_level_git_is_file
|
||||
&& is_git_pointer_file(&top_level_git)
|
||||
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
|
||||
@@ -1319,23 +1389,37 @@ fn default_read_only_subpaths_for_writable_root(
|
||||
subpaths.push(top_level_git);
|
||||
}
|
||||
|
||||
let top_level_agents = writable_root.join(".agents");
|
||||
if top_level_agents.as_path().is_dir() {
|
||||
let top_level_agents = writable_root.join(PRESERVED_AGENTS_PATH_NAME);
|
||||
if protect_missing_preserved_paths || top_level_agents.as_path().is_dir() {
|
||||
subpaths.push(top_level_agents);
|
||||
}
|
||||
|
||||
// Keep top-level project metadata under .codex read-only to the agent by
|
||||
// Keep top-level preserved paths under .codex read-only to the agent by
|
||||
// default. For the workspace root itself, protect it even before the
|
||||
// directory exists so first-time creation still goes through the
|
||||
// protected-path approval flow.
|
||||
let top_level_codex = writable_root.join(".codex");
|
||||
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
|
||||
// preserved path approval flow.
|
||||
let top_level_codex = writable_root.join(PRESERVED_CODEX_PATH_NAME);
|
||||
if protect_missing_preserved_paths || top_level_codex.as_path().is_dir() {
|
||||
subpaths.push(top_level_codex);
|
||||
}
|
||||
|
||||
dedup_absolute_paths(subpaths, /*normalize_effective_paths*/ false)
|
||||
}
|
||||
|
||||
fn default_preserved_path_names_for_writable_root(
|
||||
writable_root: &AbsolutePathBuf,
|
||||
entries: &[ResolvedFileSystemEntry],
|
||||
) -> Vec<String> {
|
||||
PRESERVED_PATH_NAMES
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
let path = writable_root.join(**name);
|
||||
!has_explicit_resolved_write_entry(entries, &path)
|
||||
})
|
||||
.map(|name| (*name).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn append_default_read_only_project_root_subpath_if_no_explicit_rule(
|
||||
entries: &mut Vec<FileSystemSandboxEntry>,
|
||||
subpath: impl Into<PathBuf>,
|
||||
@@ -1416,8 +1500,9 @@ fn legacy_non_cwd_writable_roots(
|
||||
dedup_absolute_paths(roots, /*normalize_effective_paths*/ true)
|
||||
.into_iter()
|
||||
.map(|root| WritableRoot {
|
||||
preserved_path_names: default_preserved_path_names_for_writable_root(&root, &[]),
|
||||
read_only_subpaths: default_read_only_subpaths_for_writable_root(
|
||||
&root, /*protect_missing_dot_codex*/ false,
|
||||
&root, /*protect_missing_preserved_paths*/ false,
|
||||
),
|
||||
root,
|
||||
})
|
||||
@@ -1431,8 +1516,50 @@ fn has_explicit_resolved_path_entry(
|
||||
entries.iter().any(|entry| &entry.path == path)
|
||||
}
|
||||
|
||||
fn has_explicit_resolved_write_entry(
|
||||
entries: &[ResolvedFileSystemEntry],
|
||||
path: &AbsolutePathBuf,
|
||||
) -> bool {
|
||||
entries
|
||||
.iter()
|
||||
.any(|entry| entry.access.can_write() && &entry.path == path)
|
||||
}
|
||||
|
||||
fn first_preserved_component(path: &Path) -> Option<(AbsolutePathBuf, &'static str)> {
|
||||
let mut candidate = PathBuf::new();
|
||||
for component in path.components() {
|
||||
candidate.push(component.as_os_str());
|
||||
if let Some(preserved_name) = preserved_path_name(component.as_os_str()) {
|
||||
let absolute = AbsolutePathBuf::from_absolute_path(candidate).ok()?;
|
||||
return Some((absolute, preserved_name));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn preserved_path_name(name: &OsStr) -> Option<&'static str> {
|
||||
PRESERVED_PATH_NAMES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|preserved| name == OsStr::new(preserved))
|
||||
}
|
||||
|
||||
fn has_explicit_write_entry_for_preserved_path(
|
||||
policy: &FileSystemSandboxPolicy,
|
||||
preserved_path: &AbsolutePathBuf,
|
||||
target: &Path,
|
||||
cwd: &Path,
|
||||
) -> bool {
|
||||
policy.resolved_entries_with_cwd(cwd).iter().any(|entry| {
|
||||
entry.access.can_write()
|
||||
&& target.starts_with(entry.path.as_path())
|
||||
&& entry.path.as_path().starts_with(preserved_path.as_path())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
|
||||
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
|
||||
path.as_path().is_file()
|
||||
&& path.as_path().file_name() == Some(OsStr::new(PRESERVED_GIT_PATH_NAME))
|
||||
}
|
||||
|
||||
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
|
||||
@@ -1449,7 +1576,14 @@ fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf
|
||||
|
||||
let trimmed = contents.trim();
|
||||
let (_, gitdir_raw) = match trimmed.split_once(':') {
|
||||
Some(parts) => parts,
|
||||
Some((prefix, gitdir_raw)) if prefix.trim() == "gitdir" => (prefix, gitdir_raw),
|
||||
Some(_) => {
|
||||
error!(
|
||||
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
None => {
|
||||
error!(
|
||||
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
|
||||
@@ -1540,12 +1674,14 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_roots_proactively_protect_missing_dot_codex() {
|
||||
fn writable_roots_proactively_protect_missing_preserved_paths() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let expected_root = AbsolutePathBuf::from_absolute_path(
|
||||
cwd.path().canonicalize().expect("canonicalize cwd"),
|
||||
)
|
||||
.expect("absolute canonical root");
|
||||
let expected_dot_git = expected_root.join(".git");
|
||||
let expected_dot_agents = expected_root.join(".agents");
|
||||
let expected_dot_codex = expected_root.join(".codex");
|
||||
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
@@ -1558,6 +1694,16 @@ mod tests {
|
||||
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
||||
assert_eq!(writable_roots.len(), 1);
|
||||
assert_eq!(writable_roots[0].root, expected_root);
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.read_only_subpaths
|
||||
.contains(&expected_dot_git)
|
||||
);
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.read_only_subpaths
|
||||
.contains(&expected_dot_agents)
|
||||
);
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.read_only_subpaths
|
||||
@@ -1613,12 +1759,86 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_roots_skip_default_dot_codex_when_explicit_user_rule_exists() {
|
||||
fn writable_roots_protect_missing_git_under_parent_git_repo() {
|
||||
let repo = TempDir::new().expect("tempdir");
|
||||
fs::create_dir(repo.path().join(".git")).expect("create parent .git");
|
||||
let cwd = repo.path().join("sub");
|
||||
fs::create_dir(&cwd).expect("create subdir");
|
||||
let expected_root =
|
||||
AbsolutePathBuf::from_absolute_path(cwd.canonicalize().expect("canonicalize cwd"))
|
||||
.expect("absolute canonical root");
|
||||
let expected_dot_git = expected_root.join(".git");
|
||||
let expected_dot_agents = expected_root.join(".agents");
|
||||
let expected_dot_codex = expected_root.join(".codex");
|
||||
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
}]);
|
||||
|
||||
let writable_roots = policy.get_writable_roots_with_cwd(&cwd);
|
||||
assert_eq!(writable_roots.len(), 1);
|
||||
assert_eq!(writable_roots[0].root, expected_root);
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.read_only_subpaths
|
||||
.contains(&expected_dot_git),
|
||||
"missing child .git under an existing parent repo should be read-only so git init fails"
|
||||
);
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.preserved_path_names
|
||||
.contains(&".git".to_string()),
|
||||
"missing child .git creation is denied by the preserved-name policy primitive"
|
||||
);
|
||||
assert!(!policy.can_write_path_with_cwd(expected_dot_git.join("config").as_path(), &cwd));
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.read_only_subpaths
|
||||
.contains(&expected_dot_agents)
|
||||
);
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.read_only_subpaths
|
||||
.contains(&expected_dot_codex)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_roots_protect_missing_git_when_parent_git_metadata_is_invalid() {
|
||||
let repo = TempDir::new().expect("tempdir");
|
||||
fs::create_dir(repo.path().join("real_git_dir")).expect("create real git dir");
|
||||
fs::write(repo.path().join(".git"), "notgitdir: real_git_dir").expect("write parent .git");
|
||||
let cwd = repo.path().join("sub");
|
||||
fs::create_dir(&cwd).expect("create subdir");
|
||||
let expected_root =
|
||||
AbsolutePathBuf::from_absolute_path(cwd.canonicalize().expect("canonicalize cwd"))
|
||||
.expect("absolute canonical root");
|
||||
let expected_dot_git = expected_root.join(".git");
|
||||
|
||||
assert!(
|
||||
default_read_only_subpaths_for_writable_root(
|
||||
&expected_root,
|
||||
/*protect_missing_preserved_paths*/ true,
|
||||
)
|
||||
.contains(&expected_dot_git),
|
||||
"invalid parent .git metadata should not suppress child .git protection"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_roots_skip_default_preserved_paths_when_explicit_user_rule_exists() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let expected_root = AbsolutePathBuf::from_absolute_path(
|
||||
cwd.path().canonicalize().expect("canonicalize cwd"),
|
||||
)
|
||||
.expect("absolute canonical root");
|
||||
let explicit_dot_git = expected_root.join(".git");
|
||||
let explicit_dot_agents = expected_root.join(".agents");
|
||||
let explicit_dot_codex = expected_root.join(".codex");
|
||||
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
@@ -1628,6 +1848,18 @@ mod tests {
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: explicit_dot_git.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: explicit_dot_agents.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: explicit_dot_codex.clone(),
|
||||
@@ -1641,12 +1873,42 @@ mod tests {
|
||||
.iter()
|
||||
.find(|root| root.root == expected_root)
|
||||
.expect("workspace writable root");
|
||||
assert!(
|
||||
!workspace_root
|
||||
.read_only_subpaths
|
||||
.contains(&explicit_dot_git),
|
||||
"explicit .git rule should win over the default preserved path carveout"
|
||||
);
|
||||
assert!(
|
||||
!workspace_root
|
||||
.preserved_path_names
|
||||
.contains(&".git".to_string()),
|
||||
"explicit .git rule should win over the preserved-name policy"
|
||||
);
|
||||
assert!(
|
||||
!workspace_root
|
||||
.read_only_subpaths
|
||||
.contains(&explicit_dot_agents),
|
||||
"explicit .agents rule should win over the default preserved path carveout"
|
||||
);
|
||||
assert!(
|
||||
!workspace_root
|
||||
.preserved_path_names
|
||||
.contains(&".agents".to_string()),
|
||||
"explicit .agents rule should win over the preserved-name policy"
|
||||
);
|
||||
assert!(
|
||||
!workspace_root
|
||||
.read_only_subpaths
|
||||
.contains(&explicit_dot_codex),
|
||||
"explicit .codex rule should win over the default protected carveout"
|
||||
);
|
||||
assert!(
|
||||
!workspace_root
|
||||
.preserved_path_names
|
||||
.contains(&".codex".to_string()),
|
||||
"explicit .codex rule should win over the preserved-name policy"
|
||||
);
|
||||
assert!(
|
||||
policy.can_write_path_with_cwd(
|
||||
explicit_dot_codex.join("config.toml").as_path(),
|
||||
@@ -1656,8 +1918,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_workspace_write_projection_blocks_missing_dot_codex_writes() {
|
||||
fn legacy_workspace_write_projection_blocks_missing_preserved_path_writes() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let dot_git_config = cwd.path().join(".git").join("config");
|
||||
let dot_agents_config = cwd.path().join(".agents").join("config");
|
||||
let dot_codex_config = cwd.path().join(".codex").join("config.toml");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
@@ -1669,19 +1933,49 @@ mod tests {
|
||||
let file_system_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, cwd.path());
|
||||
|
||||
assert!(!file_system_policy.can_write_path_with_cwd(&dot_git_config, cwd.path()));
|
||||
assert!(!file_system_policy.can_write_path_with_cwd(&dot_agents_config, cwd.path()));
|
||||
assert!(!file_system_policy.can_write_path_with_cwd(&dot_codex_config, cwd.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_workspace_write_projection_blocks_missing_preserved_paths_under_extra_writable_root()
|
||||
{
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let extra = TempDir::new().expect("extra writable root");
|
||||
let extra_root = AbsolutePathBuf::from_absolute_path(extra.path()).expect("absolute extra");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![extra_root],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy);
|
||||
|
||||
assert!(
|
||||
!file_system_policy
|
||||
.can_write_path_with_cwd(extra.path().join(".git/config").as_path(), cwd.path())
|
||||
);
|
||||
assert!(
|
||||
!file_system_policy
|
||||
.can_write_path_with_cwd(extra.path().join(".agents/config").as_path(), cwd.path())
|
||||
);
|
||||
assert!(!file_system_policy.can_write_path_with_cwd(
|
||||
extra.path().join(".codex/config.toml").as_path(),
|
||||
cwd.path()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_workspace_write_projection_accepts_relative_cwd() {
|
||||
let relative_cwd = Path::new("workspace");
|
||||
let expected_dot_codex = AbsolutePathBuf::from_absolute_path(
|
||||
let expected_root = AbsolutePathBuf::from_absolute_path(
|
||||
std::env::current_dir()
|
||||
.expect("current dir")
|
||||
.join(relative_cwd)
|
||||
.join(".codex"),
|
||||
.join(relative_cwd),
|
||||
)
|
||||
.expect("absolute dot codex");
|
||||
.expect("absolute root");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
@@ -1692,33 +1986,54 @@ mod tests {
|
||||
let file_system_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, relative_cwd);
|
||||
|
||||
let mut expected_entries = vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
];
|
||||
expected_entries.extend(
|
||||
default_read_only_subpaths_for_writable_root(
|
||||
&expected_root,
|
||||
/*protect_missing_preserved_paths*/ true,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|path| FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path },
|
||||
access: FileSystemAccessMode::Read,
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
file_system_policy,
|
||||
FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: expected_dot_codex,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
])
|
||||
FileSystemSandboxPolicy::restricted(expected_entries)
|
||||
);
|
||||
assert_eq!(
|
||||
forbidden_agent_preserved_path_write(
|
||||
Path::new(".git/config"),
|
||||
relative_cwd,
|
||||
&file_system_policy,
|
||||
),
|
||||
Some(".git")
|
||||
);
|
||||
assert!(
|
||||
!file_system_policy
|
||||
.can_write_path_with_cwd(Path::new(".codex/config.toml"), relative_cwd,)
|
||||
);
|
||||
assert!(
|
||||
!file_system_policy.can_write_path_with_cwd(
|
||||
Path::new(".agents/skills/example/SKILL.md"),
|
||||
relative_cwd,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -1861,6 +2176,20 @@ mod tests {
|
||||
.join(".codex"),
|
||||
)
|
||||
.expect("absolute .codex symlink");
|
||||
let expected_dot_git = AbsolutePathBuf::from_absolute_path(
|
||||
root.as_path()
|
||||
.canonicalize()
|
||||
.expect("canonicalize root")
|
||||
.join(".git"),
|
||||
)
|
||||
.expect("absolute .git");
|
||||
let expected_dot_agents = AbsolutePathBuf::from_absolute_path(
|
||||
root.as_path()
|
||||
.canonicalize()
|
||||
.expect("canonicalize root")
|
||||
.join(".agents"),
|
||||
)
|
||||
.expect("absolute .agents");
|
||||
let unexpected_decoy =
|
||||
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
|
||||
.expect("absolute canonical decoy");
|
||||
@@ -1874,7 +2203,7 @@ mod tests {
|
||||
assert_eq!(writable_roots.len(), 1);
|
||||
assert_eq!(
|
||||
writable_roots[0].read_only_subpaths,
|
||||
vec![expected_dot_codex]
|
||||
vec![expected_dot_git, expected_dot_agents, expected_dot_codex]
|
||||
);
|
||||
assert!(
|
||||
!writable_roots[0]
|
||||
@@ -1899,6 +2228,9 @@ mod tests {
|
||||
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
|
||||
let link_private = link_root.join("linked-private");
|
||||
let expected_root = link_root.clone();
|
||||
let expected_dot_git = expected_root.join(".git");
|
||||
let expected_dot_agents = expected_root.join(".agents");
|
||||
let expected_dot_codex = expected_root.join(".codex");
|
||||
let expected_linked_private = link_private.clone();
|
||||
let unexpected_decoy =
|
||||
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
|
||||
@@ -1920,7 +2252,12 @@ mod tests {
|
||||
assert_eq!(writable_roots[0].root, expected_root);
|
||||
assert_eq!(
|
||||
writable_roots[0].read_only_subpaths,
|
||||
vec![expected_linked_private]
|
||||
vec![
|
||||
expected_dot_git,
|
||||
expected_dot_agents,
|
||||
expected_dot_codex,
|
||||
expected_linked_private
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
!writable_roots[0]
|
||||
@@ -1946,6 +2283,9 @@ mod tests {
|
||||
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
|
||||
let link_private = link_root.join("linked-private");
|
||||
let expected_root = link_root.clone();
|
||||
let expected_dot_git = expected_root.join(".git");
|
||||
let expected_dot_agents = expected_root.join(".agents");
|
||||
let expected_dot_codex = expected_root.join(".codex");
|
||||
let expected_linked_private = link_private.clone();
|
||||
let unexpected_decoy =
|
||||
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
|
||||
@@ -1967,7 +2307,12 @@ mod tests {
|
||||
assert_eq!(writable_roots[0].root, expected_root);
|
||||
assert_eq!(
|
||||
writable_roots[0].read_only_subpaths,
|
||||
vec![expected_linked_private]
|
||||
vec![
|
||||
expected_dot_git,
|
||||
expected_dot_agents,
|
||||
expected_dot_codex,
|
||||
expected_linked_private
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
!writable_roots[0]
|
||||
@@ -1991,6 +2336,9 @@ mod tests {
|
||||
root.as_path().canonicalize().expect("canonicalize root"),
|
||||
)
|
||||
.expect("absolute canonical root");
|
||||
let expected_dot_git = expected_root.join(".git");
|
||||
let expected_dot_agents = expected_root.join(".agents");
|
||||
let expected_dot_codex = expected_root.join(".codex");
|
||||
let expected_alias = expected_root.join("alias-root");
|
||||
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
@@ -2007,7 +2355,15 @@ mod tests {
|
||||
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
||||
assert_eq!(writable_roots.len(), 1);
|
||||
assert_eq!(writable_roots[0].root, expected_root);
|
||||
assert_eq!(writable_roots[0].read_only_subpaths, vec![expected_alias]);
|
||||
assert_eq!(
|
||||
writable_roots[0].read_only_subpaths,
|
||||
vec![
|
||||
expected_dot_git,
|
||||
expected_dot_agents,
|
||||
expected_dot_codex,
|
||||
expected_alias
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
//! between user and agent.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::ops::Mul;
|
||||
use std::path::Path;
|
||||
@@ -84,6 +82,8 @@ pub use crate::permissions::FileSystemSandboxKind;
|
||||
pub use crate::permissions::FileSystemSandboxPolicy;
|
||||
pub use crate::permissions::FileSystemSpecialPath;
|
||||
pub use crate::permissions::NetworkSandboxPolicy;
|
||||
use crate::permissions::PRESERVED_PATH_NAMES;
|
||||
use crate::permissions::default_read_only_subpaths_for_writable_root;
|
||||
pub use crate::request_permissions::RequestPermissionsArgs;
|
||||
pub use crate::request_user_input::RequestUserInputEvent;
|
||||
|
||||
@@ -1091,6 +1091,10 @@ pub struct WritableRoot {
|
||||
|
||||
/// By construction, these subpaths are all under `root`.
|
||||
pub read_only_subpaths: Vec<AbsolutePathBuf>,
|
||||
|
||||
/// Path component names that must not be created or replaced under `root`
|
||||
/// unless the policy grants an explicit write rule for that preserved path.
|
||||
pub preserved_path_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl WritableRoot {
|
||||
@@ -1107,8 +1111,31 @@ impl WritableRoot {
|
||||
}
|
||||
}
|
||||
|
||||
if self.path_contains_preserved_name(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn path_contains_preserved_name(&self, path: &Path) -> bool {
|
||||
let Ok(relative_path) = path.strip_prefix(&self.root) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
relative_path.components().any(|component| {
|
||||
self.preserved_path_names
|
||||
.iter()
|
||||
.any(|name| component.as_os_str() == std::ffi::OsStr::new(name))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn default_preserved_path_names() -> Vec<String> {
|
||||
PRESERVED_PATH_NAMES
|
||||
.iter()
|
||||
.map(|name| (*name).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl FromStr for SandboxPolicy {
|
||||
@@ -1249,18 +1276,15 @@ impl SandboxPolicy {
|
||||
}
|
||||
|
||||
// For each root, compute subpaths that should remain read-only.
|
||||
let cwd_root = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
||||
roots
|
||||
.into_iter()
|
||||
.map(|writable_root| {
|
||||
let protect_missing_dot_codex = cwd_root
|
||||
.as_ref()
|
||||
.is_some_and(|cwd_root| cwd_root == &writable_root);
|
||||
WritableRoot {
|
||||
read_only_subpaths: default_read_only_subpaths_for_writable_root(
|
||||
&writable_root,
|
||||
protect_missing_dot_codex,
|
||||
/*protect_missing_preserved_paths*/ true,
|
||||
),
|
||||
preserved_path_names: default_preserved_path_names(),
|
||||
root: writable_root,
|
||||
}
|
||||
})
|
||||
@@ -1270,107 +1294,6 @@ impl SandboxPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_read_only_subpaths_for_writable_root(
|
||||
writable_root: &AbsolutePathBuf,
|
||||
protect_missing_dot_codex: bool,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
|
||||
let top_level_git = writable_root.join(".git");
|
||||
// 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.push(gitdir);
|
||||
}
|
||||
subpaths.push(top_level_git);
|
||||
}
|
||||
|
||||
let top_level_agents = writable_root.join(".agents");
|
||||
if top_level_agents.as_path().is_dir() {
|
||||
subpaths.push(top_level_agents);
|
||||
}
|
||||
|
||||
// Keep top-level project metadata under .codex read-only to the agent by
|
||||
// default. For the workspace root itself, protect it even before the
|
||||
// directory exists so first-time creation still goes through the
|
||||
// protected-path approval flow.
|
||||
let top_level_codex = writable_root.join(".codex");
|
||||
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
|
||||
subpaths.push(top_level_codex);
|
||||
}
|
||||
|
||||
let mut deduped = Vec::with_capacity(subpaths.len());
|
||||
let mut seen = HashSet::new();
|
||||
for path in subpaths {
|
||||
if seen.insert(path.to_path_buf()) {
|
||||
deduped.push(path);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
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 = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base);
|
||||
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 {
|
||||
@@ -4375,7 +4298,6 @@ mod tests {
|
||||
#[test]
|
||||
fn restricted_file_system_policy_derives_effective_paths() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
std::fs::create_dir_all(cwd.path().join(".agents")).expect("create .agents");
|
||||
std::fs::create_dir_all(cwd.path().join(".codex")).expect("create .codex");
|
||||
let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path())
|
||||
.expect("canonicalize cwd");
|
||||
@@ -4454,8 +4376,22 @@ mod tests {
|
||||
let expected_docs_public =
|
||||
AbsolutePathBuf::from_absolute_path(canonical_cwd.join("docs/public"))
|
||||
.expect("canonical docs/public");
|
||||
let expected_docs_public_dot_agents =
|
||||
AbsolutePathBuf::from_absolute_path(canonical_cwd.join("docs/public/.agents"))
|
||||
.expect("canonical docs/public/.agents");
|
||||
let expected_docs_public_dot_codex =
|
||||
AbsolutePathBuf::from_absolute_path(canonical_cwd.join("docs/public/.codex"))
|
||||
.expect("canonical docs/public/.codex");
|
||||
let expected_docs_public_dot_git =
|
||||
AbsolutePathBuf::from_absolute_path(canonical_cwd.join("docs/public/.git"))
|
||||
.expect("canonical docs/public/.git");
|
||||
let expected_dot_codex = AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".codex"))
|
||||
.expect("canonical .codex");
|
||||
let expected_dot_git = AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".git"))
|
||||
.expect("canonical .git");
|
||||
let expected_dot_agents =
|
||||
AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".agents"))
|
||||
.expect("canonical .agents");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
@@ -4480,11 +4416,93 @@ mod tests {
|
||||
(
|
||||
canonical_cwd,
|
||||
vec![
|
||||
expected_dot_agents.to_path_buf(),
|
||||
expected_dot_codex.to_path_buf(),
|
||||
expected_dot_git.to_path_buf(),
|
||||
expected_docs.to_path_buf()
|
||||
],
|
||||
),
|
||||
(expected_docs_public.to_path_buf(), Vec::new()),
|
||||
(
|
||||
expected_docs_public.to_path_buf(),
|
||||
vec![
|
||||
expected_docs_public_dot_agents.to_path_buf(),
|
||||
expected_docs_public_dot_codex.to_path_buf(),
|
||||
expected_docs_public_dot_git.to_path_buf(),
|
||||
],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_write_protects_missing_git_under_parent_repo() {
|
||||
let repo = TempDir::new().expect("tempdir");
|
||||
std::fs::create_dir(repo.path().join(".git")).expect("create parent .git");
|
||||
let cwd = repo.path().join("sub");
|
||||
std::fs::create_dir(&cwd).expect("create subdir");
|
||||
let expected_root = AbsolutePathBuf::from_absolute_path(&cwd).expect("absolute cwd");
|
||||
let expected_dot_git =
|
||||
AbsolutePathBuf::from_absolute_path(cwd.join(".git")).expect("canonical .git");
|
||||
let expected_dot_codex =
|
||||
AbsolutePathBuf::from_absolute_path(cwd.join(".codex")).expect("canonical .codex");
|
||||
let expected_dot_agents =
|
||||
AbsolutePathBuf::from_absolute_path(cwd.join(".agents")).expect("canonical .agents");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
sorted_writable_roots(policy.get_writable_roots_with_cwd(&cwd)),
|
||||
vec![(
|
||||
expected_root.to_path_buf(),
|
||||
vec![
|
||||
expected_dot_agents.to_path_buf(),
|
||||
expected_dot_codex.to_path_buf(),
|
||||
expected_dot_git.to_path_buf()
|
||||
],
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_write_reserves_missing_preserved_paths_under_configured_writable_roots() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let cwd = root.path().join("cwd");
|
||||
let extra = root.path().join("extra");
|
||||
std::fs::create_dir_all(&cwd).expect("create cwd");
|
||||
std::fs::create_dir_all(&extra).expect("create extra writable root");
|
||||
let expected_cwd = AbsolutePathBuf::from_absolute_path(&cwd).expect("absolute cwd");
|
||||
let expected_extra =
|
||||
AbsolutePathBuf::from_absolute_path(&extra).expect("absolute extra root");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![expected_extra.clone()],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
sorted_writable_roots(policy.get_writable_roots_with_cwd(&cwd)),
|
||||
vec![
|
||||
(
|
||||
expected_cwd.to_path_buf(),
|
||||
vec![
|
||||
expected_cwd.join(".agents").to_path_buf(),
|
||||
expected_cwd.join(".codex").to_path_buf(),
|
||||
expected_cwd.join(".git").to_path_buf()
|
||||
],
|
||||
),
|
||||
(
|
||||
expected_extra.to_path_buf(),
|
||||
vec![
|
||||
expected_extra.join(".agents").to_path_buf(),
|
||||
expected_extra.join(".codex").to_path_buf(),
|
||||
expected_extra.join(".git").to_path_buf()
|
||||
],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,6 +328,7 @@ fn root_absolute_path() -> AbsolutePathBuf {
|
||||
struct SeatbeltAccessRoot {
|
||||
root: AbsolutePathBuf,
|
||||
excluded_subpaths: Vec<AbsolutePathBuf>,
|
||||
preserved_path_names: Vec<String>,
|
||||
}
|
||||
|
||||
fn build_seatbelt_access_policy(
|
||||
@@ -342,9 +343,9 @@ fn build_seatbelt_access_policy(
|
||||
let root =
|
||||
normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root);
|
||||
let root_param = format!("{param_prefix}_{index}");
|
||||
params.push((root_param.clone(), root.into_path_buf()));
|
||||
params.push((root_param.clone(), root.clone().into_path_buf()));
|
||||
|
||||
if access_root.excluded_subpaths.is_empty() {
|
||||
if access_root.excluded_subpaths.is_empty() && access_root.preserved_path_names.is_empty() {
|
||||
policy_components.push(format!("(subpath (param \"{root_param}\"))"));
|
||||
continue;
|
||||
}
|
||||
@@ -367,6 +368,11 @@ fn build_seatbelt_access_policy(
|
||||
"(require-not (subpath (param \"{excluded_param}\")))"
|
||||
));
|
||||
}
|
||||
for preserved_name in access_root.preserved_path_names {
|
||||
let regex =
|
||||
seatbelt_preserved_path_name_regex(&root, &preserved_name).replace('"', "\\\"");
|
||||
require_parts.push(format!(r#"(require-not (regex #"{regex}"))"#));
|
||||
}
|
||||
policy_components.push(format!("(require-all {} )", require_parts.join(" ")));
|
||||
}
|
||||
|
||||
@@ -380,6 +386,20 @@ fn build_seatbelt_access_policy(
|
||||
}
|
||||
}
|
||||
|
||||
fn seatbelt_preserved_path_name_regex(root: &AbsolutePathBuf, name: &str) -> String {
|
||||
let mut root = root.to_string_lossy().to_string();
|
||||
while root.len() > 1 && root.ends_with('/') {
|
||||
root.pop();
|
||||
}
|
||||
let root = regex_lite::escape(&root);
|
||||
let name = regex_lite::escape(name);
|
||||
if root == "/" {
|
||||
format!(r#"^/(.*/)?{name}(/.*)?$"#)
|
||||
} else {
|
||||
format!(r#"^{root}/(.*/)?{name}(/.*)?$"#)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_seatbelt_unreadable_glob_policy(
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
cwd: &Path,
|
||||
@@ -586,6 +606,7 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
|
||||
vec![SeatbeltAccessRoot {
|
||||
root: root_absolute_path(),
|
||||
excluded_subpaths: unreadable_roots.clone(),
|
||||
preserved_path_names: Vec::new(),
|
||||
}],
|
||||
)
|
||||
}
|
||||
@@ -599,6 +620,7 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
|
||||
.map(|root| SeatbeltAccessRoot {
|
||||
root: root.root,
|
||||
excluded_subpaths: root.read_only_subpaths,
|
||||
preserved_path_names: root.preserved_path_names,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
@@ -618,6 +640,7 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
|
||||
vec![SeatbeltAccessRoot {
|
||||
root: root_absolute_path(),
|
||||
excluded_subpaths: unreadable_roots,
|
||||
preserved_path_names: Vec::new(),
|
||||
}],
|
||||
);
|
||||
(
|
||||
@@ -638,6 +661,7 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
|
||||
.filter(|path| path.as_path().starts_with(root.as_path()))
|
||||
.cloned()
|
||||
.collect(),
|
||||
preserved_path_names: Vec::new(),
|
||||
root,
|
||||
})
|
||||
.collect(),
|
||||
|
||||
@@ -26,6 +26,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::permissions::PRESERVED_PATH_NAMES;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -59,6 +60,26 @@ fn seatbelt_policy_arg(args: &[String]) -> &str {
|
||||
.expect("seatbelt args should include policy text")
|
||||
}
|
||||
|
||||
fn seatbelt_preserved_path_name_requirements(root: &Path) -> String {
|
||||
let mut root = root.to_string_lossy().to_string();
|
||||
while root.len() > 1 && root.ends_with('/') {
|
||||
root.pop();
|
||||
}
|
||||
let root = regex_lite::escape(&root);
|
||||
PRESERVED_PATH_NAMES
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let name = regex_lite::escape(name);
|
||||
if root == "/" {
|
||||
format!(r#"(require-not (regex #"^/(.*/)?{name}(/.*)?$"))"#)
|
||||
} else {
|
||||
format!(r#"(require-not (regex #"^{root}/(.*/)?{name}(/.*)?$"))"#)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
struct TestConfigReloader;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -200,8 +221,10 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access()
|
||||
writable_definitions,
|
||||
vec![
|
||||
"-DWRITABLE_ROOT_0=/".to_string(),
|
||||
"-DWRITABLE_ROOT_0_EXCLUDED_0=/.codex".to_string(),
|
||||
format!("-DWRITABLE_ROOT_0_EXCLUDED_1={}", unreadable_root.display()),
|
||||
"-DWRITABLE_ROOT_0_EXCLUDED_0=/.git".to_string(),
|
||||
"-DWRITABLE_ROOT_0_EXCLUDED_1=/.agents".to_string(),
|
||||
"-DWRITABLE_ROOT_0_EXCLUDED_2=/.codex".to_string(),
|
||||
format!("-DWRITABLE_ROOT_0_EXCLUDED_3={}", unreadable_root.display()),
|
||||
],
|
||||
"unexpected write carveout parameters in args: {args:#?}"
|
||||
);
|
||||
@@ -773,12 +796,13 @@ fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() {
|
||||
#[test]
|
||||
fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
|
||||
// Create a temporary workspace with two writable roots: one containing
|
||||
// top-level .git and .codex directories and one without them.
|
||||
// top-level preserved paths and one without them.
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let PopulatedTmp {
|
||||
vulnerable_root,
|
||||
vulnerable_root_canonical,
|
||||
dot_git_canonical,
|
||||
dot_agents_canonical: _,
|
||||
dot_codex_canonical,
|
||||
empty_root,
|
||||
empty_root_canonical,
|
||||
@@ -828,12 +852,20 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
|
||||
);
|
||||
assert!(
|
||||
policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_0"),
|
||||
"expected cwd .git carveout in policy:\n{policy_text}",
|
||||
);
|
||||
assert!(
|
||||
policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_1"),
|
||||
"expected cwd .agents carveout in policy:\n{policy_text}",
|
||||
);
|
||||
assert!(
|
||||
policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_2"),
|
||||
"expected cwd .codex carveout in policy:\n{policy_text}",
|
||||
);
|
||||
assert!(
|
||||
policy_text.contains("WRITABLE_ROOT_1_EXCLUDED_0")
|
||||
&& policy_text.contains("WRITABLE_ROOT_1_EXCLUDED_1"),
|
||||
"expected explicit writable root .git/.codex carveouts in policy:\n{policy_text}",
|
||||
"expected explicit writable root preserved path carveouts in policy:\n{policy_text}",
|
||||
);
|
||||
assert!(
|
||||
policy_text.contains("(subpath (param \"WRITABLE_ROOT_2\"))"),
|
||||
@@ -849,6 +881,20 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0_EXCLUDED_0={}",
|
||||
cwd.canonicalize()
|
||||
.expect("canonicalize cwd")
|
||||
.join(".git")
|
||||
.display()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0_EXCLUDED_1={}",
|
||||
cwd.canonicalize()
|
||||
.expect("canonicalize cwd")
|
||||
.join(".agents")
|
||||
.display()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0_EXCLUDED_2={}",
|
||||
cwd.canonicalize()
|
||||
.expect("canonicalize cwd")
|
||||
.join(".codex")
|
||||
@@ -864,12 +910,28 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_1_EXCLUDED_1={}",
|
||||
vulnerable_root_canonical.join(".agents").to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_1_EXCLUDED_2={}",
|
||||
dot_codex_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_2={}",
|
||||
empty_root_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_2_EXCLUDED_0={}",
|
||||
empty_root_canonical.join(".git").to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_2_EXCLUDED_1={}",
|
||||
empty_root_canonical.join(".agents").to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_2_EXCLUDED_2={}",
|
||||
empty_root_canonical.join(".codex").to_string_lossy()
|
||||
),
|
||||
];
|
||||
let writable_definitions: Vec<String> = args
|
||||
.iter()
|
||||
@@ -1037,11 +1099,11 @@ fn create_seatbelt_args_block_first_time_dot_codex_creation_with_exact_and_desce
|
||||
|
||||
let policy_text = seatbelt_policy_arg(&args);
|
||||
assert!(
|
||||
policy_text.contains("(require-not (literal (param \"WRITABLE_ROOT_0_EXCLUDED_1\")))"),
|
||||
policy_text.contains("(require-not (literal (param \"WRITABLE_ROOT_0_EXCLUDED_2\")))"),
|
||||
"expected exact .codex carveout in policy:\n{policy_text}"
|
||||
);
|
||||
assert!(
|
||||
policy_text.contains("(require-not (subpath (param \"WRITABLE_ROOT_0_EXCLUDED_1\")))"),
|
||||
policy_text.contains("(require-not (subpath (param \"WRITABLE_ROOT_0_EXCLUDED_2\")))"),
|
||||
"expected descendant .codex carveout in policy:\n{policy_text}"
|
||||
);
|
||||
}
|
||||
@@ -1146,19 +1208,20 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() {
|
||||
#[test]
|
||||
fn create_seatbelt_args_for_cwd_as_git_repo() {
|
||||
// Create a temporary workspace with two writable roots: one containing
|
||||
// top-level .git and .codex directories and one without them.
|
||||
// top-level preserved paths and one without them.
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let PopulatedTmp {
|
||||
vulnerable_root,
|
||||
vulnerable_root_canonical,
|
||||
dot_git_canonical,
|
||||
dot_agents_canonical,
|
||||
dot_codex_canonical,
|
||||
..
|
||||
} = populate_tmpdir(tmp.path());
|
||||
|
||||
// Build a policy that does not specify any writable_roots, but does
|
||||
// use the default ones (cwd and TMPDIR) and verifies the `.git` and
|
||||
// `.codex` checks are done properly for cwd.
|
||||
// use the default ones (cwd and TMPDIR) and verifies the preserved
|
||||
// path checks are done properly for cwd.
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
@@ -1193,23 +1256,33 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
|
||||
.and_then(|p| p.canonicalize().ok())
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
|
||||
let tempdir_policy_entry = if tmpdir_env_var.is_some() {
|
||||
r#" (require-all (subpath (param "WRITABLE_ROOT_2")) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_1"))) )"#
|
||||
let slash_tmp = PathBuf::from("/tmp")
|
||||
.canonicalize()
|
||||
.expect("canonicalize /tmp");
|
||||
let tempdir_policy_entry = if let Some(p) = &tmpdir_env_var {
|
||||
let preserved_requirements = seatbelt_preserved_path_name_requirements(Path::new(p));
|
||||
format!(
|
||||
r#" (require-all (subpath (param "WRITABLE_ROOT_2")) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_2"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_2"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_3"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_3"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_4"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_4"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_5"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_5"))) {preserved_requirements} )"#
|
||||
)
|
||||
} else {
|
||||
""
|
||||
String::new()
|
||||
};
|
||||
let root_0_preserved_requirements =
|
||||
seatbelt_preserved_path_name_requirements(&vulnerable_root_canonical);
|
||||
let root_1_preserved_requirements = seatbelt_preserved_path_name_requirements(&slash_tmp);
|
||||
|
||||
// Build the expected policy text using a raw string for readability.
|
||||
// Note that the policy includes:
|
||||
// - the base policy,
|
||||
// - read-only access to the filesystem,
|
||||
// - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2.
|
||||
// - write access to WRITABLE_ROOT_0, WRITABLE_ROOT_1, and cwd as
|
||||
// WRITABLE_ROOT_2, each with preserved path carveouts.
|
||||
let expected_policy = format!(
|
||||
r#"{MACOS_SEATBELT_BASE_POLICY}
|
||||
; allow read-only file operations
|
||||
(allow file-read*)
|
||||
(allow file-write*
|
||||
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
|
||||
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_2"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_2"))) {root_0_preserved_requirements} ) (require-all (subpath (param "WRITABLE_ROOT_1")) (require-not (literal (param "WRITABLE_ROOT_1_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_1_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_1_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_1_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_1_EXCLUDED_2"))) (require-not (subpath (param "WRITABLE_ROOT_1_EXCLUDED_2"))) {root_1_preserved_requirements} ){tempdir_policy_entry}
|
||||
)
|
||||
|
||||
"#,
|
||||
@@ -1228,25 +1301,42 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0_EXCLUDED_1={}",
|
||||
dot_codex_canonical.to_string_lossy()
|
||||
dot_agents_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_1={}",
|
||||
PathBuf::from("/tmp")
|
||||
.canonicalize()
|
||||
.expect("canonicalize /tmp")
|
||||
.to_string_lossy()
|
||||
"-DWRITABLE_ROOT_0_EXCLUDED_2={}",
|
||||
dot_codex_canonical.to_string_lossy()
|
||||
),
|
||||
format!("-DWRITABLE_ROOT_1={}", slash_tmp.to_string_lossy()),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_1_EXCLUDED_0={}",
|
||||
slash_tmp.join(".git").to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_1_EXCLUDED_1={}",
|
||||
slash_tmp.join(".agents").to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_1_EXCLUDED_2={}",
|
||||
slash_tmp.join(".codex").to_string_lossy()
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(p) = tmpdir_env_var {
|
||||
expected_args.push(format!("-DWRITABLE_ROOT_2={p}"));
|
||||
expected_args.push(format!("-DWRITABLE_ROOT_2_EXCLUDED_0={p}/.git"));
|
||||
expected_args.push(format!("-DWRITABLE_ROOT_2_EXCLUDED_1={p}/.agents"));
|
||||
expected_args.push(format!("-DWRITABLE_ROOT_2_EXCLUDED_2={p}/.codex"));
|
||||
expected_args.push(format!(
|
||||
"-DWRITABLE_ROOT_2_EXCLUDED_0={}",
|
||||
"-DWRITABLE_ROOT_2_EXCLUDED_3={}",
|
||||
dot_git_canonical.to_string_lossy()
|
||||
));
|
||||
expected_args.push(format!(
|
||||
"-DWRITABLE_ROOT_2_EXCLUDED_1={}",
|
||||
"-DWRITABLE_ROOT_2_EXCLUDED_4={}",
|
||||
dot_agents_canonical.to_string_lossy()
|
||||
));
|
||||
expected_args.push(format!(
|
||||
"-DWRITABLE_ROOT_2_EXCLUDED_5={}",
|
||||
dot_codex_canonical.to_string_lossy()
|
||||
));
|
||||
}
|
||||
@@ -1264,7 +1354,7 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
|
||||
}
|
||||
|
||||
struct PopulatedTmp {
|
||||
/// Path containing a .git and .codex subfolder.
|
||||
/// Path containing preserved subfolders.
|
||||
/// For the purposes of this test, we consider this a "vulnerable" root
|
||||
/// because a bad actor could write to .git/hooks/pre-commit so an
|
||||
/// unsuspecting user would run code as privileged the next time they
|
||||
@@ -1274,9 +1364,10 @@ struct PopulatedTmp {
|
||||
vulnerable_root: PathBuf,
|
||||
vulnerable_root_canonical: PathBuf,
|
||||
dot_git_canonical: PathBuf,
|
||||
dot_agents_canonical: PathBuf,
|
||||
dot_codex_canonical: PathBuf,
|
||||
|
||||
/// Path without .git or .codex subfolders.
|
||||
/// Path without preserved subfolders.
|
||||
empty_root: PathBuf,
|
||||
/// Canonicalized version of `empty_root`.
|
||||
empty_root_canonical: PathBuf,
|
||||
@@ -1310,12 +1401,14 @@ fn populate_tmpdir(tmp: &Path) -> PopulatedTmp {
|
||||
.canonicalize()
|
||||
.expect("canonicalize vulnerable_root");
|
||||
let dot_git_canonical = vulnerable_root_canonical.join(".git");
|
||||
let dot_agents_canonical = vulnerable_root_canonical.join(".agents");
|
||||
let dot_codex_canonical = vulnerable_root_canonical.join(".codex");
|
||||
let empty_root_canonical = empty_root.canonicalize().expect("canonicalize empty_root");
|
||||
PopulatedTmp {
|
||||
vulnerable_root,
|
||||
vulnerable_root_canonical,
|
||||
dot_git_canonical,
|
||||
dot_agents_canonical,
|
||||
dot_codex_canonical,
|
||||
empty_root,
|
||||
empty_root_canonical,
|
||||
|
||||
@@ -119,6 +119,55 @@ pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option<Vec<Vec<Strin
|
||||
try_parse_word_only_commands_sequence(&tree, script)
|
||||
}
|
||||
|
||||
/// Returns command word prefixes within a `bash -lc "..."` or `zsh -lc "..."`
|
||||
/// invocation, including commands nested in control-flow or substitutions.
|
||||
///
|
||||
/// This is intentionally more permissive than
|
||||
/// [`parse_shell_lc_plain_commands`]: it extracts the argv-shaped prefix from
|
||||
/// each command node and ignores shell attachments. Callers
|
||||
/// should use it only for conservative deny checks, not for allow-listing.
|
||||
pub fn parse_shell_lc_command_word_prefixes(command: &[String]) -> Option<Vec<Vec<String>>> {
|
||||
let (_, script) = extract_bash_command(command)?;
|
||||
let tree = try_parse_shell(script)?;
|
||||
let root = tree.root_node();
|
||||
if root.has_error() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut commands = Vec::new();
|
||||
for command_node in find_command_nodes(root) {
|
||||
if let Some(words) = parse_command_word_prefix_from_node(command_node, script)
|
||||
&& !words.is_empty()
|
||||
{
|
||||
commands.push(words);
|
||||
}
|
||||
}
|
||||
|
||||
(!commands.is_empty()).then_some(commands)
|
||||
}
|
||||
|
||||
/// Returns literal write redirection targets within a `bash -lc "..."` or
|
||||
/// `zsh -lc "..."` invocation.
|
||||
pub fn parse_shell_lc_write_redirection_targets(command: &[String]) -> Option<Vec<String>> {
|
||||
let (_, script) = extract_bash_command(command)?;
|
||||
let tree = try_parse_shell(script)?;
|
||||
let root = tree.root_node();
|
||||
if root.has_error() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut targets = Vec::new();
|
||||
for redirect_node in find_nodes_by_kind(root, "file_redirect") {
|
||||
if file_redirect_uses_write_operator(redirect_node)
|
||||
&& let Some(target) = parse_redirection_target(redirect_node, script)
|
||||
{
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
(!targets.is_empty()).then_some(targets)
|
||||
}
|
||||
|
||||
/// Returns the parsed argv for a single shell command in a here-doc style
|
||||
/// script (`<<`), as long as the script contains exactly one command node.
|
||||
pub fn parse_shell_lc_single_command_prefix(command: &[String]) -> Option<Vec<String>> {
|
||||
@@ -194,6 +243,100 @@ fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option<Ve
|
||||
Some(words)
|
||||
}
|
||||
|
||||
fn parse_command_word_prefix_from_node(cmd: Node<'_>, src: &str) -> Option<Vec<String>> {
|
||||
if cmd.kind() != "command" {
|
||||
return None;
|
||||
}
|
||||
let mut words = Vec::new();
|
||||
let mut cursor = cmd.walk();
|
||||
for child in cmd.named_children(&mut cursor) {
|
||||
match child.kind() {
|
||||
"command_name" => {
|
||||
let word_node = child.named_child(0)?;
|
||||
if !matches!(word_node.kind(), "word" | "number") {
|
||||
return None;
|
||||
}
|
||||
words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
}
|
||||
"word" | "number" => {
|
||||
words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
}
|
||||
"string" => {
|
||||
let parsed = parse_double_quoted_string(child, src)?;
|
||||
words.push(parsed);
|
||||
}
|
||||
"raw_string" => {
|
||||
let parsed = parse_raw_string(child, src)?;
|
||||
words.push(parsed);
|
||||
}
|
||||
"concatenation" => {
|
||||
let parsed = parse_concatenation(child, src)?;
|
||||
words.push(parsed);
|
||||
}
|
||||
"variable_assignment" | "comment" => {}
|
||||
kind if is_allowed_heredoc_attachment_kind(kind) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(words)
|
||||
}
|
||||
|
||||
fn file_redirect_uses_write_operator(node: Node<'_>) -> bool {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if !child.is_named() && child.kind().contains('>') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_redirection_target(node: Node<'_>, src: &str) -> Option<String> {
|
||||
let mut target = None;
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
if let Some(parsed) = parse_literal_shell_word(child, src) {
|
||||
target = Some(parsed);
|
||||
}
|
||||
}
|
||||
target
|
||||
}
|
||||
|
||||
fn parse_literal_shell_word(node: Node<'_>, src: &str) -> Option<String> {
|
||||
match node.kind() {
|
||||
"word" | "number" => Some(node.utf8_text(src.as_bytes()).ok()?.to_owned()),
|
||||
"string" => parse_double_quoted_string(node, src),
|
||||
"raw_string" => parse_raw_string(node, src),
|
||||
"concatenation" => parse_concatenation(node, src),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_concatenation(node: Node<'_>, src: &str) -> Option<String> {
|
||||
let mut concatenated = String::new();
|
||||
let mut cursor = node.walk();
|
||||
for part in node.named_children(&mut cursor) {
|
||||
match part.kind() {
|
||||
"word" | "number" => {
|
||||
concatenated.push_str(part.utf8_text(src.as_bytes()).ok()?.to_owned().as_str());
|
||||
}
|
||||
"string" => {
|
||||
let parsed = parse_double_quoted_string(part, src)?;
|
||||
concatenated.push_str(&parsed);
|
||||
}
|
||||
"raw_string" => {
|
||||
let parsed = parse_raw_string(part, src)?;
|
||||
concatenated.push_str(&parsed);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if concatenated.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(concatenated)
|
||||
}
|
||||
|
||||
fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option<Vec<String>> {
|
||||
if cmd.kind() != "command" {
|
||||
return None;
|
||||
@@ -268,6 +411,29 @@ fn find_single_command_node(root: Node<'_>) -> Option<Node<'_>> {
|
||||
single_command
|
||||
}
|
||||
|
||||
fn find_command_nodes(root: Node<'_>) -> Vec<Node<'_>> {
|
||||
let mut command_nodes = find_nodes_by_kind(root, "command");
|
||||
command_nodes.sort_by_key(Node::start_byte);
|
||||
command_nodes
|
||||
}
|
||||
|
||||
fn find_nodes_by_kind<'a>(root: Node<'a>, kind: &str) -> Vec<Node<'a>> {
|
||||
let mut stack = vec![root];
|
||||
let mut matches = Vec::new();
|
||||
while let Some(node) = stack.pop() {
|
||||
if node.kind() == kind {
|
||||
matches.push(node);
|
||||
}
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
matches.sort_by_key(Node::start_byte);
|
||||
matches
|
||||
}
|
||||
|
||||
fn has_named_descendant_kind(node: Node<'_>, kind: &str) -> bool {
|
||||
let mut stack = vec![node];
|
||||
while let Some(current) = stack.pop() {
|
||||
@@ -453,6 +619,64 @@ mod tests {
|
||||
assert_eq!(parsed, vec![vec!["ls".to_string()]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_command_word_prefixes_extracts_complex_script_commands() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
r#"set -e
|
||||
top=$(git rev-parse --show-toplevel)
|
||||
if git init -q; then
|
||||
exit 22
|
||||
fi
|
||||
if mkdir .codex; then
|
||||
exit 23
|
||||
fi
|
||||
printf pwned > .git/config
|
||||
"#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let parsed = parse_shell_lc_command_word_prefixes(&command).expect("parse script");
|
||||
|
||||
assert!(parsed.contains(&vec![
|
||||
"git".to_string(),
|
||||
"rev-parse".to_string(),
|
||||
"--show-toplevel".to_string()
|
||||
]));
|
||||
assert!(parsed.contains(&vec![
|
||||
"git".to_string(),
|
||||
"init".to_string(),
|
||||
"-q".to_string()
|
||||
]));
|
||||
assert!(parsed.contains(&vec!["mkdir".to_string(), ".codex".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_lc_write_redirection_targets_extracts_literal_writes() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
r#"printf pwned > .git
|
||||
cat < .git/config
|
||||
printf ok >> .codex/log
|
||||
printf ok > ".agents/config"
|
||||
"#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let parsed = parse_shell_lc_write_redirection_targets(&command).expect("parse redirects");
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
".git".to_string(),
|
||||
".codex/log".to_string(),
|
||||
".agents/config".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_concatenated_flag_and_value() {
|
||||
// Test case: -g"*.py" (flag directly concatenated with quoted value)
|
||||
|
||||
@@ -6,6 +6,8 @@ pub mod bash;
|
||||
pub(crate) mod command_safety;
|
||||
pub mod parse_command;
|
||||
pub mod powershell;
|
||||
mod preserved_path_write;
|
||||
|
||||
pub use command_safety::is_dangerous_command;
|
||||
pub use command_safety::is_safe_command;
|
||||
pub use preserved_path_write::preserved_path_write_forbidden_reason;
|
||||
|
||||
138
codex-rs/shell-command/src/preserved_path_write.rs
Normal file
138
codex-rs/shell-command/src/preserved_path_write.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use std::path::Path;
|
||||
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::forbidden_agent_preserved_path_write;
|
||||
|
||||
pub fn preserved_path_write_forbidden_reason(
|
||||
command: &[String],
|
||||
cwd: &Path,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
) -> Option<String> {
|
||||
if let Some(targets) = crate::bash::parse_shell_lc_write_redirection_targets(command) {
|
||||
for target in targets {
|
||||
if let Some(name) = forbidden_agent_preserved_path_write(
|
||||
Path::new(&target),
|
||||
cwd,
|
||||
file_system_sandbox_policy,
|
||||
) {
|
||||
return Some(preserved_path_write_reason(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn preserved_path_write_reason(name: &str) -> String {
|
||||
format!("command targets preserved workspace metadata path `{name}`")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::preserved_path_write_forbidden_reason;
|
||||
|
||||
struct TestDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-preserved-path-write-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir(&path).expect("create tempdir");
|
||||
Self { path }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_workspace_write_policy() -> FileSystemSandboxPolicy {
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserved_path_detector_allows_normal_git_under_parent_repo() {
|
||||
let repo = TestDir::new("normal-git-under-parent-repo");
|
||||
std::fs::create_dir(repo.path().join(".git")).expect("create parent .git");
|
||||
let cwd = repo.path().join("sub");
|
||||
std::fs::create_dir(&cwd).expect("create cwd");
|
||||
let policy = legacy_workspace_write_policy();
|
||||
|
||||
let reason = preserved_path_write_forbidden_reason(
|
||||
&[
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"git status --short".to_string(),
|
||||
],
|
||||
&cwd,
|
||||
&policy,
|
||||
);
|
||||
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserved_path_detector_leaves_direct_writes_to_sandbox_policy() {
|
||||
let cwd = TestDir::new("direct-preserved-path-writes");
|
||||
let policy = legacy_workspace_write_policy();
|
||||
|
||||
let reason = preserved_path_write_forbidden_reason(
|
||||
&[
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"touch .git && mkdir -p .codex".to_string(),
|
||||
],
|
||||
cwd.path(),
|
||||
&policy,
|
||||
);
|
||||
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserved_path_detector_blocks_preserved_path_redirections() {
|
||||
let repo = TestDir::new("preserved-path-redirections");
|
||||
std::fs::create_dir(repo.path().join(".git")).expect("create parent .git");
|
||||
let cwd = repo.path().join("sub");
|
||||
std::fs::create_dir(&cwd).expect("create cwd");
|
||||
let policy = legacy_workspace_write_policy();
|
||||
|
||||
let reason = preserved_path_write_forbidden_reason(
|
||||
&[
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"printf pwned > .git".to_string(),
|
||||
],
|
||||
&cwd,
|
||||
&policy,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
reason,
|
||||
Some("command targets preserved workspace metadata path `.git`".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -530,7 +530,7 @@ impl App {
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
permission_profile: _,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
@@ -614,7 +614,10 @@ impl App {
|
||||
approvals_reviewer
|
||||
.unwrap_or(self.chat_widget.config_ref().approvals_reviewer),
|
||||
sandbox_policy.clone(),
|
||||
permission_profile.clone(),
|
||||
runtime_permission_profile_for_turn_start(
|
||||
self.runtime_sandbox_policy_override.as_ref(),
|
||||
sandbox_policy,
|
||||
),
|
||||
model.to_string(),
|
||||
effort,
|
||||
*summary,
|
||||
@@ -1482,3 +1485,47 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_permission_profile_for_turn_start(
|
||||
runtime_sandbox_policy_override: Option<&SandboxPolicy>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> Option<codex_protocol::models::PermissionProfile> {
|
||||
runtime_sandbox_policy_override?;
|
||||
match sandbox_policy {
|
||||
SandboxPolicy::ExternalSandbox { .. } => None,
|
||||
SandboxPolicy::ReadOnly { .. }
|
||||
| SandboxPolicy::WorkspaceWrite { .. }
|
||||
| SandboxPolicy::DangerFullAccess => Some(
|
||||
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(sandbox_policy),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn runtime_permission_profile_for_turn_start_only_when_sandbox_was_overridden() {
|
||||
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
|
||||
assert_eq!(
|
||||
runtime_permission_profile_for_turn_start(
|
||||
/*runtime_sandbox_policy_override*/ None,
|
||||
&sandbox_policy,
|
||||
),
|
||||
None
|
||||
);
|
||||
|
||||
let profile =
|
||||
runtime_permission_profile_for_turn_start(Some(&sandbox_policy), &sandbox_policy)
|
||||
.expect("runtime sandbox override should send active permissions");
|
||||
|
||||
assert_eq!(
|
||||
profile,
|
||||
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ impl AppServerSession {
|
||||
let request_id = self.next_request_id();
|
||||
let (sandbox_policy, permission_profile) = turn_start_permission_overrides(
|
||||
self.thread_params_mode(),
|
||||
sandbox_policy,
|
||||
&sandbox_policy,
|
||||
permission_profile,
|
||||
);
|
||||
self.client
|
||||
@@ -1107,19 +1107,19 @@ fn sandbox_mode_from_policy(
|
||||
}
|
||||
|
||||
fn turn_start_permission_overrides(
|
||||
mode: ThreadParamsMode,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
thread_params_mode: ThreadParamsMode,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
permission_profile: Option<PermissionProfile>,
|
||||
) -> (
|
||||
Option<codex_app_server_protocol::SandboxPolicy>,
|
||||
Option<codex_app_server_protocol::PermissionProfile>,
|
||||
) {
|
||||
match (mode, permission_profile) {
|
||||
(ThreadParamsMode::Embedded, Some(permission_profile)) => {
|
||||
(None, Some(permission_profile.into()))
|
||||
}
|
||||
(ThreadParamsMode::Embedded, None) => (None, None),
|
||||
(ThreadParamsMode::Remote, _) => (Some(sandbox_policy.into()), None),
|
||||
if matches!(thread_params_mode, ThreadParamsMode::Remote)
|
||||
|| matches!(sandbox_policy, SandboxPolicy::ExternalSandbox { .. })
|
||||
{
|
||||
(Some(sandbox_policy.clone().into()), None)
|
||||
} else {
|
||||
(None, permission_profile.map(Into::into))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,7 +1131,16 @@ fn permission_profile_override_from_config(
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(config.permissions.permission_profile().into())
|
||||
if matches!(
|
||||
config
|
||||
.permissions
|
||||
.legacy_sandbox_policy(config.cwd.as_path()),
|
||||
SandboxPolicy::ExternalSandbox { .. }
|
||||
) {
|
||||
None
|
||||
} else {
|
||||
Some(config.permissions.permission_profile().into())
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_start_params_from_config(
|
||||
@@ -1520,6 +1529,48 @@ mod tests {
|
||||
assert_eq!(params.model_provider, Some(config.model_provider_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_turn_start_permission_overrides_send_runtime_profile_only_when_provided() {
|
||||
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy);
|
||||
|
||||
assert_eq!(
|
||||
turn_start_permission_overrides(
|
||||
ThreadParamsMode::Embedded,
|
||||
&sandbox_policy,
|
||||
/*permission_profile*/ None,
|
||||
),
|
||||
(None, None)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
turn_start_permission_overrides(
|
||||
ThreadParamsMode::Embedded,
|
||||
&sandbox_policy,
|
||||
Some(permission_profile.clone()),
|
||||
),
|
||||
(None, Some(permission_profile.into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_turn_start_permission_overrides_keep_legacy_sandbox_policy() {
|
||||
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy);
|
||||
|
||||
assert_eq!(
|
||||
turn_start_permission_overrides(
|
||||
ThreadParamsMode::Remote,
|
||||
&sandbox_policy,
|
||||
Some(permission_profile),
|
||||
),
|
||||
(
|
||||
Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_params_can_mark_clear_source() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1632,7 +1683,7 @@ mod tests {
|
||||
|
||||
let (sandbox, profile) = turn_start_permission_overrides(
|
||||
ThreadParamsMode::Embedded,
|
||||
workspace_write.clone(),
|
||||
&workspace_write,
|
||||
Some(workspace_write_profile.clone()),
|
||||
);
|
||||
assert_eq!(sandbox, None);
|
||||
@@ -1640,7 +1691,7 @@ mod tests {
|
||||
|
||||
let (sandbox, profile) = turn_start_permission_overrides(
|
||||
ThreadParamsMode::Embedded,
|
||||
workspace_write.clone(),
|
||||
&workspace_write,
|
||||
/*permission_profile*/ None,
|
||||
);
|
||||
assert_eq!(sandbox, None);
|
||||
@@ -1648,7 +1699,7 @@ mod tests {
|
||||
|
||||
let (sandbox, profile) = turn_start_permission_overrides(
|
||||
ThreadParamsMode::Remote,
|
||||
workspace_write.clone(),
|
||||
&workspace_write,
|
||||
Some(PermissionProfile::from_legacy_sandbox_policy(
|
||||
&workspace_write,
|
||||
)),
|
||||
@@ -1661,16 +1712,13 @@ mod tests {
|
||||
};
|
||||
let (sandbox, profile) = turn_start_permission_overrides(
|
||||
ThreadParamsMode::Embedded,
|
||||
external_sandbox.clone(),
|
||||
&external_sandbox,
|
||||
Some(PermissionProfile::from_legacy_sandbox_policy(
|
||||
&external_sandbox,
|
||||
)),
|
||||
);
|
||||
assert_eq!(sandbox, None);
|
||||
assert_eq!(
|
||||
profile,
|
||||
Some(PermissionProfile::from_legacy_sandbox_policy(&external_sandbox).into())
|
||||
);
|
||||
assert_eq!(sandbox, Some(external_sandbox.into()));
|
||||
assert_eq!(profile, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user