From 7abd70178aa2b8b9f014f898e5ff79aeb1725298 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 3 Mar 2026 22:54:50 -0800 Subject: [PATCH] config: add v3 filesystem permission profiles --- codex-rs/core/config.schema.json | 65 +- codex-rs/core/src/config/mod.rs | 618 +++++++++++++++++- codex-rs/core/src/config/permissions.rs | 220 ++++++- codex-rs/core/src/network_proxy_loader.rs | 22 +- codex-rs/core/src/skills/permissions.rs | 134 ++-- .../runtimes/shell/unix_escalation_tests.rs | 8 + codex-rs/core/tests/suite/approvals.rs | 2 +- codex-rs/protocol/src/protocol.rs | 510 +++++++++++++++ 8 files changed, 1432 insertions(+), 147 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 71d624d2c0..32931b4fdb 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -562,6 +562,30 @@ }, "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "none", + "read", + "write" + ], + "type": "string" + }, + "FilesystemPermissionToml": { + "anyOf": [ + { + "$ref": "#/definitions/FileSystemAccessMode" + }, + { + "additionalProperties": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "type": "object" + } + ] + }, + "FilesystemPermissionsToml": { + "type": "object" + }, "ForcedLoginMethod": { "enum": [ "chatgpt", @@ -1089,20 +1113,30 @@ }, "type": "object" }, - "PermissionsToml": { + "PermissionProfileNetworkToml": { "additionalProperties": false, "properties": { - "network": { - "allOf": [ - { - "$ref": "#/definitions/NetworkToml" - } - ], - "description": "Network proxy settings from `[permissions.network]`. User config can enable the proxy; managed requirements may still constrain values." + "enabled": { + "type": "boolean" } }, "type": "object" }, + "PermissionProfileToml": { + "additionalProperties": false, + "properties": { + "filesystem": { + "$ref": "#/definitions/FilesystemPermissionsToml" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkToml" + } + }, + "type": "object" + }, + "PermissionsToml": { + "type": "object" + }, "Personality": { "enum": [ "none", @@ -1663,6 +1697,10 @@ "description": "Compact prompt used for history compaction.", "type": "string" }, + "default_permissions": { + "description": "Default named permissions profile to apply from the `[permissions]` table.", + "type": "string" + }, "developer_instructions": { "default": null, "description": "Developer instructions inserted as a `developer` role message.", @@ -2025,6 +2063,15 @@ ], "description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`)." }, + "network": { + "allOf": [ + { + "$ref": "#/definitions/NetworkToml" + } + ], + "default": null, + "description": "Top-level network proxy settings." + }, "notice": { "allOf": [ { @@ -2060,7 +2107,7 @@ } ], "default": null, - "description": "Nested permissions settings." + "description": "Named permissions profiles." }, "personality": { "allOf": [ diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2d833d8b51..56cc632636 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -27,6 +27,7 @@ use crate::config::types::WindowsSandboxModeToml; use crate::config::types::WindowsToml; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConstrainedWithSource; use crate::config_loader::LoaderOverrides; @@ -49,6 +50,10 @@ use crate::model_provider_info::built_in_model_providers; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; +#[cfg(test)] +use crate::protocol::FileSystemAccessMode; +use crate::protocol::FileSystemSandboxPolicy; +use crate::protocol::NetworkSandboxPolicy; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS; @@ -86,7 +91,8 @@ use std::path::PathBuf; #[cfg(test)] use tempfile::tempdir; -use crate::config::permissions::network_proxy_config_from_permissions; +use crate::config::permissions::compile_permission_profile; +use crate::config::permissions::network_proxy_config_from_network; use crate::config::profile::ConfigProfile; use toml::Value as TomlValue; use toml_edit::DocumentMut; @@ -107,7 +113,11 @@ pub use codex_network_proxy::NetworkProxyAuditMetadata; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; +pub use permissions::FilesystemPermissionToml; +pub use permissions::FilesystemPermissionsToml; pub use permissions::NetworkToml; +pub use permissions::PermissionProfileNetworkToml; +pub use permissions::PermissionProfileToml; pub use permissions::PermissionsToml; pub use service::ConfigService; pub use service::ConfigServiceError; @@ -155,6 +165,12 @@ pub struct Permissions { pub approval_policy: Constrained, /// Effective sandbox policy used for shell/unified exec. pub sandbox_policy: Constrained, + /// Effective filesystem sandbox policy, including entries that cannot yet + /// be fully represented by the legacy [`SandboxPolicy`] projection. + pub file_system_sandbox_policy: FileSystemSandboxPolicy, + /// Effective network sandbox policy split out from the legacy + /// [`SandboxPolicy`] projection. + pub network_sandbox_policy: NetworkSandboxPolicy, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -1047,10 +1063,18 @@ pub struct ConfigToml { /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. pub sandbox_workspace_write: Option, - /// Nested permissions settings. + /// Default named permissions profile to apply from the `[permissions]` + /// table. + pub default_permissions: Option, + + /// Named permissions profiles. #[serde(default)] pub permissions: Option, + /// Top-level network proxy settings. + #[serde(default)] + pub network: Option, + /// Optional external command to spawn for end-user notifications. #[serde(default)] pub notify: Option>, @@ -1565,6 +1589,78 @@ impl ConfigToml { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PermissionConfigSyntax { + Legacy, + Profiles, +} + +#[derive(Debug, Deserialize, Default)] +struct PermissionSelectionToml { + default_permissions: Option, + sandbox_mode: Option, +} + +fn resolve_permission_config_syntax( + config_layer_stack: &ConfigLayerStack, + cfg: &ConfigToml, + sandbox_mode_override: Option, + profile_sandbox_mode: Option, +) -> Option { + if sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() { + return Some(PermissionConfigSyntax::Legacy); + } + + let mut selection = None; + for layer in + config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) + { + let Ok(layer_selection) = layer.config.clone().try_into::() else { + continue; + }; + + if layer_selection.sandbox_mode.is_some() { + selection = Some(PermissionConfigSyntax::Legacy); + } + if layer_selection.default_permissions.is_some() { + selection = Some(PermissionConfigSyntax::Profiles); + } + } + + selection.or_else(|| { + if cfg.default_permissions.is_some() { + Some(PermissionConfigSyntax::Profiles) + } else if cfg.sandbox_mode.is_some() { + Some(PermissionConfigSyntax::Legacy) + } else { + None + } + }) +} + +fn add_additional_file_system_writes( + file_system_sandbox_policy: &mut FileSystemSandboxPolicy, + additional_writable_roots: &[AbsolutePathBuf], +) { + for path in additional_writable_roots { + let exists = file_system_sandbox_policy.entries.iter().any(|entry| { + matches!( + &entry.path, + crate::protocol::FileSystemPath::Path { path: existing } + if existing == path && entry.access == crate::protocol::FileSystemAccessMode::Write + ) + }); + if !exists { + file_system_sandbox_policy + .entries + .push(crate::protocol::FileSystemSandboxEntry { + path: crate::protocol::FileSystemPath::Path { path: path.clone() }, + access: crate::protocol::FileSystemAccessMode::Write, + }); + } + } +} + /// Optional overrides for user configuration (e.g., from CLI flags). #[derive(Default, Debug, Clone)] pub struct ConfigOverrides { @@ -1753,7 +1849,7 @@ impl Config { None => ConfigProfile::default(), }; let configured_network_proxy_config = - network_proxy_config_from_permissions(cfg.permissions.as_ref()); + network_proxy_config_from_network(cfg.network.as_ref()); let feature_overrides = FeatureOverrides { include_apply_patch_tool: include_apply_patch_tool_override, @@ -1788,29 +1884,112 @@ impl Config { let active_project = cfg .get_active_project(&resolved_cwd) .unwrap_or(ProjectConfig { trust_level: None }); + let permission_config_syntax = resolve_permission_config_syntax( + &config_layer_stack, + &cfg, + sandbox_mode, + config_profile.sandbox_mode, + ); + let has_permission_profiles = cfg + .permissions + .as_ref() + .is_some_and(|profiles| !profiles.is_empty()); + let legacy_permissions_network_table = + cfg.permissions.as_ref().is_some_and(|permissions| { + permissions.entries.get("network").is_some_and(|profile| { + profile.filesystem.is_none() && profile.network.is_none() + }) + }); + if legacy_permissions_network_table { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "legacy `[permissions.network]` is no longer supported; move this config to the top-level `[network]` table", + )); + } + if has_permission_profiles + && !matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Legacy) + ) + && cfg.default_permissions.is_none() + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "config defines `[permissions]` profiles but does not set `default_permissions`", + )); + } let sandbox_mode_was_explicit = sandbox_mode.is_some() || config_profile.sandbox_mode.is_some() - || cfg.sandbox_mode.is_some(); + || cfg.sandbox_mode.is_some() + || matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Profiles) + ); let windows_sandbox_level = match windows_sandbox_mode { Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated, Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken, None => WindowsSandboxLevel::from_features(&features), }; - let mut sandbox_policy = cfg.derive_sandbox_policy( - sandbox_mode, - config_profile.sandbox_mode, - windows_sandbox_level, - &resolved_cwd, - Some(&constrained_sandbox_policy), - ); - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { - for path in additional_writable_roots { - if !writable_roots.iter().any(|existing| existing == &path) { - writable_roots.push(path); + let profiles_are_active = matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Profiles) + ) || (permission_config_syntax.is_none() + && has_permission_profiles); + let (sandbox_policy, file_system_sandbox_policy, network_sandbox_policy) = + 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 (mut file_system_sandbox_policy, network_sandbox_policy) = + compile_permission_profile(permissions, default_permissions)?; + let mut sandbox_policy = file_system_sandbox_policy + .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; + if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { + add_additional_file_system_writes( + &mut file_system_sandbox_policy, + &additional_writable_roots, + ); + sandbox_policy = file_system_sandbox_policy + .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; } - } - } + ( + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + } else { + let mut sandbox_policy = cfg.derive_sandbox_policy( + sandbox_mode, + config_profile.sandbox_mode, + windows_sandbox_level, + &resolved_cwd, + Some(&constrained_sandbox_policy), + ); + if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { + for path in &additional_writable_roots { + if !writable_roots.iter().any(|existing| existing == path) { + writable_roots.push(path.clone()); + } + } + } + let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); + ( + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + }; let approval_policy_was_explicit = approval_policy_override.is_some() || config_profile.approval_policy.is_some() || cfg.approval_policy.is_some(); @@ -2078,6 +2257,7 @@ impl Config { .map(AbsolutePathBuf::to_path_buf) .or_else(|| resolve_sqlite_home_env(&resolved_cwd)) .unwrap_or_else(|| codex_home.to_path_buf()); + let original_sandbox_policy = sandbox_policy.clone(); apply_requirement_constrained_value( "approval_policy", @@ -2125,6 +2305,19 @@ impl Config { } else { network.enabled().then_some(network) }; + let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone(); + let effective_file_system_sandbox_policy = + if effective_sandbox_policy == original_sandbox_policy { + file_system_sandbox_policy + } else { + FileSystemSandboxPolicy::from(&effective_sandbox_policy) + }; + let effective_network_sandbox_policy = + if effective_sandbox_policy == original_sandbox_policy { + network_sandbox_policy + } else { + NetworkSandboxPolicy::from(&effective_sandbox_policy) + }; let config = Self { model, @@ -2139,6 +2332,8 @@ impl Config { permissions: Permissions { approval_policy: constrained_approval_policy.value, sandbox_policy: constrained_sandbox_policy.value, + file_system_sandbox_policy: effective_file_system_sandbox_policy, + network_sandbox_policy: effective_network_sandbox_policy, network, allow_login_shell, shell_environment_policy, @@ -2701,20 +2896,18 @@ consolidation_model = "gpt-5" #[test] fn config_toml_deserializes_permissions_network() { let toml = r#" -[permissions.network] +[network] enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false allowed_domains = ["openai.com"] "#; - let cfg: ConfigToml = toml::from_str(toml) - .expect("TOML deserialization should succeed for permissions.network"); + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for [network]"); assert_eq!( - cfg.permissions - .and_then(|permissions| permissions.network) - .expect("permissions.network should deserialize"), + cfg.network.expect("[network] should deserialize"), NetworkToml { enabled: Some(true), proxy_url: Some("http://127.0.0.1:43128".to_string()), @@ -2739,13 +2932,11 @@ allowed_domains = ["openai.com"] fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { - permissions: Some(PermissionsToml { - network: Some(NetworkToml { - enabled: Some(true), - proxy_url: Some("http://127.0.0.1:43128".to_string()), - enable_socks5: Some(false), - ..Default::default() - }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() }), ..Default::default() }; @@ -2770,11 +2961,9 @@ allowed_domains = ["openai.com"] fn permissions_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { - permissions: Some(PermissionsToml { - network: Some(NetworkToml { - allowed_domains: Some(vec!["openai.com".to_string()]), - ..Default::default() - }), + network: Some(NetworkToml { + allowed_domains: Some(vec!["openai.com".to_string()]), + ..Default::default() }), ..Default::default() }; @@ -2788,6 +2977,349 @@ allowed_domains = ["openai.com"] Ok(()) } + #[test] + fn config_toml_deserializes_permission_profiles() { + let toml = r#" +default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.filesystem.":project_roots"] +"." = "write" +"docs" = "read" +"#; + let cfg: ConfigToml = toml::from_str(toml) + .expect("TOML deserialization should succeed for permissions profiles"); + + assert_eq!(cfg.default_permissions.as_deref(), Some("workspace")); + assert_eq!( + cfg.permissions.expect("[permissions] should deserialize"), + PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([ + ( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + ), + ( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + (".".to_string(), FileSystemAccessMode::Write), + ("docs".to_string(), FileSystemAccessMode::Read), + ])), + ), + ]), + }), + network: None, + }, + )]), + } + ); + } + + #[test] + fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::create_dir_all(cwd.path().join("docs"))?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let cfg = ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([ + ( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + ), + ( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + (".".to_string(), FileSystemAccessMode::Write), + ("docs".to_string(), FileSystemAccessMode::Read), + ])), + ), + ]), + }), + network: None, + }, + )]), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.permissions.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![ + crate::protocol::FileSystemSandboxEntry { + path: crate::protocol::FileSystemPath::Special { + value: crate::protocol::FileSystemSpecialPath { + kind: crate::protocol::FileSystemSpecialPathKind::Minimal, + subpath: None, + }, + }, + access: FileSystemAccessMode::Read, + }, + crate::protocol::FileSystemSandboxEntry { + path: crate::protocol::FileSystemPath::Special { + value: crate::protocol::FileSystemSpecialPath { + kind: crate::protocol::FileSystemSpecialPathKind::ProjectRoots, + subpath: None, + }, + }, + access: FileSystemAccessMode::Write, + }, + crate::protocol::FileSystemSandboxEntry { + path: crate::protocol::FileSystemPath::Special { + value: crate::protocol::FileSystemSpecialPath { + kind: crate::protocol::FileSystemSpecialPathKind::ProjectRoots, + subpath: Some("docs".into()), + }, + }, + access: FileSystemAccessMode::Read, + }, + ]), + ); + assert_eq!( + config.permissions.sandbox_policy.get(), + &SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from(cwd.path().join("docs")) + .expect("absolute docs path"), + ], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); + assert_eq!( + config.permissions.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); + Ok(()) + } + + #[test] + fn permissions_profiles_require_default_permissions() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: None, + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("missing default_permissions should be rejected"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "config defines `[permissions]` profiles but does not set `default_permissions`" + ); + Ok(()) + } + + #[test] + fn legacy_permissions_network_requires_migration_to_top_level_network() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let cfg: ConfigToml = toml::from_str( + r#" +[permissions.network] +enabled = true +proxy_url = "http://127.0.0.1:43128" +"#, + ) + .expect("legacy permissions.network TOML should deserialize"); + + let err = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("legacy permissions.network should require migration"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "legacy `[permissions.network]` is no longer supported; move this config to the top-level `[network]` table" + ); + Ok(()) + } + + #[test] + fn legacy_permissions_network_requires_migration_even_with_legacy_sandbox_mode() + -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let cfg: ConfigToml = toml::from_str( + r#" +sandbox_mode = "read-only" + +[permissions.network] +enabled = true +proxy_url = "http://127.0.0.1:43128" +"#, + ) + .expect("legacy permissions.network TOML should deserialize"); + + let err = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("legacy permissions.network should require migration"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "legacy `[permissions.network]` is no longer supported; move this config to the top-level `[network]` table" + ); + Ok(()) + } + + #[test] + fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + let external_write_path = if cfg!(windows) { r"C:\temp" } else { "/tmp" }; + + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + external_write_path.to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + )]), + }), + network: None, + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("writes outside the workspace root should be rejected"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("filesystem writes outside the workspace root"), + "{err}" + ); + Ok(()) + } + + #[test] + fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(PermissionProfileNetworkToml { + enabled: Some(true), + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + assert!( + config.permissions.network_sandbox_policy.is_enabled(), + "expected network sandbox policy to be enabled", + ); + assert!( + config + .permissions + .sandbox_policy + .get() + .has_full_network_access() + ); + Ok(()) + } + #[test] fn tui_theme_deserializes_from_toml() { let cfg = r#" @@ -5129,6 +5661,10 @@ model_verbosity = "high" permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -5259,6 +5795,10 @@ model_verbosity = "high" permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -5387,6 +5927,10 @@ model_verbosity = "high" permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -5501,6 +6045,10 @@ model_verbosity = "high" permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index dd242bd857..46fa0536eb 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,15 +1,63 @@ +use std::collections::BTreeMap; +use std::io; +use std::path::Path; + use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; +use codex_protocol::protocol::FileSystemAccessMode; +use codex_protocol::protocol::FileSystemPath; +use codex_protocol::protocol::FileSystemSandboxEntry; +use codex_protocol::protocol::FileSystemSandboxPolicy; +use codex_protocol::protocol::FileSystemSpecialPath; +use codex_protocol::protocol::FileSystemSpecialPathKind; +use codex_protocol::protocol::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] pub struct PermissionsToml { - /// Network proxy settings from `[permissions.network]`. - /// User config can enable the proxy; managed requirements may still constrain values. - pub network: Option, + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl PermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PermissionProfileToml { + pub filesystem: Option, + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct FilesystemPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl FilesystemPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum FilesystemPermissionToml { + Access(FileSystemAccessMode), + Scoped(BTreeMap), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PermissionProfileNetworkToml { + pub enabled: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] @@ -102,13 +150,161 @@ impl NetworkToml { } } -pub(crate) fn network_proxy_config_from_permissions( - permissions: Option<&PermissionsToml>, +pub(crate) fn network_proxy_config_from_network( + network: Option<&NetworkToml>, ) -> NetworkProxyConfig { - permissions - .and_then(|permissions| permissions.network.as_ref()) - .map_or_else( - NetworkProxyConfig::default, - NetworkToml::to_network_proxy_config, - ) + network.map_or_else( + NetworkProxyConfig::default, + NetworkToml::to_network_proxy_config, + ) +} + +pub(crate) fn compile_permission_profile( + permissions: &PermissionsToml, + profile_name: &str, +) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { + let profile = permissions.entries.get(profile_name).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("default_permissions refers to undefined profile `{profile_name}`"), + ) + })?; + + let filesystem = profile.filesystem.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "permissions profile `{profile_name}` must define a `[permissions.{profile_name}.filesystem]` table" + ), + ) + })?; + + if filesystem.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "permissions profile `{profile_name}` must define at least one filesystem entry" + ), + )); + } + + let mut entries = Vec::new(); + for (path, permission) in &filesystem.entries { + compile_filesystem_permission(path, permission, &mut entries)?; + } + + let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref()); + + Ok(( + FileSystemSandboxPolicy::restricted(entries), + network_sandbox_policy, + )) +} + +fn compile_network_sandbox_policy( + network: Option<&PermissionProfileNetworkToml>, +) -> NetworkSandboxPolicy { + let Some(network) = network else { + return NetworkSandboxPolicy::Restricted; + }; + + match network.enabled { + Some(true) => NetworkSandboxPolicy::Enabled, + _ => NetworkSandboxPolicy::Restricted, + } +} + +fn compile_filesystem_permission( + path: &str, + permission: &FilesystemPermissionToml, + entries: &mut Vec, +) -> io::Result<()> { + match permission { + FilesystemPermissionToml::Access(access) => entries.push(FileSystemSandboxEntry { + path: compile_filesystem_path(path)?, + access: *access, + }), + FilesystemPermissionToml::Scoped(scoped_entries) => { + for (subpath, access) in scoped_entries { + entries.push(FileSystemSandboxEntry { + path: compile_scoped_filesystem_path(path, subpath)?, + access: *access, + }); + } + } + } + Ok(()) +} + +fn compile_filesystem_path(path: &str) -> io::Result { + if let Some(special) = parse_special_path(path)? { + return Ok(FileSystemPath::Special { value: special }); + } + + let path = parse_absolute_path(path)?; + Ok(FileSystemPath::Path { path }) +} + +fn compile_scoped_filesystem_path(path: &str, subpath: &str) -> io::Result { + if subpath == "." { + return compile_filesystem_path(path); + } + + let subpath = Path::new(subpath); + if subpath.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "filesystem subpath `{}` must be relative", + subpath.display() + ), + )); + } + + if let Some(mut special) = parse_special_path(path)? { + if !matches!(special.kind, FileSystemSpecialPathKind::ProjectRoots) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("filesystem path `{path}` does not support nested entries"), + )); + } + special.subpath = Some(subpath.to_path_buf()); + return Ok(FileSystemPath::Special { value: special }); + } + + let base = parse_absolute_path(path)?; + let path = AbsolutePathBuf::resolve_path_against_base(subpath, base.as_path())?; + Ok(FileSystemPath::Path { path }) +} + +fn parse_special_path(path: &str) -> io::Result> { + let kind = match path { + ":root" => Some(FileSystemSpecialPathKind::Root), + ":minimal" => Some(FileSystemSpecialPathKind::Minimal), + ":project_roots" => Some(FileSystemSpecialPathKind::ProjectRoots), + ":tmpdir" => Some(FileSystemSpecialPathKind::Tmpdir), + _ if path.starts_with(':') => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unknown filesystem special path `{path}`"), + )); + } + _ => None, + }; + + Ok(kind.map(|kind| FileSystemSpecialPath { + kind, + subpath: None, + })) +} + +fn parse_absolute_path(path: &str) -> io::Result { + let path_ref = Path::new(path); + if !path_ref.is_absolute() && path != "~" && !path.starts_with("~/") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("filesystem path `{path}` must be absolute, use `~/...`, or start with `:`"), + )); + } + AbsolutePathBuf::from_absolute_path(path_ref) } diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index ce865756ee..887be0ffe3 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,5 +1,4 @@ use crate::config::NetworkToml; -use crate::config::PermissionsToml; use crate::config::find_codex_home; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; @@ -121,12 +120,6 @@ fn network_constraints_from_trusted_layers( if let Some(network) = parsed.network { apply_network_constraints(network, &mut constraints); } - if let Some(network) = parsed - .permissions - .and_then(|permissions| permissions.network) - { - apply_network_constraints(network, &mut constraints); - } } Ok(constraints) } @@ -171,7 +164,6 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx #[derive(Debug, Clone, Default, Deserialize)] struct NetworkTablesToml { network: Option, - permissions: Option, } fn network_tables_from_toml(value: &toml::Value) -> Result { @@ -185,12 +177,6 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo if let Some(network) = parsed.network { network.apply_to_network_proxy_config(config); } - if let Some(network) = parsed - .permissions - .and_then(|permissions| permissions.network) - { - network.apply_to_network_proxy_config(config); - } } fn config_from_layers( @@ -315,10 +301,10 @@ mod tests { use pretty_assertions::assert_eq; #[test] - fn higher_precedence_network_table_beats_lower_permissions_network_table() { - let lower_permissions: toml::Value = toml::from_str( + fn higher_precedence_network_table_beats_lower_network_table() { + let lower_network: toml::Value = toml::from_str( r#" -[permissions.network] +[network] allowed_domains = ["lower.example.com"] "#, ) @@ -334,7 +320,7 @@ allowed_domains = ["higher.example.com"] let mut config = NetworkProxyConfig::default(); apply_network_tables( &mut config, - network_tables_from_toml(&lower_permissions).expect("lower layer should deserialize"), + network_tables_from_toml(&lower_network).expect("lower layer should deserialize"), ); apply_network_tables( &mut config, diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs index 53b1f7bd93..33af369bec 100644 --- a/codex-rs/core/src/skills/permissions.rs +++ b/codex-rs/core/src/skills/permissions.rs @@ -12,6 +12,10 @@ use codex_protocol::models::MacOsSeatbeltProfileExtensions; #[cfg(any(unix, test))] use codex_protocol::models::PermissionProfile; #[cfg(any(unix, test))] +use codex_protocol::protocol::FileSystemSandboxPolicy; +#[cfg(any(unix, test))] +use codex_protocol::protocol::NetworkSandboxPolicy; +#[cfg(any(unix, test))] use codex_utils_absolute_path::AbsolutePathBuf; #[cfg(any(unix, test))] use dunce::canonicalize as canonicalize_path; @@ -89,10 +93,14 @@ pub(crate) fn compile_permission_profile( let macos_permissions = macos.unwrap_or_default(); let macos_seatbelt_profile_extensions = build_macos_seatbelt_profile_extensions(&macos_permissions); + let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); Some(Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(sandbox_policy), + file_system_sandbox_policy, + network_sandbox_policy, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -234,6 +242,8 @@ mod tests { use codex_protocol::models::MacOsPreferencesValue; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; + use codex_protocol::protocol::FileSystemSandboxPolicy; + use codex_protocol::protocol::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; @@ -243,6 +253,25 @@ mod tests { AbsolutePathBuf::try_from(path).expect("absolute path") } + fn expected_permissions(sandbox_policy: SandboxPolicy) -> Permissions { + Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(sandbox_policy.clone()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), + network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), + ), + #[cfg(not(target_os = "macos"))] + macos_seatbelt_profile_extensions: None, + } + } + #[test] fn compile_permission_profile_normalizes_paths() { let tempdir = tempfile::tempdir().expect("tempdir"); @@ -269,37 +298,24 @@ mod tests { assert_eq!( profile, - Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - sandbox_policy: Constrained::allow_any(SandboxPolicy::WorkspaceWrite { - writable_roots: vec![ - AbsolutePathBuf::try_from(skill_dir.join("output")) - .expect("absolute output path") + expected_permissions(SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::try_from(skill_dir.join("output")) + .expect("absolute output path") + ], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from( + dunce::canonicalize(&read_dir).unwrap_or(read_dir) + ) + .expect("absolute read path") ], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![ - AbsolutePathBuf::try_from( - dunce::canonicalize(&read_dir).unwrap_or(read_dir) - ) - .expect("absolute read path") - ], - }, - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: Some( - crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), - ), - #[cfg(not(target_os = "macos"))] - macos_seatbelt_profile_extensions: None, - } + }, + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }) ); } @@ -330,23 +346,10 @@ mod tests { assert_eq!( profile, - Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: true, - }), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: Some( - crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), - ), - #[cfg(not(target_os = "macos"))] - macos_seatbelt_profile_extensions: None, - } + expected_permissions(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: true, + }) ); } @@ -371,31 +374,18 @@ mod tests { assert_eq!( profile, - Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![ - AbsolutePathBuf::try_from( - dunce::canonicalize(&read_dir).unwrap_or(read_dir) - ) - .expect("absolute read path") - ], - }, - network_access: true, - }), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: Some( - crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), - ), - #[cfg(not(target_os = "macos"))] - macos_seatbelt_profile_extensions: None, - } + expected_permissions(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from( + dunce::canonicalize(&read_dir).unwrap_or(read_dir) + ) + .expect("absolute read path") + ], + }, + network_access: true, + }) ); } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index b88f5b0542..329d0d3bb9 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -30,6 +30,10 @@ use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::MacOsPreferencesPermission; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::PermissionProfile; +#[cfg(target_os = "macos")] +use codex_protocol::protocol::FileSystemSandboxPolicy; +#[cfg(target_os = "macos")] +use codex_protocol::protocol::NetworkSandboxPolicy; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; @@ -481,6 +485,10 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() let permissions = Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 9ca159965f..ab9e4ce35f 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2234,7 +2234,7 @@ async fn denying_network_policy_amendment_persists_policy_and_skips_future_netwo let home = Arc::new(TempDir::new()?); fs::write( home.path().join("config.toml"), - r#"[permissions.network] + r#"[network] enabled = true mode = "limited" allow_local_binding = true diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 55f19255b0..ecb280651a 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::io; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; @@ -537,6 +538,279 @@ impl NetworkAccess { } } +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum NetworkSandboxPolicy { + #[default] + Restricted, + Enabled, +} + +impl NetworkSandboxPolicy { + pub fn is_enabled(self) -> bool { + matches!(self, NetworkSandboxPolicy::Enabled) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum FileSystemAccessMode { + None, + Read, + Write, +} + +impl FileSystemAccessMode { + pub fn can_read(self) -> bool { + !matches!(self, FileSystemAccessMode::None) + } + + pub fn can_write(self) -> bool { + matches!(self, FileSystemAccessMode::Write) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum FileSystemSpecialPathKind { + Root, + Minimal, + CurrentWorkingDirectory, + ProjectRoots, + Tmpdir, + SlashTmp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub struct FileSystemSpecialPath { + pub kind: FileSystemSpecialPathKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub subpath: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +pub enum FileSystemPath { + Path { path: AbsolutePathBuf }, + Special { value: FileSystemSpecialPath }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct FileSystemSandboxEntry { + pub path: FileSystemPath, + pub access: FileSystemAccessMode, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum FileSystemSandboxKind { + #[default] + Restricted, + Unrestricted, + ExternalSandbox, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct FileSystemSandboxPolicy { + pub kind: FileSystemSandboxKind, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entries: Vec, +} + +impl Default for FileSystemSandboxPolicy { + fn default() -> Self { + Self { + kind: FileSystemSandboxKind::Restricted, + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath { + kind: FileSystemSpecialPathKind::Root, + subpath: None, + }, + }, + access: FileSystemAccessMode::Read, + }], + } + } +} + +impl FileSystemSandboxPolicy { + pub fn unrestricted() -> Self { + Self { + kind: FileSystemSandboxKind::Unrestricted, + entries: Vec::new(), + } + } + + pub fn external_sandbox() -> Self { + Self { + kind: FileSystemSandboxKind::ExternalSandbox, + entries: Vec::new(), + } + } + + pub fn restricted(entries: Vec) -> Self { + Self { + kind: FileSystemSandboxKind::Restricted, + entries, + } + } + + pub fn to_legacy_sandbox_policy( + &self, + network_policy: NetworkSandboxPolicy, + cwd: &Path, + ) -> io::Result { + Ok(match self.kind { + FileSystemSandboxKind::ExternalSandbox => SandboxPolicy::ExternalSandbox { + network_access: if network_policy.is_enabled() { + NetworkAccess::Enabled + } else { + NetworkAccess::Restricted + }, + }, + FileSystemSandboxKind::Unrestricted => { + if network_policy.is_enabled() { + SandboxPolicy::DangerFullAccess + } else { + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + } + } + } + FileSystemSandboxKind::Restricted => { + let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); + let mut include_platform_defaults = false; + let mut has_full_disk_read_access = false; + let mut has_full_disk_write_access = false; + let mut workspace_root_writable = false; + let mut writable_roots = Vec::new(); + let mut readable_roots = Vec::new(); + let mut tmpdir_writable = false; + let mut slash_tmp_writable = false; + + for entry in &self.entries { + match &entry.path { + FileSystemPath::Path { path } => { + if entry.access.can_write() { + if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) { + workspace_root_writable = true; + } else { + writable_roots.push(path.clone()); + } + } else if entry.access.can_read() { + readable_roots.push(path.clone()); + } + } + FileSystemPath::Special { value } => match value.kind { + FileSystemSpecialPathKind::Root => match entry.access { + FileSystemAccessMode::None => {} + FileSystemAccessMode::Read => has_full_disk_read_access = true, + FileSystemAccessMode::Write => { + has_full_disk_read_access = true; + has_full_disk_write_access = true; + } + }, + FileSystemSpecialPathKind::Minimal => { + if entry.access.can_read() { + include_platform_defaults = true; + } + } + FileSystemSpecialPathKind::CurrentWorkingDirectory + | FileSystemSpecialPathKind::ProjectRoots => { + if value.subpath.is_none() && entry.access.can_write() { + workspace_root_writable = true; + } else if let Some(path) = + resolve_file_system_special_path(value, cwd_absolute.as_ref()) + { + if entry.access.can_write() { + writable_roots.push(path); + } else if entry.access.can_read() { + readable_roots.push(path); + } + } + } + FileSystemSpecialPathKind::Tmpdir => { + if entry.access.can_write() { + tmpdir_writable = true; + } else if entry.access.can_read() + && let Some(path) = resolve_file_system_special_path( + value, + cwd_absolute.as_ref(), + ) + { + readable_roots.push(path); + } + } + FileSystemSpecialPathKind::SlashTmp => { + if entry.access.can_write() { + slash_tmp_writable = true; + } else if entry.access.can_read() + && let Some(path) = resolve_file_system_special_path( + value, + cwd_absolute.as_ref(), + ) + { + readable_roots.push(path); + } + } + }, + } + } + + if has_full_disk_write_access { + return Ok(if network_policy.is_enabled() { + SandboxPolicy::DangerFullAccess + } else { + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + } + }); + } + + let read_only_access = if has_full_disk_read_access { + ReadOnlyAccess::FullAccess + } else { + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots: dedup_absolute_paths(readable_roots), + } + }; + + if workspace_root_writable { + SandboxPolicy::WorkspaceWrite { + writable_roots: dedup_absolute_paths(writable_roots), + read_only_access, + network_access: network_policy.is_enabled(), + exclude_tmpdir_env_var: !tmpdir_writable, + exclude_slash_tmp: !slash_tmp_writable, + } + } else if !writable_roots.is_empty() || tmpdir_writable || slash_tmp_writable { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly", + )); + } else { + SandboxPolicy::ReadOnly { + access: read_only_access, + network_access: network_policy.is_enabled(), + } + } + } + }) + } +} + fn default_include_platform_defaults() -> bool { true } @@ -923,6 +1197,212 @@ impl SandboxPolicy { } } +impl From<&SandboxPolicy> for NetworkSandboxPolicy { + fn from(value: &SandboxPolicy) -> Self { + if value.has_full_network_access() { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + } + } +} + +impl From<&SandboxPolicy> for FileSystemSandboxPolicy { + fn from(value: &SandboxPolicy) -> Self { + match value { + SandboxPolicy::DangerFullAccess => FileSystemSandboxPolicy::unrestricted(), + SandboxPolicy::ExternalSandbox { .. } => FileSystemSandboxPolicy::external_sandbox(), + SandboxPolicy::ReadOnly { access, .. } => { + let mut entries = Vec::new(); + match access { + ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath { + kind: FileSystemSpecialPathKind::Root, + subpath: None, + }, + }, + access: FileSystemAccessMode::Read, + }), + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath { + kind: FileSystemSpecialPathKind::CurrentWorkingDirectory, + subpath: None, + }, + }, + access: FileSystemAccessMode::Read, + }); + if *include_platform_defaults { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath { + kind: FileSystemSpecialPathKind::Minimal, + subpath: None, + }, + }, + access: FileSystemAccessMode::Read, + }); + } + entries.extend(readable_roots.iter().cloned().map(|path| { + FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Read, + } + })); + } + } + FileSystemSandboxPolicy::restricted(entries) + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + read_only_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + .. + } => { + let mut entries = Vec::new(); + match read_only_access { + ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath { + kind: FileSystemSpecialPathKind::Root, + subpath: None, + }, + }, + access: FileSystemAccessMode::Read, + }), + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => { + if *include_platform_defaults { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath { + kind: FileSystemSpecialPathKind::Minimal, + subpath: None, + }, + }, + access: FileSystemAccessMode::Read, + }); + } + entries.extend(readable_roots.iter().cloned().map(|path| { + FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Read, + } + })); + } + } + + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath { + kind: FileSystemSpecialPathKind::CurrentWorkingDirectory, + subpath: None, + }, + }, + access: FileSystemAccessMode::Write, + }); + if !exclude_slash_tmp { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath { + kind: FileSystemSpecialPathKind::SlashTmp, + subpath: None, + }, + }, + access: FileSystemAccessMode::Write, + }); + } + if !exclude_tmpdir_env_var { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath { + kind: FileSystemSpecialPathKind::Tmpdir, + subpath: None, + }, + }, + access: FileSystemAccessMode::Write, + }); + } + entries.extend( + writable_roots + .iter() + .cloned() + .map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Write, + }), + ); + FileSystemSandboxPolicy::restricted(entries) + } + } + } +} + +fn resolve_file_system_special_path( + value: &FileSystemSpecialPath, + cwd: Option<&AbsolutePathBuf>, +) -> Option { + match value.kind { + FileSystemSpecialPathKind::Root | FileSystemSpecialPathKind::Minimal => None, + FileSystemSpecialPathKind::CurrentWorkingDirectory + | FileSystemSpecialPathKind::ProjectRoots => { + let cwd = cwd?; + match value.subpath.as_ref() { + Some(subpath) => { + AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path()).ok() + } + None => Some(cwd.clone()), + } + } + FileSystemSpecialPathKind::Tmpdir => { + let tmpdir = std::env::var_os("TMPDIR")?; + if tmpdir.is_empty() { + None + } else { + let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?; + match value.subpath.as_ref() { + Some(subpath) => { + AbsolutePathBuf::resolve_path_against_base(subpath, tmpdir.as_path()).ok() + } + None => Some(tmpdir), + } + } + } + FileSystemSpecialPathKind::SlashTmp => { + #[allow(clippy::expect_used)] + let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute"); + if !slash_tmp.as_path().is_dir() { + return None; + } + match value.subpath.as_ref() { + Some(subpath) => { + AbsolutePathBuf::resolve_path_against_base(subpath, slash_tmp.as_path()).ok() + } + None => Some(slash_tmp), + } + } + } +} + +fn dedup_absolute_paths(paths: Vec) -> Vec { + let mut deduped = Vec::with_capacity(paths.len()); + let mut seen = HashSet::new(); + for path in paths { + if seen.insert(path.to_path_buf()) { + deduped.push(path); + } + } + deduped +} + fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool { path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git")) } @@ -3201,6 +3681,36 @@ mod tests { } } + #[test] + fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() { + let cwd = if cfg!(windows) { + Path::new(r"C:\workspace") + } else { + Path::new("/tmp/workspace") + }; + let external_write_path = if cfg!(windows) { + AbsolutePathBuf::from_absolute_path(r"C:\temp").expect("absolute windows temp path") + } else { + AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute tmp path") + }; + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: external_write_path, + }, + access: FileSystemAccessMode::Write, + }]); + + let err = policy + .to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd) + .expect_err("non-workspace writes should be rejected"); + + assert!( + err.to_string() + .contains("filesystem writes outside the workspace root"), + "{err}" + ); + } + #[test] fn item_started_event_from_web_search_emits_begin_event() { let event = ItemStartedEvent {