fix: introduce AbsolutePathBuf as part of sandbox config (#7856)

Changes the `writable_roots` field of the `WorkspaceWrite` variant of
the `SandboxPolicy` enum from `Vec<PathBuf>` to `Vec<AbsolutePathBuf>`.
This is helpful because now callers can be sure the value is an absolute
path rather than a relative one. (Though when using an absolute path in
a Seatbelt config policy, we still have to _canonicalize_ it first.)

Because `writable_roots` can be read from a config file, it is important
that we are able to resolve relative paths properly using the parent
folder of the config file as the base path.
This commit is contained in:
Michael Bolin
2025-12-12 15:25:22 -08:00
committed by GitHub
Unverified
parent 3d07cd6c0c
commit 642b7566df
34 changed files with 277 additions and 122 deletions
+10
View File
@@ -837,6 +837,7 @@ dependencies = [
"codex-login",
"codex-protocol",
"codex-rmcp-client",
"codex-utils-absolute-path",
"codex-utils-json-to-toml",
"core_test_support",
"mcp-types",
@@ -863,6 +864,7 @@ dependencies = [
"anyhow",
"clap",
"codex-protocol",
"codex-utils-absolute-path",
"mcp-types",
"pretty_assertions",
"schemars 0.8.22",
@@ -1181,6 +1183,7 @@ dependencies = [
"codex-common",
"codex-core",
"codex-protocol",
"codex-utils-absolute-path",
"core_test_support",
"libc",
"mcp-types",
@@ -1320,6 +1323,7 @@ version = "0.0.0"
dependencies = [
"clap",
"codex-core",
"codex-utils-absolute-path",
"landlock",
"libc",
"seccompiler",
@@ -1444,6 +1448,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"codex-git",
"codex-utils-absolute-path",
"codex-utils-image",
"icu_decimal",
"icu_locale_core",
@@ -1542,6 +1547,7 @@ dependencies = [
"codex-file-search",
"codex-login",
"codex-protocol",
"codex-utils-absolute-path",
"codex-windows-sandbox",
"color-eyre",
"crossterm",
@@ -1611,6 +1617,7 @@ dependencies = [
"codex-login",
"codex-protocol",
"codex-tui",
"codex-utils-absolute-path",
"codex-windows-sandbox",
"color-eyre",
"crossterm",
@@ -1663,9 +1670,11 @@ name = "codex-utils-absolute-path"
version = "0.0.0"
dependencies = [
"path-absolutize",
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
"ts-rs",
]
[[package]]
@@ -1735,6 +1744,7 @@ dependencies = [
"base64",
"chrono",
"codex-protocol",
"codex-utils-absolute-path",
"dirs-next",
"dunce",
"rand 0.8.5",
+1
View File
@@ -15,6 +15,7 @@ workspace = true
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
mcp-types = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
@@ -16,6 +16,7 @@ use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -359,7 +360,7 @@ pub struct Tools {
#[serde(rename_all = "camelCase")]
pub struct SandboxSettings {
#[serde(default)]
pub writable_roots: Vec<PathBuf>,
pub writable_roots: Vec<AbsolutePathBuf>,
pub network_access: Option<bool>,
pub exclude_tmpdir_env_var: Option<bool>,
pub exclude_slash_tmp: Option<bool>,
@@ -24,6 +24,7 @@ use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo;
use codex_protocol::user_input::UserInput as CoreUserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use mcp_types::ContentBlock as McpContentBlock;
use mcp_types::Resource as McpResource;
use mcp_types::ResourceTemplate as McpResourceTemplate;
@@ -420,7 +421,7 @@ pub enum SandboxPolicy {
#[ts(rename_all = "camelCase")]
WorkspaceWrite {
#[serde(default)]
writable_roots: Vec<PathBuf>,
writable_roots: Vec<AbsolutePathBuf>,
#[serde(default)]
network_access: bool,
#[serde(default)]
+1
View File
@@ -27,6 +27,7 @@ codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-feedback = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true, features = ["derive"] }
@@ -410,7 +410,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
cwd: first_cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![first_cwd.clone()],
writable_roots: vec![first_cwd.try_into()?],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+18 -3
View File
@@ -14,6 +14,7 @@ use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::Path;
@@ -23,10 +24,16 @@ use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
let writable_root = if cfg!(windows) {
r"C:\Users\codex\AppData\Local\Temp"
} else {
"/tmp"
};
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
r#"
format!(
r#"
model = "gpt-5.1-codex-max"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
@@ -38,7 +45,7 @@ forced_chatgpt_workspace_id = "12345678-0000-0000-0000-000000000000"
forced_login_method = "chatgpt"
[sandbox_workspace_write]
writable_roots = ["/tmp"]
writable_roots = [{}]
network_access = true
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
@@ -56,6 +63,8 @@ model_verbosity = "medium"
model_provider = "openai"
chatgpt_base_url = "https://api.chatgpt.com"
"#,
serde_json::json!(writable_root)
),
)
}
@@ -75,12 +84,18 @@ async fn get_config_toml_parses_all_fields() -> Result<()> {
.await??;
let config: GetUserSavedConfigResponse = to_response(resp)?;
let writable_root = if cfg!(windows) {
r"C:\Users\codex\AppData\Local\Temp"
} else {
"/tmp"
};
let writable_root = AbsolutePathBuf::from_absolute_path(writable_root)?;
let expected = GetUserSavedConfigResponse {
config: UserSavedConfig {
approval_policy: Some(AskForApproval::OnRequest),
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
sandbox_settings: Some(SandboxSettings {
writable_roots: vec!["/tmp".into()],
writable_roots: vec![writable_root],
network_access: Some(true),
exclude_tmpdir_env_var: Some(true),
exclude_slash_tmp: Some(true),
@@ -135,29 +135,40 @@ view_image = false
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_system_layer_and_overrides() -> Result<()> {
let codex_home = TempDir::new()?;
let (user_dir, system_dir) = if cfg!(windows) {
(r"C:\Users\user", r"C:\System")
} else {
("/user", "/system")
};
write_config(
&codex_home,
r#"
&format!(
r#"
model = "gpt-user"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
writable_roots = ["/user"]
writable_roots = [{}]
network_access = true
"#,
serde_json::json!(user_dir)
),
)?;
let managed_path = codex_home.path().join("managed_config.toml");
std::fs::write(
&managed_path,
r#"
format!(
r#"
model = "gpt-system"
approval_policy = "never"
[sandbox_workspace_write]
writable_roots = ["/system"]
writable_roots = [{}]
"#,
serde_json::json!(system_dir)
),
)?;
let managed_path_str = managed_path.display().to_string();
@@ -207,7 +218,7 @@ writable_roots = ["/system"]
.sandbox_workspace_write
.as_ref()
.expect("sandbox workspace write");
assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/system")]);
assert_eq!(sandbox.writable_roots, vec![PathBuf::from(system_dir)]);
assert_eq!(
origins
.get("sandbox_workspace_write.writable_roots.0")
@@ -350,6 +361,11 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let writable_root = if cfg!(windows) {
r"C:\Users\codex\AppData\Local\Temp"
} else {
"/tmp"
};
let batch_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: Some(codex_home.path().join("config.toml").display().to_string()),
@@ -362,7 +378,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
ConfigEdit {
key_path: "sandbox_workspace_write".to_string(),
value: json!({
"writable_roots": ["/tmp"],
"writable_roots": [writable_root],
"network_access": false
}),
merge_strategy: MergeStrategy::Replace,
@@ -404,7 +420,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
.sandbox_workspace_write
.as_ref()
.expect("sandbox workspace write");
assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/tmp")]);
assert_eq!(sandbox.writable_roots, vec![PathBuf::from(writable_root)]);
assert!(!sandbox.network_access);
Ok(())
@@ -532,7 +532,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
cwd: Some(first_cwd.clone()),
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![first_cwd.clone()],
writable_roots: vec![first_cwd.try_into()?],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+29 -18
View File
@@ -37,9 +37,9 @@ use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningSummaryFormat;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use dirs::home_dir;
use dunce::canonicalize;
use serde::Deserialize;
use similar::DiffableStr;
use std::collections::BTreeMap;
@@ -982,13 +982,10 @@ impl Config {
}
}
};
let additional_writable_roots: Vec<PathBuf> = additional_writable_roots
let additional_writable_roots: Vec<AbsolutePathBuf> = additional_writable_roots
.into_iter()
.map(|path| {
let absolute = resolve_path(&resolved_cwd, &path);
canonicalize(&absolute).unwrap_or(absolute)
})
.collect();
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd))
.collect::<Result<Vec<_>, _>>()?;
let active_project = cfg
.get_active_project(&resolved_cwd)
.unwrap_or(ProjectConfig { trust_level: None });
@@ -1416,18 +1413,26 @@ network_access = true # This should be ignored.
}
);
let sandbox_workspace_write = r#"
let writable_root = if cfg!(windows) {
"C:\\my\\workspace"
} else {
"/my/workspace"
};
let sandbox_workspace_write = format!(
r#"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
writable_roots = [
"/my/workspace",
{},
]
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
"#;
"#,
serde_json::json!(writable_root.to_string_lossy())
);
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(sandbox_workspace_write)
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(&sandbox_workspace_write)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
let resolution = sandbox_workspace_write_cfg.derive_sandbox_policy(
@@ -1448,7 +1453,7 @@ exclude_slash_tmp = true
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("/my/workspace")],
writable_roots: vec!["/my/workspace".try_into().unwrap()],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -1458,21 +1463,24 @@ exclude_slash_tmp = true
);
}
let sandbox_workspace_write = r#"
let sandbox_workspace_write = format!(
r#"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
writable_roots = [
"/my/workspace",
{},
]
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
[projects."/tmp/test"]
trust_level = "trusted"
"#;
"#,
serde_json::json!(writable_root.to_string_lossy())
);
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(sandbox_workspace_write)
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(&sandbox_workspace_write)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
let resolution = sandbox_workspace_write_cfg.derive_sandbox_policy(
@@ -1493,7 +1501,10 @@ trust_level = "trusted"
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("/my/workspace")],
writable_roots: vec![
AbsolutePathBuf::from_absolute_path("/my/workspace")
.expect("absolute path")
],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -1525,7 +1536,7 @@ trust_level = "trusted"
temp_dir.path().to_path_buf(),
)?;
let expected_backend = canonicalize(&backend).expect("canonicalize backend directory");
let expected_backend = AbsolutePathBuf::try_from(backend).unwrap();
if cfg!(target_os = "windows") {
assert!(
config.forced_auto_mode_downgraded_on_windows,
+1 -1
View File
@@ -410,7 +410,7 @@ impl Notice {
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct SandboxWorkspaceWrite {
#[serde(default)]
pub writable_roots: Vec<PathBuf>,
pub writable_roots: Vec<AbsolutePathBuf>,
#[serde(default)]
pub network_access: bool,
#[serde(default)]
+17 -9
View File
@@ -1,3 +1,4 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display as DeriveDisplay;
@@ -27,7 +28,7 @@ pub(crate) struct EnvironmentContext {
pub approval_policy: Option<AskForApproval>,
pub sandbox_mode: Option<SandboxMode>,
pub network_access: Option<NetworkAccess>,
pub writable_roots: Option<Vec<PathBuf>>,
pub writable_roots: Option<Vec<AbsolutePathBuf>>,
pub shell: Shell,
}
@@ -203,7 +204,10 @@ mod tests {
fn workspace_write_policy(writable_roots: Vec<&str>, network_access: bool) -> SandboxPolicy {
SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.into_iter().map(PathBuf::from).collect(),
writable_roots: writable_roots
.into_iter()
.map(|s| AbsolutePathBuf::try_from(s).unwrap())
.collect(),
network_access,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -212,24 +216,28 @@ mod tests {
#[test]
fn serialize_workspace_write_environment_context() {
let cwd = if cfg!(windows) { "C:\\repo" } else { "/repo" };
let writable_root = if cfg!(windows) { "C:\\tmp" } else { "/tmp" };
let context = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(PathBuf::from(cwd)),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], false)),
Some(workspace_write_policy(vec![cwd, writable_root], false)),
fake_shell(),
);
let expected = r#"<environment_context>
<cwd>/repo</cwd>
let expected = format!(
r#"<environment_context>
<cwd>{cwd}</cwd>
<approval_policy>on-request</approval_policy>
<sandbox_mode>workspace-write</sandbox_mode>
<network_access>restricted</network_access>
<writable_roots>
<root>/repo</root>
<root>/tmp</root>
<root>{cwd}</root>
<root>{writable_root}</root>
</writable_roots>
<shell>bash</shell>
</environment_context>"#;
</environment_context>"#
);
assert_eq!(context.serialize_to_xml(), expected);
}
+2 -1
View File
@@ -210,6 +210,7 @@ fn is_write_patch_constrained_to_writable_paths(
#[cfg(test)]
mod tests {
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use tempfile::TempDir;
#[test]
@@ -250,7 +251,7 @@ mod tests {
// 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],
writable_roots: vec![AbsolutePathBuf::try_from(parent).unwrap()],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
+13 -3
View File
@@ -63,7 +63,11 @@ pub(crate) fn create_seatbelt_command_args(
for (index, wr) in writable_roots.iter().enumerate() {
// Canonicalize to avoid mismatches like /var vs /private/var on macOS.
let canonical_root = wr.root.canonicalize().unwrap_or_else(|_| wr.root.clone());
let canonical_root = wr
.root
.as_path()
.canonicalize()
.unwrap_or_else(|_| wr.root.to_path_buf());
let root_param = format!("WRITABLE_ROOT_{index}");
file_write_params.push((root_param.clone(), canonical_root));
@@ -75,7 +79,10 @@ pub(crate) fn create_seatbelt_command_args(
let mut require_parts: Vec<String> = Vec::new();
require_parts.push(format!("(subpath (param \"{root_param}\"))"));
for (subpath_index, ro) in wr.read_only_subpaths.iter().enumerate() {
let canonical_ro = ro.canonicalize().unwrap_or_else(|_| ro.clone());
let canonical_ro = ro
.as_path()
.canonicalize()
.unwrap_or_else(|_| ro.to_path_buf());
let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}");
require_parts
.push(format!("(require-not (subpath (param \"{ro_param}\")))"));
@@ -182,7 +189,10 @@ mod tests {
// Build a policy that only includes the two test roots as writable and
// does not automatically include defaults TMPDIR or /tmp.
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![root_with_git, root_without_git],
writable_roots: vec![root_with_git, root_without_git]
.into_iter()
.map(|p| p.try_into().unwrap())
.collect(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
+3 -2
View File
@@ -11,6 +11,7 @@ use codex_core::shell::Shell;
use codex_core::shell::default_user_shell;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::start_mock_server;
@@ -317,7 +318,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
cwd: None,
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable.path().to_path_buf()],
writable_roots: vec![writable.path().try_into().unwrap()],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -507,7 +508,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
cwd: new_cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable.path().to_path_buf()],
writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
+2 -2
View File
@@ -76,7 +76,7 @@ async fn if_parent_of_repo_is_writable_then_dot_git_folder_is_writable() {
let tmp = TempDir::new().expect("should be able to create temp dir");
let test_scenario = create_test_scenario(&tmp);
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![test_scenario.repo_parent.clone()],
writable_roots: vec![test_scenario.repo_parent.as_path().try_into().unwrap()],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -102,7 +102,7 @@ async fn if_git_repo_is_writable_root_then_dot_git_folder_is_read_only() {
let tmp = TempDir::new().expect("should be able to create temp dir");
let test_scenario = create_test_scenario(&tmp);
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![test_scenario.repo_root.clone()],
writable_roots: vec![test_scenario.repo_root.as_path().try_into().unwrap()],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
+1
View File
@@ -26,6 +26,7 @@ codex-common = { workspace = true, features = [
] }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
mcp-types = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
owo-colors = { workspace = true }
+3 -2
View File
@@ -1,6 +1,7 @@
#![cfg(unix)]
use codex_core::protocol::SandboxPolicy;
use codex_core::spawn::StdioPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::future::Future;
use std::io;
@@ -58,14 +59,14 @@ async fn spawn_command_under_sandbox(
async fn python_multiprocessing_lock_works_under_sandbox() {
core_test_support::skip_if_sandbox!();
#[cfg(target_os = "macos")]
let writable_roots = Vec::<PathBuf>::new();
let writable_roots = Vec::<AbsolutePathBuf>::new();
// From https://man7.org/linux/man-pages/man7/sem_overview.7.html
//
// > On Linux, named semaphores are created in a virtual filesystem,
// > normally mounted under /dev/shm.
#[cfg(target_os = "linux")]
let writable_roots = vec![PathBuf::from("/dev/shm")];
let writable_roots: Vec<AbsolutePathBuf> = vec!["/dev/shm".try_into().unwrap()];
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots,
+1
View File
@@ -18,6 +18,7 @@ workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
clap = { workspace = true, features = ["derive"] }
codex-core = { workspace = true }
codex-utils-absolute-path = { workspace = true }
landlock = { workspace = true }
libc = { workspace = true }
seccompiler = { workspace = true }
+4 -2
View File
@@ -1,11 +1,11 @@
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use codex_core::error::CodexErr;
use codex_core::error::Result;
use codex_core::error::SandboxErr;
use codex_core::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use landlock::ABI;
use landlock::Access;
@@ -56,7 +56,9 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
///
/// # Errors
/// Returns [`CodexErr::Sandbox`] variants when the ruleset fails to apply.
fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathBuf>) -> Result<()> {
fn install_filesystem_landlock_rules_on_current_thread(
writable_roots: Vec<AbsolutePathBuf>,
) -> Result<()> {
let abi = ABI::V5;
let access_rw = AccessFs::from_all(abi);
let access_ro = AccessFs::from_read(abi);
@@ -7,6 +7,7 @@ use codex_core::exec::process_exec_tool_call;
use codex_core::exec_env::create_env;
use codex_core::protocol::SandboxPolicy;
use codex_core::sandboxing::SandboxPermissions;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::NamedTempFile;
@@ -48,7 +49,10 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
};
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.to_vec(),
writable_roots: writable_roots
.iter()
.map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap())
.collect(),
network_access: false,
// Exclude tmp-related folders from writable roots because we need a
// folder that is writable by tests but that we intentionally disallow
+1 -1
View File
@@ -13,7 +13,7 @@ workspace = true
[dependencies]
codex-git = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-image = { workspace = true }
icu_decimal = { workspace = true }
icu_locale_core = { workspace = true }
+37 -12
View File
@@ -23,6 +23,7 @@ use crate::openai_models::ReasoningEffort as ReasoningEffortConfig;
use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
use crate::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use mcp_types::CallToolResult;
use mcp_types::RequestId;
use mcp_types::Resource as McpResource;
@@ -34,6 +35,7 @@ use serde::Serialize;
use serde_json::Value;
use serde_with::serde_as;
use strum_macros::Display;
use tracing::error;
use ts_rs::TS;
pub use crate::approvals::ApplyPatchApprovalRequestEvent;
@@ -273,7 +275,7 @@ pub enum SandboxPolicy {
/// Additional folders (beyond cwd and possibly TMPDIR) that should be
/// writable from within the sandbox.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
writable_roots: Vec<PathBuf>,
writable_roots: Vec<AbsolutePathBuf>,
/// When set to `true`, outbound network access is allowed. `false` by
/// default.
@@ -299,11 +301,9 @@ pub enum SandboxPolicy {
/// not modified by the agent.
#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
pub struct WritableRoot {
/// Absolute path, by construction.
pub root: PathBuf,
pub root: AbsolutePathBuf,
/// Also absolute paths, by construction.
pub read_only_subpaths: Vec<PathBuf>,
pub read_only_subpaths: Vec<AbsolutePathBuf>,
}
impl WritableRoot {
@@ -385,16 +385,30 @@ impl SandboxPolicy {
network_access: _,
} => {
// Start from explicitly configured writable roots.
let mut roots: Vec<PathBuf> = writable_roots.clone();
let mut roots: Vec<AbsolutePathBuf> = writable_roots.clone();
// Always include defaults: cwd, /tmp (if present on Unix), and
// on macOS, the per-user TMPDIR unless explicitly excluded.
roots.push(cwd.to_path_buf());
// TODO(mbolin): cwd param should be AbsolutePathBuf.
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd);
match cwd_absolute {
Ok(cwd) => {
roots.push(cwd);
}
Err(e) => {
error!(
"Ignoring invalid cwd {:?} for sandbox writable root: {}",
cwd, e
);
}
}
// Include /tmp on Unix unless explicitly excluded.
if cfg!(unix) && !exclude_slash_tmp {
let slash_tmp = PathBuf::from("/tmp");
if slash_tmp.is_dir() {
#[allow(clippy::expect_used)]
let slash_tmp =
AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
if slash_tmp.as_path().is_dir() {
roots.push(slash_tmp);
}
}
@@ -411,7 +425,15 @@ impl SandboxPolicy {
&& let Some(tmpdir) = std::env::var_os("TMPDIR")
&& !tmpdir.is_empty()
{
roots.push(PathBuf::from(tmpdir));
if let Ok(tmpdir_path) =
AbsolutePathBuf::from_absolute_path(PathBuf::from(&tmpdir))
{
roots.push(tmpdir_path);
} else {
error!(
"Ignoring invalid TMPDIR value {tmpdir:?} for sandbox writable root",
);
}
}
// For each root, compute subpaths that should remain read-only.
@@ -419,8 +441,11 @@ impl SandboxPolicy {
.into_iter()
.map(|writable_root| {
let mut subpaths = Vec::new();
let top_level_git = writable_root.join(".git");
if top_level_git.is_dir() {
#[allow(clippy::expect_used)]
let top_level_git = writable_root
.join(".git")
.expect(".git is a valid relative path");
if top_level_git.as_path().is_dir() {
subpaths.push(top_level_git);
}
WritableRoot {
+1
View File
@@ -41,6 +41,7 @@ codex-feedback = { workspace = true }
codex-file-search = { workspace = true }
codex-login = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
color-eyre = { workspace = true }
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
derive_more = { workspace = true, features = ["is_variant"] }
+2 -1
View File
@@ -58,6 +58,7 @@ use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::CodexErrorInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -1807,7 +1808,7 @@ fn preset_matching_ignores_extra_writable_roots() {
.find(|p| p.id == "auto")
.expect("auto preset exists");
let current_sandbox = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("C:\\extra")],
writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+1
View File
@@ -40,6 +40,7 @@ codex-feedback = { workspace = true }
codex-file-search = { workspace = true }
codex-login = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-tui = { workspace = true }
color-eyre = { workspace = true }
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
+2 -1
View File
@@ -56,6 +56,7 @@ use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::CodexErrorInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -1806,7 +1807,7 @@ fn preset_matching_ignores_extra_writable_roots() {
.find(|p| p.id == "auto")
.expect("auto preset exists");
let current_sandbox = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("C:\\extra")],
writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+5
View File
@@ -10,7 +10,12 @@ workspace = true
[dependencies]
path-absolutize = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
ts-rs = { workspace = true, features = [
"serde-json-impl",
"no-serde-warnings",
] }
[dev-dependencies]
serde_json = { workspace = true }
+60 -22
View File
@@ -1,4 +1,5 @@
use path_absolutize::Absolutize;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
@@ -7,6 +8,7 @@ use std::cell::RefCell;
use std::path::Display;
use std::path::Path;
use std::path::PathBuf;
use ts_rs::TS;
/// A path that is guaranteed to be absolute and normalized (though it is not
/// guaranteed to be canonicalized or exist on the filesystem).
@@ -15,27 +17,27 @@ use std::path::PathBuf;
/// using `AbsolutePathBufGuard::new(base_path)`. If no base path is set, the
/// deserialization will fail unless the path being deserialized is already
/// absolute.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema, TS)]
pub struct AbsolutePathBuf(PathBuf);
impl AbsolutePathBuf {
pub fn resolve_path_against_base<P, B>(path: P, base_path: B) -> std::io::Result<Self>
where
P: AsRef<Path>,
B: AsRef<Path>,
{
pub fn resolve_path_against_base<P: AsRef<Path>, B: AsRef<Path>>(
path: P,
base_path: B,
) -> std::io::Result<Self> {
let absolute_path = path.as_ref().absolutize_from(base_path.as_ref())?;
Ok(Self(absolute_path.into_owned()))
}
pub fn from_absolute_path<P>(path: P) -> std::io::Result<Self>
where
P: AsRef<Path>,
{
pub fn from_absolute_path<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
let absolute_path = path.as_ref().absolutize()?;
Ok(Self(absolute_path.into_owned()))
}
pub fn join<P: AsRef<Path>>(&self, path: P) -> std::io::Result<Self> {
Self::resolve_path_against_base(path, &self.0)
}
pub fn as_path(&self) -> &Path {
&self.0
}
@@ -48,11 +50,59 @@ impl AbsolutePathBuf {
self.0.clone()
}
pub fn to_string_lossy(&self) -> std::borrow::Cow<'_, str> {
self.0.to_string_lossy()
}
pub fn display(&self) -> Display<'_> {
self.0.display()
}
}
impl AsRef<Path> for AbsolutePathBuf {
fn as_ref(&self) -> &Path {
&self.0
}
}
impl From<AbsolutePathBuf> for PathBuf {
fn from(path: AbsolutePathBuf) -> Self {
path.into_path_buf()
}
}
impl TryFrom<&Path> for AbsolutePathBuf {
type Error = std::io::Error;
fn try_from(value: &Path) -> Result<Self, Self::Error> {
Self::from_absolute_path(value)
}
}
impl TryFrom<PathBuf> for AbsolutePathBuf {
type Error = std::io::Error;
fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
Self::from_absolute_path(value)
}
}
impl TryFrom<&str> for AbsolutePathBuf {
type Error = std::io::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::from_absolute_path(value)
}
}
impl TryFrom<String> for AbsolutePathBuf {
type Error = std::io::Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::from_absolute_path(value)
}
}
thread_local! {
static ABSOLUTE_PATH_BASE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}
@@ -96,18 +146,6 @@ impl<'de> Deserialize<'de> for AbsolutePathBuf {
}
}
impl AsRef<Path> for AbsolutePathBuf {
fn as_ref(&self) -> &Path {
self.as_path()
}
}
impl From<AbsolutePathBuf> for PathBuf {
fn from(path: AbsolutePathBuf) -> Self {
path.into_path_buf()
}
}
#[cfg(test)]
mod tests {
use super::*;
+11 -2
View File
@@ -1,9 +1,9 @@
[package]
build = "build.rs"
edition = "2021"
license.workspace = true
name = "codex-windows-sandbox"
version.workspace = true
build = "build.rs"
[lib]
name = "codex_windows_sandbox"
@@ -20,25 +20,33 @@ path = "src/bin/command_runner.rs"
[dependencies]
anyhow = "1.0"
base64 = { workspace = true }
chrono = { version = "0.4.42", default-features = false, features = [
"clock",
"std",
] }
codex-utils-absolute-path = { workspace = true }
dunce = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_NetworkManagement_WindowsFirewall",
"Win32_System_Com",
"Win32_System_Variant",
] }
[dependencies.codex-protocol]
package = "codex-protocol"
path = "../protocol"
[dependencies.rand]
default-features = false
features = ["std", "small_rng"]
version = "0.8"
[dependencies.dirs-next]
version = "2.0"
[dependencies.windows-sys]
features = [
"Win32_Foundation",
@@ -67,6 +75,7 @@ features = [
"Win32_System_Registry",
]
version = "0.52"
[dev-dependencies]
tempfile = "3"
+4 -6
View File
@@ -68,7 +68,7 @@ pub fn compute_allow_paths(
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy {
for root in writable_roots {
add_writable_root(
root.clone(),
root.clone().into(),
policy_cwd,
&mut add_allow_path,
&mut add_deny_path,
@@ -92,12 +92,10 @@ pub fn compute_allow_paths(
#[cfg(test)]
mod tests {
use super::compute_allow_paths;
use super::*;
use codex_protocol::protocol::SandboxPolicy;
use std::collections::HashMap;
use std::collections::HashSet;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
@@ -109,7 +107,7 @@ mod tests {
let _ = fs::create_dir_all(&extra_root);
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![extra_root.clone()],
writable_roots: vec![AbsolutePathBuf::try_from(extra_root.as_path()).unwrap()],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+2 -6
View File
@@ -260,12 +260,8 @@ pub fn apply_capability_denies_for_world_writable(
let mut roots: Vec<PathBuf> =
vec![dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())];
for root in writable_roots {
let candidate = if root.is_absolute() {
root.clone()
} else {
cwd.join(root)
};
roots.push(dunce::canonicalize(&candidate).unwrap_or(candidate));
let candidate = root.as_path();
roots.push(dunce::canonicalize(candidate).unwrap_or_else(|_| root.to_path_buf()));
}
(sid, roots)
}
+7 -3
View File
@@ -1,13 +1,14 @@
use crate::dpapi;
use crate::logging::debug_log;
use crate::policy::SandboxPolicy;
use crate::setup::gather_read_roots;
use crate::setup::gather_write_roots;
use crate::setup::run_elevated_setup;
use crate::setup::sandbox_users_path;
use crate::setup::setup_marker_path;
use crate::setup::SandboxUserRecord;
use crate::setup::SandboxUsersFile;
use crate::setup::SetupMarker;
use crate::setup::{gather_read_roots, gather_write_roots};
use anyhow::anyhow;
use anyhow::Context;
use anyhow::Result;
@@ -117,7 +118,7 @@ pub fn require_logon_sandbox_creds(
codex_home: &Path,
) -> Result<SandboxCreds> {
let sandbox_dir = crate::setup::sandbox_dir(codex_home);
let needed_read = gather_read_roots(command_cwd, policy, policy_cwd);
let needed_read = gather_read_roots(command_cwd, policy);
let mut needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map);
// Ensure the sandbox directory itself is writable by sandbox users.
needed_write.push(sandbox_dir.clone());
@@ -142,7 +143,10 @@ pub fn require_logon_sandbox_creds(
if identity.is_none() {
if let Some(reason) = &setup_reason {
crate::logging::log_note(&format!("sandbox setup required: {reason}"), Some(&sandbox_dir));
crate::logging::log_note(
&format!("sandbox setup required: {reason}"),
Some(&sandbox_dir),
);
} else {
crate::logging::log_note("sandbox setup required", Some(&sandbox_dir));
}
@@ -59,7 +59,7 @@ pub fn run_setup_refresh(
offline_username: OFFLINE_USERNAME.to_string(),
online_username: ONLINE_USERNAME.to_string(),
codex_home: codex_home.to_path_buf(),
read_roots: gather_read_roots(command_cwd, policy, policy_cwd),
read_roots: gather_read_roots(command_cwd, policy),
write_roots: gather_write_roots(policy, policy_cwd, command_cwd, env_map),
real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()),
refresh_only: true,
@@ -183,11 +183,7 @@ fn canonical_existing(paths: &[PathBuf]) -> Vec<PathBuf> {
.collect()
}
pub(crate) fn gather_read_roots(
command_cwd: &Path,
policy: &SandboxPolicy,
policy_cwd: &Path,
) -> Vec<PathBuf> {
pub(crate) fn gather_read_roots(command_cwd: &Path, policy: &SandboxPolicy) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
for p in [
PathBuf::from(r"C:\Windows"),
@@ -203,12 +199,7 @@ pub(crate) fn gather_read_roots(
roots.push(command_cwd.to_path_buf());
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy {
for root in writable_roots {
let candidate = if root.is_absolute() {
root.clone()
} else {
policy_cwd.join(root)
};
roots.push(candidate);
roots.push(root.to_path_buf());
}
}
canonical_existing(&roots)
@@ -375,7 +366,7 @@ pub fn run_elevated_setup(
let read_roots = if let Some(roots) = read_roots_override {
roots
} else {
gather_read_roots(command_cwd, policy, policy_cwd)
gather_read_roots(command_cwd, policy)
};
let payload = ElevationPayload {
version: SETUP_VERSION,