fix: tighten up checks against writable folders for SandboxPolicy (#2338)
I was looking at the implementation of `Session::get_writable_roots()`, which did not seem right, as it was a copy of writable roots, which is not guaranteed to be in sync with the `sandbox_policy` field. I looked at who was calling `get_writable_roots()` and its only call site was `apply_patch()` in `codex-rs/core/src/apply_patch.rs`, which took the roots and forwarded them to `assess_patch_safety()` in `safety.rs`. I updated `assess_patch_safety()` to take `sandbox_policy: &SandboxPolicy` instead of `writable_roots: &[PathBuf]` (and replaced `Session::get_writable_roots()` with `Session::get_sandbox_policy()`). Within `safety.rs`, it was fairly easy to update `is_write_patch_constrained_to_writable_paths()` to work with `SandboxPolicy`, and in particular, it is far more accurate because, for better or worse, `SandboxPolicy::get_writable_roots_with_cwd()` _returns an empty vec_ for `SandboxPolicy::DangerFullAccess`, suggesting that _nothing_ is writable when in reality _everything_ is writable. With this PR, `is_write_patch_constrained_to_writable_paths()` now does the right thing for each variant of `SandboxPolicy`. I thought this would be the end of the story, but it turned out that `test_writable_roots_constraint()` in `safety.rs` needed to be updated, as well. In particular, the test was writing to `std::env::current_dir()` instead of a `TempDir`, which I suspect was a holdover from earlier when `SandboxPolicy::WorkspaceWrite` would always make `TMPDIR` writable on macOS, which made it hard to write tests to verify `SandboxPolicy` in `TMPDIR`. Fortunately, we now have `exclude_tmpdir_env_var` as an option on `SandboxPolicy::WorkspaceWrite`, so I was able to update the test to preserve the existing behavior, but to no longer write to `std::env::current_dir()`. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/2338). * #2345 * #2329 * #2343 * #2340 * __->__ #2338
This commit is contained in:
@@ -8,7 +8,6 @@ use crate::safety::assess_patch_safety;
|
|||||||
use codex_apply_patch::ApplyPatchAction;
|
use codex_apply_patch::ApplyPatchAction;
|
||||||
use codex_apply_patch::ApplyPatchFileChange;
|
use codex_apply_patch::ApplyPatchFileChange;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub const CODEX_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
|
pub const CODEX_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
|
||||||
@@ -45,12 +44,10 @@ pub(crate) async fn apply_patch(
|
|||||||
call_id: &str,
|
call_id: &str,
|
||||||
action: ApplyPatchAction,
|
action: ApplyPatchAction,
|
||||||
) -> InternalApplyPatchInvocation {
|
) -> InternalApplyPatchInvocation {
|
||||||
let writable_roots_snapshot = sess.get_writable_roots().to_vec();
|
|
||||||
|
|
||||||
match assess_patch_safety(
|
match assess_patch_safety(
|
||||||
&action,
|
&action,
|
||||||
sess.get_approval_policy(),
|
sess.get_approval_policy(),
|
||||||
&writable_roots_snapshot,
|
sess.get_sandbox_policy(),
|
||||||
sess.get_cwd(),
|
sess.get_cwd(),
|
||||||
) {
|
) {
|
||||||
SafetyCheck::AutoApprove { .. } => {
|
SafetyCheck::AutoApprove { .. } => {
|
||||||
@@ -124,30 +121,3 @@ pub(crate) fn convert_apply_patch_to_protocol(
|
|||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_writable_roots(cwd: &Path) -> Vec<PathBuf> {
|
|
||||||
let mut writable_roots = Vec::new();
|
|
||||||
if cfg!(target_os = "macos") {
|
|
||||||
// On macOS, $TMPDIR is private to the user.
|
|
||||||
writable_roots.push(std::env::temp_dir());
|
|
||||||
|
|
||||||
// Allow pyenv to update its shims directory. Without this, any tool
|
|
||||||
// that happens to be managed by `pyenv` will fail with an error like:
|
|
||||||
//
|
|
||||||
// pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable
|
|
||||||
//
|
|
||||||
// which is emitted every time `pyenv` tries to run `rehash` (for
|
|
||||||
// example, after installing a new Python package that drops an entry
|
|
||||||
// point). Although the sandbox is intentionally read‑only by default,
|
|
||||||
// writing to the user's local `pyenv` directory is safe because it
|
|
||||||
// is already user‑writable and scoped to the current user account.
|
|
||||||
if let Ok(home_dir) = std::env::var("HOME") {
|
|
||||||
let pyenv_dir = PathBuf::from(home_dir).join(".pyenv");
|
|
||||||
writable_roots.push(pyenv_dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writable_roots.push(cwd.to_path_buf());
|
|
||||||
|
|
||||||
writable_roots
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,12 +31,11 @@ use tracing::warn;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::ModelProviderInfo;
|
use crate::ModelProviderInfo;
|
||||||
|
use crate::apply_patch;
|
||||||
use crate::apply_patch::ApplyPatchExec;
|
use crate::apply_patch::ApplyPatchExec;
|
||||||
use crate::apply_patch::CODEX_APPLY_PATCH_ARG1;
|
use crate::apply_patch::CODEX_APPLY_PATCH_ARG1;
|
||||||
use crate::apply_patch::InternalApplyPatchInvocation;
|
use crate::apply_patch::InternalApplyPatchInvocation;
|
||||||
use crate::apply_patch::convert_apply_patch_to_protocol;
|
use crate::apply_patch::convert_apply_patch_to_protocol;
|
||||||
use crate::apply_patch::get_writable_roots;
|
|
||||||
use crate::apply_patch::{self};
|
|
||||||
use crate::client::ModelClient;
|
use crate::client::ModelClient;
|
||||||
use crate::client_common::Prompt;
|
use crate::client_common::Prompt;
|
||||||
use crate::client_common::ResponseEvent;
|
use crate::client_common::ResponseEvent;
|
||||||
@@ -231,7 +230,6 @@ pub(crate) struct Session {
|
|||||||
approval_policy: AskForApproval,
|
approval_policy: AskForApproval,
|
||||||
sandbox_policy: SandboxPolicy,
|
sandbox_policy: SandboxPolicy,
|
||||||
shell_environment_policy: ShellEnvironmentPolicy,
|
shell_environment_policy: ShellEnvironmentPolicy,
|
||||||
writable_roots: Vec<PathBuf>,
|
|
||||||
disable_response_storage: bool,
|
disable_response_storage: bool,
|
||||||
tools_config: ToolsConfig,
|
tools_config: ToolsConfig,
|
||||||
|
|
||||||
@@ -410,8 +408,6 @@ impl Session {
|
|||||||
state.history.record_items(&restored_items);
|
state.history.record_items(&restored_items);
|
||||||
}
|
}
|
||||||
|
|
||||||
let writable_roots = get_writable_roots(&cwd);
|
|
||||||
|
|
||||||
// Handle MCP manager result and record any startup failures.
|
// Handle MCP manager result and record any startup failures.
|
||||||
let (mcp_connection_manager, failed_clients) = match mcp_res {
|
let (mcp_connection_manager, failed_clients) = match mcp_res {
|
||||||
Ok((mgr, failures)) => (mgr, failures),
|
Ok((mgr, failures)) => (mgr, failures),
|
||||||
@@ -465,7 +461,6 @@ impl Session {
|
|||||||
sandbox_policy,
|
sandbox_policy,
|
||||||
shell_environment_policy: config.shell_environment_policy.clone(),
|
shell_environment_policy: config.shell_environment_policy.clone(),
|
||||||
cwd,
|
cwd,
|
||||||
writable_roots,
|
|
||||||
mcp_connection_manager,
|
mcp_connection_manager,
|
||||||
notify,
|
notify,
|
||||||
state: Mutex::new(state),
|
state: Mutex::new(state),
|
||||||
@@ -509,14 +504,14 @@ impl Session {
|
|||||||
Ok(sess)
|
Ok(sess)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_writable_roots(&self) -> &[PathBuf] {
|
|
||||||
&self.writable_roots
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn get_approval_policy(&self) -> AskForApproval {
|
pub(crate) fn get_approval_policy(&self) -> AskForApproval {
|
||||||
self.approval_policy
|
self.approval_policy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_sandbox_policy(&self) -> &SandboxPolicy {
|
||||||
|
&self.sandbox_policy
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn get_cwd(&self) -> &Path {
|
pub(crate) fn get_cwd(&self) -> &Path {
|
||||||
&self.cwd
|
&self.cwd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,10 +156,31 @@ pub enum SandboxPolicy {
|
|||||||
/// not modified by the agent.
|
/// not modified by the agent.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct WritableRoot {
|
pub struct WritableRoot {
|
||||||
|
/// Absolute path, by construction.
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
|
|
||||||
|
/// Also absolute paths, by construction.
|
||||||
pub read_only_subpaths: Vec<PathBuf>,
|
pub read_only_subpaths: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl WritableRoot {
|
||||||
|
pub(crate) fn is_path_writable(&self, path: &Path) -> bool {
|
||||||
|
// Check if the path is under the root.
|
||||||
|
if !path.starts_with(&self.root) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the path is under any of the read-only subpaths.
|
||||||
|
for subpath in &self.read_only_subpaths {
|
||||||
|
if path.starts_with(subpath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FromStr for SandboxPolicy {
|
impl FromStr for SandboxPolicy {
|
||||||
type Err = serde_json::Error;
|
type Err = serde_json::Error;
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub enum SafetyCheck {
|
|||||||
pub fn assess_patch_safety(
|
pub fn assess_patch_safety(
|
||||||
action: &ApplyPatchAction,
|
action: &ApplyPatchAction,
|
||||||
policy: AskForApproval,
|
policy: AskForApproval,
|
||||||
writable_roots: &[PathBuf],
|
sandbox_policy: &SandboxPolicy,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
) -> SafetyCheck {
|
) -> SafetyCheck {
|
||||||
if action.is_empty() {
|
if action.is_empty() {
|
||||||
@@ -45,7 +45,7 @@ pub fn assess_patch_safety(
|
|||||||
// is possible that paths in the patch are hard links to files outside the
|
// is possible that paths in the patch are hard links to files outside the
|
||||||
// writable roots, so we should still run `apply_patch` in a sandbox in that
|
// writable roots, so we should still run `apply_patch` in a sandbox in that
|
||||||
// case.
|
// case.
|
||||||
if is_write_patch_constrained_to_writable_paths(action, writable_roots, cwd)
|
if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd)
|
||||||
|| policy == AskForApproval::OnFailure
|
|| policy == AskForApproval::OnFailure
|
||||||
{
|
{
|
||||||
// Only auto‑approve when we can actually enforce a sandbox. Otherwise
|
// Only auto‑approve when we can actually enforce a sandbox. Otherwise
|
||||||
@@ -171,13 +171,19 @@ pub fn get_platform_sandbox() -> Option<SandboxType> {
|
|||||||
|
|
||||||
fn is_write_patch_constrained_to_writable_paths(
|
fn is_write_patch_constrained_to_writable_paths(
|
||||||
action: &ApplyPatchAction,
|
action: &ApplyPatchAction,
|
||||||
writable_roots: &[PathBuf],
|
sandbox_policy: &SandboxPolicy,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
// Early‑exit if there are no declared writable roots.
|
// Early‑exit if there are no declared writable roots.
|
||||||
if writable_roots.is_empty() {
|
let writable_roots = match sandbox_policy {
|
||||||
return false;
|
SandboxPolicy::ReadOnly => {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
SandboxPolicy::DangerFullAccess => {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
SandboxPolicy::WorkspaceWrite { .. } => sandbox_policy.get_writable_roots_with_cwd(cwd),
|
||||||
|
};
|
||||||
|
|
||||||
// Normalize a path by removing `.` and resolving `..` without touching the
|
// Normalize a path by removing `.` and resolving `..` without touching the
|
||||||
// filesystem (works even if the file does not exist).
|
// filesystem (works even if the file does not exist).
|
||||||
@@ -209,15 +215,9 @@ fn is_write_patch_constrained_to_writable_paths(
|
|||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
|
|
||||||
writable_roots.iter().any(|root| {
|
writable_roots
|
||||||
let root_abs = if root.is_absolute() {
|
.iter()
|
||||||
root.clone()
|
.any(|writable_root| writable_root.is_path_writable(&abs))
|
||||||
} else {
|
|
||||||
normalize(&cwd.join(root)).unwrap_or_else(|| cwd.join(root))
|
|
||||||
};
|
|
||||||
|
|
||||||
abs.starts_with(&root_abs)
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (path, change) in action.changes() {
|
for (path, change) in action.changes() {
|
||||||
@@ -246,38 +246,56 @@ fn is_write_patch_constrained_to_writable_paths(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_writable_roots_constraint() {
|
fn test_writable_roots_constraint() {
|
||||||
let cwd = std::env::current_dir().unwrap();
|
// Use a temporary directory as our workspace to avoid touching
|
||||||
|
// the real current working directory.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let cwd = tmp.path().to_path_buf();
|
||||||
let parent = cwd.parent().unwrap().to_path_buf();
|
let parent = cwd.parent().unwrap().to_path_buf();
|
||||||
|
|
||||||
// Helper to build a single‑entry map representing a patch that adds a
|
// Helper to build a single‑entry patch that adds a file at `p`.
|
||||||
// file at `p`.
|
|
||||||
let make_add_change = |p: PathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string());
|
let make_add_change = |p: PathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string());
|
||||||
|
|
||||||
let add_inside = make_add_change(cwd.join("inner.txt"));
|
let add_inside = make_add_change(cwd.join("inner.txt"));
|
||||||
let add_outside = make_add_change(parent.join("outside.txt"));
|
let add_outside = make_add_change(parent.join("outside.txt"));
|
||||||
|
|
||||||
|
// Policy limited to the workspace only; exclude system temp roots so
|
||||||
|
// only `cwd` is writable by default.
|
||||||
|
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
|
||||||
|
writable_roots: vec![],
|
||||||
|
network_access: false,
|
||||||
|
exclude_tmpdir_env_var: true,
|
||||||
|
exclude_slash_tmp: true,
|
||||||
|
};
|
||||||
|
|
||||||
assert!(is_write_patch_constrained_to_writable_paths(
|
assert!(is_write_patch_constrained_to_writable_paths(
|
||||||
&add_inside,
|
&add_inside,
|
||||||
&[PathBuf::from(".")],
|
&policy_workspace_only,
|
||||||
&cwd,
|
&cwd,
|
||||||
));
|
));
|
||||||
|
|
||||||
let add_outside_2 = make_add_change(parent.join("outside.txt"));
|
|
||||||
assert!(!is_write_patch_constrained_to_writable_paths(
|
assert!(!is_write_patch_constrained_to_writable_paths(
|
||||||
&add_outside_2,
|
&add_outside,
|
||||||
&[PathBuf::from(".")],
|
&policy_workspace_only,
|
||||||
&cwd,
|
&cwd,
|
||||||
));
|
));
|
||||||
|
|
||||||
// With parent dir added as writable root, it should pass.
|
// With the parent dir explicitly added as a writable root, the
|
||||||
|
// outside write should be permitted.
|
||||||
|
let policy_with_parent = SandboxPolicy::WorkspaceWrite {
|
||||||
|
writable_roots: vec![parent.clone()],
|
||||||
|
network_access: false,
|
||||||
|
exclude_tmpdir_env_var: true,
|
||||||
|
exclude_slash_tmp: true,
|
||||||
|
};
|
||||||
assert!(is_write_patch_constrained_to_writable_paths(
|
assert!(is_write_patch_constrained_to_writable_paths(
|
||||||
&add_outside,
|
&add_outside,
|
||||||
&[PathBuf::from("..")],
|
&policy_with_parent,
|
||||||
&cwd,
|
&cwd,
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user