diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 43038d351c..5ebb4461d1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -885,6 +885,9 @@ impl From for SandboxPolicy { exclude_tmpdir_env_var, exclude_slash_tmp, }, + codex_protocol::protocol::SandboxPolicy::Custom { .. } => { + panic!("SandboxPolicy::Custom is internal-only and not supported in app-server v2") + } } } } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index db81b45e35..4e3303a28f 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -451,6 +451,13 @@ impl TryFrom for ConfigRequirements { SandboxPolicy::ExternalSandbox { .. } => { SandboxModeRequirement::ExternalSandbox } + SandboxPolicy::Custom { writable_roots, .. } => { + if writable_roots.is_empty() { + SandboxModeRequirement::ReadOnly + } else { + SandboxModeRequirement::WorkspaceWrite + } + } }; if modes.contains(&mode) { Ok(()) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 52329a255f..ab37b20370 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -493,7 +493,9 @@ pub fn render_decision_for_unmatched_command( // command has not been flagged as dangerous. Decision::Allow } - SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => { + SandboxPolicy::ReadOnly { .. } + | SandboxPolicy::WorkspaceWrite { .. } + | SandboxPolicy::Custom { .. } => { // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for // non‑escalated, non‑dangerous commands — let the sandbox enforce // restrictions (e.g., block network/write) without a user prompt. @@ -511,7 +513,9 @@ pub fn render_decision_for_unmatched_command( // by `prompt_is_rejected_by_policy`. Decision::Allow } - SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => { + SandboxPolicy::ReadOnly { .. } + | SandboxPolicy::WorkspaceWrite { .. } + | SandboxPolicy::Custom { .. } => { if sandbox_permissions.requires_escalated_permissions() { Decision::Prompt } else { diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 350e7dad0f..ab9be37674 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -133,7 +133,9 @@ fn is_write_patch_constrained_to_writable_paths( SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { return true; } - SandboxPolicy::WorkspaceWrite { .. } => sandbox_policy.get_writable_roots_with_cwd(cwd), + SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::Custom { .. } => { + sandbox_policy.get_writable_roots_with_cwd(cwd) + } }; // Normalize a path by removing `.` and resolving `..` without touching the diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 7a0be68df3..50cb081494 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -310,6 +310,7 @@ fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str { match policy { SandboxPolicy::ReadOnly { .. } => "read-only", SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", + SandboxPolicy::Custom { .. } => "custom", SandboxPolicy::DangerFullAccess => "danger-full-access", SandboxPolicy::ExternalSandbox { .. } => "external-sandbox", } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 65099541c4..ce3a5c92eb 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -321,6 +321,14 @@ impl DeveloperInstructions { let roots = sandbox_policy.get_writable_roots_with_cwd(cwd); (SandboxMode::WorkspaceWrite, Some(roots)) } + SandboxPolicy::Custom { .. } => { + let roots = sandbox_policy.get_writable_roots_with_cwd(cwd); + if roots.is_empty() { + (SandboxMode::ReadOnly, None) + } else { + (SandboxMode::WorkspaceWrite, Some(roots)) + } + } }; DeveloperInstructions::from_permissions_with_network( diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a8b7ade166..12ae25351d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; use std::fmt; +use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; @@ -610,6 +611,25 @@ pub enum SandboxPolicy { #[serde(default)] exclude_slash_tmp: bool, }, + + /// Internal-only exact path policy with explicit read/write roots. + #[serde(rename = "custom")] + Custom { + /// Read access granted while running under this policy. + #[serde( + default, + skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access" + )] + read_only_access: ReadOnlyAccess, + + /// Exact writable roots and custom read-only subpaths under each root. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + writable_roots: Vec, + + /// Whether outbound network access is allowed. + #[serde(default)] + network_access: NetworkAccess, + }, } /// A writable root path accompanied by a list of subpaths that should remain @@ -625,6 +645,16 @@ pub struct WritableRoot { pub read_only_subpaths: Vec, } +/// Declarative writable root configuration for [`SandboxPolicy::Custom`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct CustomWritableRoot { + pub root: AbsolutePathBuf, + + /// Relative subpaths under `root` that should remain read-only. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub read_only_paths: Vec, +} + impl WritableRoot { pub fn is_path_writable(&self, path: &Path) -> bool { // Check if the path is under the root. @@ -643,6 +673,90 @@ impl WritableRoot { } } +fn normalize_relative_subpath(path: &Path) -> Option { + if path.as_os_str().is_empty() || path.is_absolute() { + return None; + } + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(part) => normalized.push(part), + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, + } + } + + if normalized.as_os_str().is_empty() { + None + } else { + Some(normalized) + } +} + +fn build_writable_root( + writable_root: AbsolutePathBuf, + explicit_read_only_paths: &[PathBuf], +) -> WritableRoot { + let mut subpaths: Vec = Vec::new(); + let mut seen = HashSet::new(); + + for read_only_path in explicit_read_only_paths { + let Some(normalized) = normalize_relative_subpath(read_only_path) else { + error!( + "Ignoring invalid custom read-only subpath {:?} under {}", + read_only_path, + writable_root.as_path().display() + ); + continue; + }; + #[allow(clippy::expect_used)] + let absolute_subpath = writable_root + .join(normalized) + .expect("normalized relative path is valid"); + if seen.insert(absolute_subpath.clone()) { + subpaths.push(absolute_subpath); + } + } + + #[allow(clippy::expect_used)] + let top_level_git = writable_root + .join(".git") + .expect(".git is a valid relative path"); + // 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) + && seen.insert(gitdir.clone()) + { + subpaths.push(gitdir); + } + if seen.insert(top_level_git.clone()) { + subpaths.push(top_level_git); + } + } + + // Make .agents/skills and .codex/config.toml and related files read-only + // to the agent, by default. + for subdir in &[".agents", ".codex"] { + #[allow(clippy::expect_used)] + let top_level_codex = writable_root.join(subdir).expect("valid relative path"); + if top_level_codex.as_path().is_dir() && seen.insert(top_level_codex.clone()) { + subpaths.push(top_level_codex); + } + } + + WritableRoot { + root: writable_root, + read_only_subpaths: subpaths, + } +} + impl FromStr for SandboxPolicy { type Err = serde_json::Error; @@ -680,6 +794,9 @@ impl SandboxPolicy { SandboxPolicy::WorkspaceWrite { read_only_access, .. } => read_only_access.has_full_disk_read_access(), + SandboxPolicy::Custom { + read_only_access, .. + } => read_only_access.has_full_disk_read_access(), } } @@ -689,6 +806,7 @@ impl SandboxPolicy { SandboxPolicy::ExternalSandbox { .. } => true, SandboxPolicy::ReadOnly { .. } => false, SandboxPolicy::WorkspaceWrite { .. } => false, + SandboxPolicy::Custom { .. } => false, } } @@ -698,6 +816,7 @@ impl SandboxPolicy { SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(), SandboxPolicy::ReadOnly { .. } => false, SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, + SandboxPolicy::Custom { network_access, .. } => network_access.is_enabled(), } } @@ -711,6 +830,9 @@ impl SandboxPolicy { SandboxPolicy::WorkspaceWrite { read_only_access, .. } => read_only_access.include_platform_defaults(), + SandboxPolicy::Custom { + read_only_access, .. + } => read_only_access.include_platform_defaults(), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => false, } } @@ -726,6 +848,9 @@ impl SandboxPolicy { SandboxPolicy::ReadOnly { access } => access.get_readable_roots_with_cwd(cwd), SandboxPolicy::WorkspaceWrite { read_only_access, .. + } + | SandboxPolicy::Custom { + read_only_access, .. } => { let mut roots = read_only_access.get_readable_roots_with_cwd(cwd); roots.extend( @@ -812,48 +937,19 @@ impl SandboxPolicy { // For each root, compute subpaths that should remain read-only. roots .into_iter() - .map(|writable_root| { - let mut subpaths: Vec = Vec::new(); - #[allow(clippy::expect_used)] - let top_level_git = writable_root - .join(".git") - .expect(".git is a valid relative path"); - // This applies to typical repos (directory .git), worktrees/submodules - // (file .git with gitdir pointer), and bare repos when the gitdir is the - // writable root itself. - let top_level_git_is_file = top_level_git.as_path().is_file(); - let top_level_git_is_dir = top_level_git.as_path().is_dir(); - if top_level_git_is_dir || top_level_git_is_file { - if top_level_git_is_file - && is_git_pointer_file(&top_level_git) - && let Some(gitdir) = resolve_gitdir_from_file(&top_level_git) - && !subpaths - .iter() - .any(|subpath| subpath.as_path() == gitdir.as_path()) - { - subpaths.push(gitdir); - } - subpaths.push(top_level_git); - } - - // Make .agents/skills and .codex/config.toml and - // related files read-only to the agent, by default. - for subdir in &[".agents", ".codex"] { - #[allow(clippy::expect_used)] - let top_level_codex = - writable_root.join(subdir).expect("valid relative path"); - if top_level_codex.as_path().is_dir() { - subpaths.push(top_level_codex); - } - } - - WritableRoot { - root: writable_root, - read_only_subpaths: subpaths, - } - }) + .map(|writable_root| build_writable_root(writable_root, &[])) .collect() } + SandboxPolicy::Custom { + writable_roots, + read_only_access: _, + network_access: _, + } => writable_roots + .iter() + .map(|writable_root| { + build_writable_root(writable_root.root.clone(), &writable_root.read_only_paths) + }) + .collect(), } } } @@ -3071,6 +3167,79 @@ mod tests { } } + #[test] + fn custom_writable_roots_are_readable_and_exact_only() { + let tmp = tempfile::tempdir().expect("tempdir"); + let custom_root_path = tmp.path().join("custom"); + std::fs::create_dir_all(&custom_root_path).expect("custom root"); + let custom_root = AbsolutePathBuf::try_from(custom_root_path.as_path()).expect("absolute"); + let cwd = tmp.path().join("cwd"); + std::fs::create_dir_all(&cwd).expect("cwd"); + + let policy = SandboxPolicy::Custom { + read_only_access: ReadOnlyAccess::FullAccess, + writable_roots: vec![CustomWritableRoot { + root: custom_root.clone(), + read_only_paths: vec![PathBuf::from("Cargo.lock")], + }], + network_access: NetworkAccess::Restricted, + }; + + let writable_roots = policy.get_writable_roots_with_cwd(&cwd); + assert_eq!(writable_roots.len(), 1); + assert_eq!(writable_roots[0].root, custom_root); + assert!( + writable_roots[0] + .read_only_subpaths + .iter() + .any(|path| path.as_path() == custom_root_path.join("Cargo.lock")) + ); + + let readable_roots = policy.get_readable_roots_with_cwd(&cwd); + assert!( + readable_roots + .iter() + .any(|path| path.as_path() == custom_root_path) + ); + assert!(!readable_roots.iter().any(|path| path.as_path() == cwd)); + } + + #[test] + fn custom_ignores_invalid_read_only_subpaths() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root_path = tmp.path().join("custom"); + std::fs::create_dir_all(&root_path).expect("custom root"); + let root = AbsolutePathBuf::try_from(root_path.as_path()).expect("absolute"); + + let policy = SandboxPolicy::Custom { + read_only_access: ReadOnlyAccess::FullAccess, + writable_roots: vec![CustomWritableRoot { + root: root, + read_only_paths: vec![ + PathBuf::from("."), + PathBuf::from("../escape"), + PathBuf::from("/absolute"), + PathBuf::from("ok"), + ], + }], + network_access: NetworkAccess::Enabled, + }; + + let writable_roots = policy.get_writable_roots_with_cwd(tmp.path()); + assert_eq!(writable_roots.len(), 1); + let subpaths = &writable_roots[0].read_only_subpaths; + assert!( + subpaths + .iter() + .any(|path| path.as_path() == root_path.join("ok")) + ); + assert!( + !subpaths + .iter() + .any(|path| path.as_path() == root_path.join("escape")) + ); + } + #[test] fn item_started_event_from_web_search_emits_begin_event() { let event = ItemStartedEvent { diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs index f7d2ef5508..d0415d907d 100644 --- a/codex-rs/tui/src/additional_dirs.rs +++ b/codex-rs/tui/src/additional_dirs.rs @@ -16,7 +16,9 @@ pub fn add_dir_warning_message( SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => None, - SandboxPolicy::ReadOnly { .. } => Some(format_warning(additional_dirs)), + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::Custom { .. } => { + Some(format_warning(additional_dirs)) + } } } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index bf55c0ace1..d926813b38 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -202,6 +202,13 @@ impl StatusHistoryCell { .. } => "workspace-write with network access".to_string(), SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), + SandboxPolicy::Custom { network_access, .. } => { + if matches!(network_access, NetworkAccess::Enabled) { + "custom sandbox with network access".to_string() + } else { + "custom sandbox".to_string() + } + } SandboxPolicy::ExternalSandbox { network_access } => { if matches!(network_access, NetworkAccess::Enabled) { "external-sandbox (network access enabled)".to_string() diff --git a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs index a5c9b31fdd..d87ec9f0cc 100644 --- a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs @@ -41,12 +41,31 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { } summary } + SandboxPolicy::Custom { + writable_roots, + network_access, + read_only_access: _, + } => { + let mut summary = "custom".to_string(); + if !writable_roots.is_empty() { + let writable_entries = writable_roots + .iter() + .map(|root| root.root.to_string_lossy().to_string()) + .collect::>(); + summary.push_str(&format!(" [{}]", writable_entries.join(", "))); + } + if matches!(network_access, NetworkAccess::Enabled) { + summary.push_str(" (network access enabled)"); + } + summary + } } } #[cfg(test)] mod tests { use super::*; + use codex_protocol::protocol::CustomWritableRoot; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -85,4 +104,25 @@ mod tests { ) ); } + + #[test] + fn custom_summary_lists_explicit_roots_without_workspace_defaults() { + let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; + let writable_root = AbsolutePathBuf::try_from(root).unwrap(); + let summary = summarize_sandbox_policy(&SandboxPolicy::Custom { + read_only_access: Default::default(), + writable_roots: vec![CustomWritableRoot { + root: writable_root.clone(), + read_only_paths: vec![], + }], + network_access: NetworkAccess::Enabled, + }); + assert_eq!( + summary, + format!( + "custom [{}] (network access enabled)", + writable_root.to_string_lossy() + ) + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index b40532cda8..8f2be1d11c 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -38,9 +38,13 @@ pub fn compute_allow_paths( } ); - if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { + if matches!( + policy, + SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::Custom { .. } + ) { let add_writable_root = |root: PathBuf, + read_only_subpaths: &[PathBuf], policy_cwd: &Path, add_allow: &mut dyn FnMut(PathBuf), add_deny: &mut dyn FnMut(PathBuf)| { @@ -52,30 +56,34 @@ pub fn compute_allow_paths( let canonical = canonicalize(&candidate).unwrap_or(candidate); add_allow(canonical.clone()); - for protected_subdir in [".git", ".codex", ".agents"] { - let protected_entry = canonical.join(protected_subdir); - if protected_entry.exists() { - add_deny(protected_entry); - } + for read_only_subpath in read_only_subpaths { + add_deny(canonicalize(read_only_subpath).unwrap_or_else(|_| read_only_subpath.clone())); } }; - add_writable_root( - command_cwd.to_path_buf(), - policy_cwd, - &mut add_allow_path, - &mut add_deny_path, - ); + if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { + add_writable_root( + command_cwd.to_path_buf(), + &[], + policy_cwd, + &mut add_allow_path, + &mut add_deny_path, + ); + } - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { - for root in writable_roots { - add_writable_root( - root.clone().into(), - policy_cwd, - &mut add_allow_path, - &mut add_deny_path, - ); - } + for writable_root in policy.get_writable_roots_with_cwd(policy_cwd) { + let read_only_subpaths: Vec = writable_root + .read_only_subpaths + .iter() + .map(|path| path.to_path_buf()) + .collect(); + add_writable_root( + writable_root.root.to_path_buf(), + &read_only_subpaths, + policy_cwd, + &mut add_allow_path, + &mut add_deny_path, + ); } } if include_tmp_env_vars { diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 2aefb7a3fd..94dd68d83b 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -251,15 +251,32 @@ pub fn apply_capability_denies_for_world_writable( let caps = load_or_create_cap_sids(codex_home)?; std::fs::write(&cap_path, serde_json::to_string(&caps)?)?; let (active_sid, workspace_roots): (*mut c_void, Vec) = match sandbox_policy { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { let sid = unsafe { convert_string_sid_to_sid(&caps.workspace) } .ok_or_else(|| anyhow!("ConvertStringSidToSidW failed for workspace capability"))?; - let mut roots: Vec = - vec![dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())]; - for root in writable_roots { - let candidate = root.as_path(); - roots.push(dunce::canonicalize(candidate).unwrap_or_else(|_| root.to_path_buf())); - } + let mut roots: Vec = sandbox_policy + .get_writable_roots_with_cwd(cwd) + .into_iter() + .map(|root| dunce::canonicalize(root.root.as_path()).unwrap_or_else(|_| root.root.to_path_buf())) + .collect(); + roots.push(dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())); + (sid, roots) + } + SandboxPolicy::Custom { writable_roots, .. } => { + let sid = if writable_roots.is_empty() { + unsafe { convert_string_sid_to_sid(&caps.readonly) }.ok_or_else(|| { + anyhow!("ConvertStringSidToSidW failed for readonly capability") + })? + } else { + unsafe { convert_string_sid_to_sid(&caps.workspace) }.ok_or_else(|| { + anyhow!("ConvertStringSidToSidW failed for workspace capability") + })? + }; + let roots = sandbox_policy + .get_writable_roots_with_cwd(cwd) + .into_iter() + .map(|root| dunce::canonicalize(root.root.as_path()).unwrap_or_else(|_| root.root.to_path_buf())) + .collect(); (sid, roots) } SandboxPolicy::ReadOnly { .. } => ( diff --git a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs index da949f8778..ea253fe2bc 100644 --- a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs @@ -140,6 +140,13 @@ pub fn main() -> Result<()> { SandboxPolicy::WorkspaceWrite { .. } => { create_workspace_write_token_with_caps_from(base, &cap_psids) } + SandboxPolicy::Custom { writable_roots, .. } => { + if writable_roots.is_empty() { + create_readonly_token_with_caps_from(base, &cap_psids) + } else { + create_workspace_write_token_with_caps_from(base, &cap_psids) + } + } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!() } diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 8f8de37e0e..969974c1be 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -256,6 +256,22 @@ mod windows_impl { crate::cap::workspace_cap_sid_for_cwd(codex_home, cwd)?, ], ), + SandboxPolicy::Custom { writable_roots, .. } => { + if writable_roots.is_empty() { + ( + unsafe { convert_string_sid_to_sid(&caps.readonly).unwrap() }, + vec![caps.readonly.clone()], + ) + } else { + ( + unsafe { convert_string_sid_to_sid(&caps.workspace).unwrap() }, + vec![ + caps.workspace.clone(), + crate::cap::workspace_cap_sid_for_cwd(codex_home, cwd)?, + ], + ) + } + } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("DangerFullAccess handled above") } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index fc96e5186b..5b1566b017 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -259,7 +259,11 @@ mod windows_impl { std::fs::create_dir_all(&sandbox_base)?; let logs_base_dir = Some(sandbox_base.as_path()); log_start(&command, logs_base_dir); - let is_workspace_write = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. }); + let is_write_capable = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. }) + || matches!( + &policy, + SandboxPolicy::Custom { writable_roots, .. } if !writable_roots.is_empty() + ); if matches!( &policy, @@ -293,6 +297,25 @@ mod windows_impl { let h = h_res?; (h, psid_generic, Some(psid_workspace)) } + SandboxPolicy::Custom { writable_roots, .. } => { + if writable_roots.is_empty() { + let psid = convert_string_sid_to_sid(&caps.readonly).unwrap(); + let (h, _) = super::token::create_readonly_token_with_cap(psid)?; + (h, psid, None) + } else { + let psid_generic = convert_string_sid_to_sid(&caps.workspace).unwrap(); + let ws_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; + let psid_workspace = convert_string_sid_to_sid(&ws_sid).unwrap(); + let base = super::token::get_current_token_for_restriction()?; + let h_res = create_workspace_write_token_with_caps_from( + base, + &[psid_generic, psid_workspace], + ); + windows_sys::Win32::Foundation::CloseHandle(base); + let h = h_res?; + (h, psid_generic, Some(psid_workspace)) + } + } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("DangerFullAccess handled above") } @@ -300,7 +323,7 @@ mod windows_impl { }; unsafe { - if is_workspace_write { + if is_write_capable { if let Ok(base) = super::token::get_current_token_for_restriction() { if let Ok(bytes) = super::token::get_logon_sid_bytes(base) { let mut tmp = bytes.clone(); @@ -312,7 +335,7 @@ mod windows_impl { } } - let persist_aces = is_workspace_write; + let persist_aces = is_write_capable; let AllowDenyPaths { allow, deny } = compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); let canonical_cwd = canonicalize_path(¤t_dir); @@ -524,8 +547,12 @@ mod windows_impl { cwd: &Path, env_map: &HashMap, ) -> Result<()> { - let is_workspace_write = matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }); - if !is_workspace_write { + let is_write_capable = matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) + || matches!( + sandbox_policy, + SandboxPolicy::Custom { writable_roots, .. } if !writable_roots.is_empty() + ); + if !is_write_capable { return Ok(()); } diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index e7f9bee69f..760d38b94f 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -264,10 +264,20 @@ pub(crate) fn gather_read_roots(command_cwd: &Path, policy: &SandboxPolicy) -> V roots.push(PathBuf::from(up)); } roots.push(command_cwd.to_path_buf()); - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { - for root in writable_roots { - roots.push(root.to_path_buf()); + match policy { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + for root in writable_roots { + roots.push(root.to_path_buf()); + } } + SandboxPolicy::Custom { writable_roots, .. } => { + for root in writable_roots { + roots.push(root.root.to_path_buf()); + } + } + SandboxPolicy::ReadOnly { .. } + | SandboxPolicy::DangerFullAccess + | SandboxPolicy::ExternalSandbox { .. } => {} } canonical_existing(&roots) }