diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 6f075e7dc..764fc4fbb 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -79,6 +79,7 @@ pub mod review_format; pub mod review_prompts; mod thread_manager; pub mod web_search; +pub mod windows_sandbox_read_grants; pub use codex_protocol::protocol::InitialHistory; pub use thread_manager::NewThread; pub use thread_manager::ThreadManager; diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 180cce6c2..02f740ebd 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -10,6 +10,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; +use std::path::PathBuf; /// Kill switch for the elevated sandbox NUX on Windows. /// @@ -200,6 +201,25 @@ pub fn run_legacy_setup_preflight( ) } +#[cfg(target_os = "windows")] +pub fn run_setup_refresh_with_extra_read_roots( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + extra_read_roots: Vec, +) -> anyhow::Result<()> { + codex_windows_sandbox::run_setup_refresh_with_extra_read_roots( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + extra_read_roots, + ) +} + #[cfg(not(target_os = "windows"))] pub fn run_legacy_setup_preflight( _policy: &SandboxPolicy, @@ -211,6 +231,18 @@ pub fn run_legacy_setup_preflight( anyhow::bail!("legacy Windows sandbox setup is only supported on Windows") } +#[cfg(not(target_os = "windows"))] +pub fn run_setup_refresh_with_extra_read_roots( + _policy: &SandboxPolicy, + _policy_cwd: &Path, + _command_cwd: &Path, + _env_map: &HashMap, + _codex_home: &Path, + _extra_read_roots: Vec, +) -> anyhow::Result<()> { + anyhow::bail!("Windows sandbox read-root refresh is only supported on Windows") +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/windows_sandbox_read_grants.rs b/codex-rs/core/src/windows_sandbox_read_grants.rs new file mode 100644 index 000000000..8fa843c8b --- /dev/null +++ b/codex-rs/core/src/windows_sandbox_read_grants.rs @@ -0,0 +1,97 @@ +use crate::protocol::SandboxPolicy; +use crate::windows_sandbox::run_setup_refresh_with_extra_read_roots; +use anyhow::Result; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +pub fn grant_read_root_non_elevated( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + read_root: &Path, +) -> Result { + if !read_root.is_absolute() { + anyhow::bail!("path must be absolute: {}", read_root.display()); + } + if !read_root.exists() { + anyhow::bail!("path does not exist: {}", read_root.display()); + } + if !read_root.is_dir() { + anyhow::bail!("path must be a directory: {}", read_root.display()); + } + + let canonical_root = dunce::canonicalize(read_root)?; + run_setup_refresh_with_extra_read_roots( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + vec![canonical_root.clone()], + )?; + Ok(canonical_root) +} + +#[cfg(test)] +mod tests { + use super::grant_read_root_non_elevated; + use crate::protocol::SandboxPolicy; + use std::collections::HashMap; + use std::path::Path; + use tempfile::TempDir; + + fn policy() -> SandboxPolicy { + SandboxPolicy::new_workspace_write_policy() + } + + #[test] + fn rejects_relative_path() { + let tmp = TempDir::new().expect("tempdir"); + let err = grant_read_root_non_elevated( + &policy(), + tmp.path(), + tmp.path(), + &HashMap::new(), + tmp.path(), + Path::new("relative"), + ) + .expect_err("relative path should fail"); + assert!(err.to_string().contains("path must be absolute")); + } + + #[test] + fn rejects_missing_path() { + let tmp = TempDir::new().expect("tempdir"); + let missing = tmp.path().join("does-not-exist"); + let err = grant_read_root_non_elevated( + &policy(), + tmp.path(), + tmp.path(), + &HashMap::new(), + tmp.path(), + missing.as_path(), + ) + .expect_err("missing path should fail"); + assert!(err.to_string().contains("path does not exist")); + } + + #[test] + fn rejects_file_path() { + let tmp = TempDir::new().expect("tempdir"); + let file_path = tmp.path().join("file.txt"); + std::fs::write(&file_path, "hello").expect("write file"); + let err = grant_read_root_non_elevated( + &policy(), + tmp.path(), + tmp.path(), + &HashMap::new(), + tmp.path(), + file_path.as_path(), + ) + .expect_err("file path should fail"); + assert!(err.to_string().contains("path must be a directory")); + } +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c700a3a84..67d5e30a8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1826,6 +1826,63 @@ impl App { let _ = preset; } } + AppEvent::BeginWindowsSandboxGrantReadRoot { path } => { + #[cfg(target_os = "windows")] + { + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!("Granting sandbox read access to {path} ..."), + None, + )); + + let policy = self.config.sandbox_policy.get().clone(); + let policy_cwd = self.config.cwd.clone(); + let command_cwd = self.config.cwd.clone(); + let env_map: std::collections::HashMap = + std::env::vars().collect(); + let codex_home = self.config.codex_home.clone(); + let tx = self.app_event_tx.clone(); + + tokio::task::spawn_blocking(move || { + let requested_path = PathBuf::from(path); + let event = match codex_core::windows_sandbox_read_grants::grant_read_root_non_elevated( + &policy, + policy_cwd.as_path(), + command_cwd.as_path(), + &env_map, + codex_home.as_path(), + requested_path.as_path(), + ) { + Ok(canonical_path) => AppEvent::WindowsSandboxGrantReadRootCompleted { + path: canonical_path, + error: None, + }, + Err(err) => AppEvent::WindowsSandboxGrantReadRootCompleted { + path: requested_path, + error: Some(err.to_string()), + }, + }; + tx.send(event); + }); + } + #[cfg(not(target_os = "windows"))] + { + let _ = path; + } + } + AppEvent::WindowsSandboxGrantReadRootCompleted { path, error } => match error { + Some(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!("Error: {err}"))); + } + None => { + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!("Sandbox read access granted for {}", path.display()), + None, + )); + } + }, AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { #[cfg(target_os = "windows")] { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index d2168e961..67db6d7c8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -218,6 +218,19 @@ pub(crate) enum AppEvent { preset: ApprovalPreset, }, + /// Begin a non-elevated grant of read access for an additional directory. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + BeginWindowsSandboxGrantReadRoot { + path: String, + }, + + /// Result of attempting to grant read access for an additional directory. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + WindowsSandboxGrantReadRootCompleted { + path: PathBuf, + error: Option, + }, + /// Enable the Windows sandbox feature and switch to Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] EnableWindowsSandboxForAgentMode { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0ae056f83..46dcc1999 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3342,6 +3342,11 @@ impl ChatWidget { // Not supported; on non-Windows this command should never be reachable. }; } + SlashCommand::SandboxReadRoot => { + self.add_error_message( + "Usage: /sandbox-add-read-dir ".to_string(), + ); + } SlashCommand::Experimental => { self.open_experimental_popup(); } @@ -3544,6 +3549,18 @@ impl ChatWidget { }); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(false) + else { + return; + }; + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxGrantReadRoot { + path: prepared_args, + }); + self.bottom_pane.drain_pending_submission_state(); + } _ => self.dispatch_command(cmd), } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a1699c37b..16e767f80 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -17,6 +17,8 @@ pub enum SlashCommand { Permissions, #[strum(serialize = "setup-default-sandbox")] ElevateSandbox, + #[strum(serialize = "sandbox-add-read-dir")] + SandboxReadRoot, Experimental, Skills, Review, @@ -84,7 +86,10 @@ impl SlashCommand { SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex is allowed to do", SlashCommand::Permissions => "choose what Codex is allowed to do", - SlashCommand::ElevateSandbox => "set up default agent sandbox", + SlashCommand::ElevateSandbox => "set up elevated agent sandbox", + SlashCommand::SandboxReadRoot => { + "let sandbox read a directory: /sandbox-add-read-dir " + } SlashCommand::Experimental => "toggle experimental features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Apps => "manage apps", @@ -104,7 +109,10 @@ impl SlashCommand { pub fn supports_inline_args(self) -> bool { matches!( self, - SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan + SlashCommand::Review + | SlashCommand::Rename + | SlashCommand::Plan + | SlashCommand::SandboxReadRoot ) } @@ -122,6 +130,7 @@ impl SlashCommand { | SlashCommand::Approvals | SlashCommand::Permissions | SlashCommand::ElevateSandbox + | SlashCommand::SandboxReadRoot | SlashCommand::Experimental | SlashCommand::Review | SlashCommand::Plan @@ -151,6 +160,7 @@ impl SlashCommand { fn is_visible(self) -> bool { match self { + SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), _ => true, } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 3e15c7a27..61077342d 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -83,6 +83,8 @@ pub use setup::run_elevated_setup; #[cfg(target_os = "windows")] pub use setup::run_setup_refresh; #[cfg(target_os = "windows")] +pub use setup::run_setup_refresh_with_extra_read_roots; +#[cfg(target_os = "windows")] pub use setup::sandbox_dir; #[cfg(target_os = "windows")] pub use setup::sandbox_secrets_dir; diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 0e43d1ba4..e7f9bee69 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -61,6 +61,47 @@ pub fn run_setup_refresh( command_cwd: &Path, env_map: &HashMap, codex_home: &Path, +) -> Result<()> { + run_setup_refresh_inner( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + None, + None, + ) +} + +pub fn run_setup_refresh_with_extra_read_roots( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + extra_read_roots: Vec, +) -> Result<()> { + let mut read_roots = gather_read_roots(command_cwd, policy); + read_roots.extend(extra_read_roots); + run_setup_refresh_inner( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + Some(read_roots), + Some(Vec::new()), + ) +} + +fn run_setup_refresh_inner( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + read_roots_override: Option>, + write_roots_override: Option>, ) -> Result<()> { // Skip in danger-full-access. if matches!( @@ -75,8 +116,8 @@ pub fn run_setup_refresh( command_cwd, env_map, codex_home, - None, - None, + read_roots_override, + write_roots_override, ); let payload = ElevationPayload { version: SETUP_VERSION,