diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index dc5ff1114..82be47ac3 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -3,6 +3,7 @@ use crate::config::types::McpServerConfig; use crate::config::types::Notice; use anyhow::Context; use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::config_types::TrustLevel; use codex_utils_tokenizer::warm_model_cache; use std::collections::BTreeMap; use std::path::Path; @@ -34,9 +35,9 @@ pub enum ConfigEdit { SetNoticeHideModelMigrationPrompt(String, bool), /// Replace the entire `[mcp_servers]` table. ReplaceMcpServers(BTreeMap), - /// Set trust_level = "trusted" under `[projects.""]`, + /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. - SetProjectTrusted(PathBuf), + SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, /// Set the value stored at the exact dotted path. SetPath { segments: Vec, @@ -274,10 +275,14 @@ impl ConfigDocument { ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), - ConfigEdit::SetProjectTrusted(project_path) => { + ConfigEdit::SetProjectTrustLevel { path, level } => { // Delegate to the existing, tested logic in config.rs to // ensure tables are explicit and migration is preserved. - crate::config::set_project_trusted_inner(&mut self.doc, project_path.as_path())?; + crate::config::set_project_trust_level_inner( + &mut self.doc, + path.as_path(), + *level, + )?; Ok(true) } } @@ -533,9 +538,15 @@ impl ConfigEditsBuilder { self } - pub fn set_project_trusted>(mut self, project_path: P) -> Self { - self.edits - .push(ConfigEdit::SetProjectTrusted(project_path.into())); + pub fn set_project_trust_level>( + mut self, + project_path: P, + trust_level: TrustLevel, + ) -> Self { + self.edits.push(ConfigEdit::SetProjectTrustLevel { + path: project_path.into(), + level: trust_level, + }); self } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index afd30512e..b1e5b7f98 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -38,6 +38,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; use codex_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; @@ -382,15 +383,16 @@ fn ensure_no_inline_bearer_tokens(value: &TomlValue) -> std::io::Result<()> { Ok(()) } -pub(crate) fn set_project_trusted_inner( +pub(crate) fn set_project_trust_level_inner( doc: &mut DocumentMut, project_path: &Path, + trust_level: TrustLevel, ) -> anyhow::Result<()> { // Ensure we render a human-friendly structure: // // [projects] // [projects."/path/to/project"] - // trust_level = "trusted" + // trust_level = "trusted" or "untrusted" // // rather than inline tables like: // @@ -446,17 +448,21 @@ pub(crate) fn set_project_trusted_inner( return Err(anyhow::anyhow!("project table missing for {project_key}")); }; proj_tbl.set_implicit(false); - proj_tbl["trust_level"] = toml_edit::value("trusted"); + proj_tbl["trust_level"] = toml_edit::value(trust_level.to_string()); Ok(()) } -/// Patch `CODEX_HOME/config.toml` project state. +/// Patch `CODEX_HOME/config.toml` project state to set trust level. /// Use with caution. -pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Result<()> { +pub fn set_project_trust_level( + codex_home: &Path, + project_path: &Path, + trust_level: TrustLevel, +) -> anyhow::Result<()> { use crate::config::edit::ConfigEditsBuilder; ConfigEditsBuilder::new(codex_home) - .set_project_trusted(project_path) + .set_project_trust_level(project_path, trust_level) .apply_blocking() } @@ -686,15 +692,16 @@ impl From for UserSavedConfig { #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] pub struct ProjectConfig { - pub trust_level: Option, + pub trust_level: Option, } impl ProjectConfig { pub fn is_trusted(&self) -> bool { - match &self.trust_level { - Some(trust_level) => trust_level == "trusted", - None => false, - } + matches!(self.trust_level, Some(TrustLevel::Trusted)) + } + + pub fn is_untrusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Untrusted)) } } @@ -735,9 +742,9 @@ impl ConfigToml { .or(profile_sandbox_mode) .or(self.sandbox_mode) .or_else(|| { - // if no sandbox_mode is set, but user has marked directory as trusted, use WorkspaceWrite + // if no sandbox_mode is set, but user has marked directory as trusted or untrusted, use WorkspaceWrite self.get_active_project(resolved_cwd).and_then(|p| { - if p.is_trusted() { + if p.is_trusted() || p.is_untrusted() { Some(SandboxMode::WorkspaceWrite) } else { None @@ -958,6 +965,9 @@ impl Config { if active_project.is_trusted() { // If no explicit approval policy is set, but we trust cwd, default to OnRequest AskForApproval::OnRequest + } else if active_project.is_untrusted() { + // If project is explicitly marked untrusted, require approval for non-safe commands + AskForApproval::UnlessTrusted } else { AskForApproval::default() } @@ -3164,7 +3174,7 @@ model_verbosity = "high" let project_dir = Path::new("/some/path"); let mut doc = DocumentMut::new(); - set_project_trusted_inner(&mut doc, project_dir)?; + set_project_trust_level_inner(&mut doc, project_dir, TrustLevel::Trusted)?; let contents = doc.to_string(); @@ -3204,7 +3214,7 @@ trust_level = "trusted" let mut doc = initial.parse::()?; // Run the function; it should convert to explicit tables and set trusted - set_project_trusted_inner(&mut doc, project_dir)?; + set_project_trust_level_inner(&mut doc, project_dir, TrustLevel::Trusted)?; let contents = doc.to_string(); @@ -3231,7 +3241,7 @@ model = "foo""#; // Approve a new directory let new_project = Path::new("/Users/mbolin/code/codex2"); - set_project_trusted_inner(&mut doc, new_project)?; + set_project_trust_level_inner(&mut doc, new_project, TrustLevel::Trusted)?; let contents = doc.to_string(); @@ -3254,6 +3264,87 @@ trust_level = "trusted" Ok(()) } + + #[test] + fn test_untrusted_project_gets_workspace_write_sandbox() -> anyhow::Result<()> { + let config_with_untrusted = r#" +[projects."/tmp/test"] +trust_level = "untrusted" +"#; + + let cfg = toml::from_str::(config_with_untrusted) + .expect("TOML deserialization should succeed"); + + let resolution = cfg.derive_sandbox_policy(None, None, &PathBuf::from("/tmp/test")); + + // Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade) + if cfg!(target_os = "windows") { + assert!( + matches!(resolution.policy, SandboxPolicy::ReadOnly), + "Expected ReadOnly on Windows, got {:?}", + resolution.policy + ); + } else { + assert!( + matches!(resolution.policy, SandboxPolicy::WorkspaceWrite { .. }), + "Expected WorkspaceWrite for untrusted project, got {:?}", + resolution.policy + ); + } + + Ok(()) + } + + #[test] + fn test_untrusted_project_gets_unless_trusted_approval_policy() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let test_project_dir = TempDir::new()?; + let test_path = test_project_dir.path(); + + let mut projects = std::collections::HashMap::new(); + projects.insert( + test_path.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Untrusted), + }, + ); + + let cfg = ConfigToml { + projects: Some(projects), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(test_path.to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + // Verify that untrusted projects get UnlessTrusted approval policy + assert_eq!( + config.approval_policy, + AskForApproval::UnlessTrusted, + "Expected UnlessTrusted approval policy for untrusted project" + ); + + // Verify that untrusted projects still get WorkspaceWrite sandbox (or ReadOnly on Windows) + if cfg!(target_os = "windows") { + assert!( + matches!(config.sandbox_policy, SandboxPolicy::ReadOnly), + "Expected ReadOnly on Windows" + ); + } else { + assert!( + matches!(config.sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }), + "Expected WorkspaceWrite sandbox for untrusted project" + ); + } + + Ok(()) + } } #[cfg(test)] diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index a947e7d13..3881bde67 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -98,3 +98,13 @@ pub enum ForcedLoginMethod { Chatgpt, Api, } + +/// Represents the trust level for a project directory. +/// This determines the approval policy and sandbox mode applied. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum TrustLevel { + Trusted, + Untrusted, +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d7b674a9d..3953f8b50 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -82,7 +82,6 @@ mod wrapping; #[cfg(test)] pub mod test_backend; -use crate::onboarding::TrustDirectorySelection; use crate::onboarding::WSL_INSTRUCTIONS; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; @@ -378,13 +377,8 @@ async fn run_ratatui_app( update_action: None, }); } - // if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly - if should_show_windows_wsl_screen - || onboarding_result - .directory_trust_decision - .map(|d| d == TrustDirectorySelection::Trust) - .unwrap_or(false) - { + // if the user acknowledged windows or made any trust decision, reload the config accordingly + if should_show_windows_wsl_screen || onboarding_result.directory_trust_decision.is_some() { load_config_or_exit(cli_kv_overrides, overrides).await } else { initial_config @@ -540,8 +534,8 @@ fn should_show_trust_screen(config: &Config) -> bool { // Respect explicit approval/sandbox overrides made by the user. return false; } - // otherwise, skip iff the active project is trusted - !config.active_project.is_trusted() + // otherwise, show only if no trust decision has been made + config.active_project.trust_level.is_none() } fn should_show_onboarding( @@ -635,4 +629,25 @@ mod tests { } Ok(()) } + #[test] + fn untrusted_project_skips_trust_prompt() -> std::io::Result<()> { + use codex_protocol::config_types::TrustLevel; + let temp_dir = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + temp_dir.path().to_path_buf(), + )?; + config.did_user_set_custom_approval_policy_or_sandbox_mode = false; + config.active_project = ProjectConfig { + trust_level: Some(TrustLevel::Untrusted), + }; + + let should_show = should_show_trust_screen(&config); + assert!( + !should_show, + "Trust prompt should not be shown for projects explicitly marked as untrusted" + ); + Ok(()) + } } diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 8a7541c02..fd24b8e88 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; -use codex_core::config::set_project_trusted; +use codex_core::config::set_project_trust_level; use codex_core::git_info::resolve_root_git_project_for_trust; +use codex_protocol::config_types::TrustLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -153,7 +154,7 @@ impl TrustDirectoryWidget { fn handle_trust(&mut self) { let target = resolve_root_git_project_for_trust(&self.cwd).unwrap_or_else(|| self.cwd.clone()); - if let Err(e) = set_project_trusted(&self.codex_home, &target) { + if let Err(e) = set_project_trust_level(&self.codex_home, &target, TrustLevel::Trusted) { tracing::error!("Failed to set project trusted: {e:?}"); self.error = Some(format!("Failed to set trust for {}: {e}", target.display())); } @@ -163,6 +164,16 @@ impl TrustDirectoryWidget { fn handle_dont_trust(&mut self) { self.highlighted = TrustDirectorySelection::DontTrust; + let target = + resolve_root_git_project_for_trust(&self.cwd).unwrap_or_else(|| self.cwd.clone()); + if let Err(e) = set_project_trust_level(&self.codex_home, &target, TrustLevel::Untrusted) { + tracing::error!("Failed to set project untrusted: {e:?}"); + self.error = Some(format!( + "Failed to set untrusted for {}: {e}", + target.display() + )); + } + self.selection = Some(TrustDirectorySelection::DontTrust); } }