diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index d40d9eb29e..2116bf87a3 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -114,7 +114,8 @@ pub struct ConfigToml { /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. pub sandbox_workspace_write: Option, - /// Default named permissions profile to apply from the `[permissions]` + /// Default permissions profile to apply. Names starting with `:` refer to + /// built-in profiles; other names are resolved from the `[permissions]` /// table. pub default_permissions: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 788ff5f955..fdcb1020ab 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2494,7 +2494,7 @@ "type": "string" }, "default_permissions": { - "description": "Default named permissions profile to apply from the `[permissions]` table.", + "description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.", "type": "string" }, "developer_instructions": { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 21a86dcadd..65a70804c5 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1189,6 +1189,193 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( Ok(()) } +#[tokio::test] +async fn default_permissions_can_select_builtin_profile_without_permissions_table() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some(":workspace".to_string()), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert!( + policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected :workspace to allow writing the project root, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()), + "expected :workspace to protect project metadata, policy: {policy:?}" + ); + Ok(()) +} + +#[tokio::test] +async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let project_key = cwd.path().to_string_lossy().to_string(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + projects: Some(HashMap::from([( + project_key, + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + if cfg!(target_os = "windows") { + assert!( + !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected trusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}" + ); + } else { + assert!( + policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected trusted project fallback to use :workspace, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()), + "expected :workspace metadata carveouts, policy: {policy:?}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn empty_config_defaults_to_builtin_read_only_without_trust_decision() -> std::io::Result<()> +{ + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert!( + policy.can_read_path_with_cwd(cwd.path(), cwd.path()), + "expected :read-only to allow reads, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected :read-only to deny writes, policy: {policy:?}" + ); + Ok(()) +} + +#[tokio::test] +async fn default_permissions_can_select_builtin_no_sandbox_profile() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some(":danger-no-sandbox".to_string()), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.permission_profile(), + PermissionProfile::Disabled + ); + Ok(()) +} + +#[tokio::test] +async fn user_defined_permission_profile_names_cannot_use_builtin_prefix() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some(":custom".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + ":custom".to_string(), + PermissionProfileToml::default(), + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await + .expect_err("reserved profile name should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "permissions profile `:custom` uses a reserved built-in profile prefix" + ); + Ok(()) +} + +#[tokio::test] +async fn unknown_builtin_permission_profile_name_is_rejected() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some(":unknown".to_string()), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await + .expect_err("unknown built-in profile name should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "default_permissions refers to unknown built-in profile `:unknown`" + ); + Ok(()) +} + #[tokio::test] async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9b7d23b041..1938cb3a96 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -99,9 +99,12 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; -use crate::config::permissions::compile_permission_profile; +use crate::config::permissions::builtin_permission_profile; +use crate::config::permissions::compile_permission_profile_selection; +use crate::config::permissions::default_builtin_permission_profile_name; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; -use crate::config::permissions::network_proxy_config_from_profile_network; +use crate::config::permissions::network_proxy_config_for_profile_selection; +use crate::config::permissions::validate_user_permission_profile_names; use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; use toml_edit::DocumentMut; @@ -1840,6 +1843,7 @@ impl Config { .permissions .as_ref() .is_some_and(|profiles| !profiles.is_empty()); + validate_user_permission_profile_names(cfg.permissions.as_ref())?; if has_permission_profiles && !matches!( permission_config_syntax, @@ -1870,8 +1874,7 @@ impl Config { let profiles_are_active = matches!( permission_config_syntax, Some(PermissionConfigSyntax::Profiles) - ) || (permission_config_syntax.is_none() - && has_permission_profiles); + ) || permission_config_syntax.is_none(); let ( configured_network_proxy_config, permission_profile, @@ -1881,24 +1884,19 @@ impl Config { permission_profile.to_runtime_permissions(); let configured_network_proxy_config = if network_sandbox_policy.is_enabled() && profiles_are_active { - let permissions = cfg.permissions.as_ref().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "default_permissions requires a `[permissions]` table", - ) - })?; - let default_permissions = cfg.default_permissions.as_deref().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "default_permissions requires a named permissions profile", - ) - })?; - let profile = resolve_permission_profile(permissions, default_permissions)?; - // PermissionProfile carries the active network sandbox bit, not the configured // proxy/allowlist policy. Keep that config so active profiles can round-trip // without broadening network behavior. - network_proxy_config_from_profile_network(profile.network.as_ref()) + let default_permissions = cfg.default_permissions.as_deref().unwrap_or_else(|| { + default_builtin_permission_profile_name( + &active_project, + windows_sandbox_level, + ) + }); + network_proxy_config_for_profile_selection( + cfg.permissions.as_ref(), + default_permissions, + )? } else { NetworkProxyConfig::default() }; @@ -1926,32 +1924,27 @@ impl Config { file_system_sandbox_policy, ) } else if profiles_are_active { - let permissions = cfg.permissions.as_ref().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "default_permissions requires a `[permissions]` table", - ) - })?; - let default_permissions = cfg.default_permissions.as_deref().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "default_permissions requires a named permissions profile", - ) - })?; - let profile = resolve_permission_profile(permissions, default_permissions)?; - let configured_network_proxy_config = - network_proxy_config_from_profile_network(profile.network.as_ref()); + let default_permissions = cfg.default_permissions.as_deref().unwrap_or_else(|| { + default_builtin_permission_profile_name(&active_project, windows_sandbox_level) + }); + let configured_network_proxy_config = network_proxy_config_for_profile_selection( + cfg.permissions.as_ref(), + default_permissions, + )?; let (mut file_system_sandbox_policy, network_sandbox_policy) = - compile_permission_profile( - permissions, + compile_permission_profile_selection( + cfg.permissions.as_ref(), default_permissions, resolved_cwd.as_path(), &mut startup_warnings, )?; - let mut permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); + let mut permission_profile = builtin_permission_profile(default_permissions) + .unwrap_or_else(|| { + PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + ) + }); let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( &permission_profile, &file_system_sandbox_policy, diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 6d938e9185..f49a61f493 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -12,6 +12,8 @@ use codex_config::permissions_toml::PermissionsToml; use codex_network_proxy::NetworkProxyConfig; #[cfg(test)] use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -20,6 +22,64 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; +use super::ProjectConfig; + +pub(crate) const BUILT_IN_READ_ONLY_PROFILE: &str = ":read-only"; +pub(crate) const BUILT_IN_WORKSPACE_PROFILE: &str = ":workspace"; +pub(crate) const BUILT_IN_DANGER_NO_SANDBOX_PROFILE: &str = ":danger-no-sandbox"; + +pub(crate) fn default_builtin_permission_profile_name( + active_project: &ProjectConfig, + windows_sandbox_level: WindowsSandboxLevel, +) -> &'static str { + if (active_project.is_trusted() || active_project.is_untrusted()) + && !(cfg!(target_os = "windows") && windows_sandbox_level == WindowsSandboxLevel::Disabled) + { + BUILT_IN_WORKSPACE_PROFILE + } else { + BUILT_IN_READ_ONLY_PROFILE + } +} + +pub(crate) fn is_builtin_permission_profile_name(profile_name: &str) -> bool { + matches!( + profile_name, + BUILT_IN_READ_ONLY_PROFILE + | BUILT_IN_WORKSPACE_PROFILE + | BUILT_IN_DANGER_NO_SANDBOX_PROFILE + ) +} + +pub(crate) fn builtin_permission_profile(profile_name: &str) -> Option { + match profile_name { + BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), + BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()), + BUILT_IN_DANGER_NO_SANDBOX_PROFILE => Some(PermissionProfile::Disabled), + _ => None, + } +} + +pub(crate) fn validate_user_permission_profile_names( + permissions: Option<&PermissionsToml>, +) -> io::Result<()> { + let Some(permissions) = permissions else { + return Ok(()); + }; + + for profile_name in permissions.entries.keys() { + if profile_name.starts_with(':') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "permissions profile `{profile_name}` uses a reserved built-in profile prefix" + ), + )); + } + } + + Ok(()) +} + pub(crate) fn network_proxy_config_from_profile_network( network: Option<&NetworkToml>, ) -> NetworkProxyConfig { @@ -41,6 +101,27 @@ pub(crate) fn resolve_permission_profile<'a>( }) } +pub(crate) fn network_proxy_config_for_profile_selection( + permissions: Option<&PermissionsToml>, + profile_name: &str, +) -> io::Result { + if is_builtin_permission_profile_name(profile_name) { + return Ok(NetworkProxyConfig::default()); + } + reject_unknown_builtin_permission_profile(profile_name)?; + + let permissions = permissions.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "default_permissions requires a `[permissions]` table", + ) + })?; + let profile = resolve_permission_profile(permissions, profile_name)?; + Ok(network_proxy_config_from_profile_network( + profile.network.as_ref(), + )) +} + pub(crate) fn compile_permission_profile( permissions: &PermissionsToml, profile_name: &str, @@ -103,6 +184,37 @@ pub(crate) fn compile_permission_profile( Ok((file_system_sandbox_policy, network_sandbox_policy)) } +pub(crate) fn compile_permission_profile_selection( + permissions: Option<&PermissionsToml>, + profile_name: &str, + policy_cwd: &Path, + startup_warnings: &mut Vec, +) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { + if let Some(permission_profile) = builtin_permission_profile(profile_name) { + return Ok(permission_profile.to_runtime_permissions()); + } + reject_unknown_builtin_permission_profile(profile_name)?; + + let permissions = permissions.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "default_permissions requires a `[permissions]` table", + ) + })?; + compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings) +} + +fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { + if profile_name.starts_with(':') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("default_permissions refers to unknown built-in profile `{profile_name}`"), + )); + } + + Ok(()) +} + /// Returns a list of paths that must be readable by shell tools in order /// for Codex to function. These should always be added to the /// `FileSystemSandboxPolicy` for a thread.