From f82678b2a466b3e6e30b8f1eb5ca2dbae528daa7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 6 Mar 2026 15:39:13 -0800 Subject: [PATCH] config: add initial support for the new permission profile config language in config.toml (#13434) ## Why `SandboxPolicy` currently mixes together three separate concerns: - parsing layered config from `config.toml` - representing filesystem sandbox state - carrying basic network policy alongside filesystem choices That makes the existing config awkward to extend and blocks the new TOML proposal where `[permissions]` becomes a table of named permission profiles selected by `default_permissions`. (The idea is that if `default_permissions` is not specified, we assume the user is opting into the "traditional" way to configure the sandbox.) This PR adds the config-side plumbing for those profiles while still projecting back to the legacy `SandboxPolicy` shape that the current macOS and Linux sandbox backends consume. It also tightens the filesystem profile model so scoped entries only exist for `:project_roots`, and so nested keys must stay within a project root instead of using `.` or `..` traversal. This drops support for the short-lived `[permissions.network]` in `config.toml` because now that would be interpreted as a profile named `network` within `[permissions]`. ## What Changed - added `PermissionsToml`, `PermissionProfileToml`, `FilesystemPermissionsToml`, and `FilesystemPermissionToml` so config can parse named profiles under `[permissions..filesystem]` - added top-level `default_permissions` selection, validation for missing or unknown profiles, and compilation from a named profile into split `FileSystemSandboxPolicy` and `NetworkSandboxPolicy` values - taught config loading to choose between the legacy `sandbox_mode` path and the profile-based path without breaking legacy users - introduced `codex-protocol::permissions` for the split filesystem and network sandbox types, and stored those alongside the legacy projected `sandbox_policy` in runtime `Permissions` - modeled `FileSystemSpecialPath` so only `ProjectRoots` can carry a nested `subpath`, matching the intended config syntax instead of allowing invalid states for other special paths - restricted scoped filesystem maps to `:project_roots`, with validation that nested entries are non-empty descendant paths and cannot use `.` or `..` to escape the project root - kept existing runtime consumers working by projecting `FileSystemSandboxPolicy` back into `SandboxPolicy`, with an explicit error for profiles that request writes outside the workspace root - loaded proxy settings from top-level `[network]` - regenerated `core/config.schema.json` ## Verification - added config coverage for profile deserialization, `default_permissions` selection, top-level `[network]` loading, network enablement, rejection of writes outside the workspace root, rejection of nested entries for non-`:project_roots` special paths, and rejection of parent-directory traversal in `:project_roots` maps - added protocol coverage for the legacy bridge rejecting non-workspace writes ## Docs - update the Codex config docs on developers.openai.com/codex to document named `[permissions.]` entries, `default_permissions`, scoped `:project_roots` syntax, the descendant-path restriction for nested `:project_roots` entries, and top-level `[network]` proxy configuration --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/13434). * #13453 * #13452 * #13451 * #13449 * #13448 * #13445 * #13440 * #13439 * __->__ #13434 --- codex-rs/core/config.schema.json | 45 +- codex-rs/core/src/config/config_tests.rs | 485 ++++++++++++++++-- codex-rs/core/src/config/mod.rs | 245 +++++++-- codex-rs/core/src/config/permissions.rs | 225 +++++++- codex-rs/core/src/network_proxy_loader.rs | 70 +-- .../runtimes/shell/unix_escalation_tests.rs | 4 + codex-rs/core/tests/suite/approvals.rs | 7 +- codex-rs/network-proxy/README.md | 6 +- codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/permissions.rs | 473 +++++++++++++++++ codex-rs/protocol/src/protocol.rs | 35 ++ 11 files changed, 1472 insertions(+), 124 deletions(-) create mode 100644 codex-rs/protocol/src/permissions.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1f9446953..34239c0c9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -568,6 +568,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,21 @@ }, "type": "object" }, - "PermissionsToml": { + "PermissionProfileToml": { "additionalProperties": false, "properties": { + "filesystem": { + "$ref": "#/definitions/FilesystemPermissionsToml" + }, "network": { - "allOf": [ - { - "$ref": "#/definitions/NetworkToml" - } - ], - "description": "Network proxy settings from `[permissions.network]`. User config can enable the proxy; managed requirements may still constrain values." + "$ref": "#/definitions/NetworkToml" } }, "type": "object" }, + "PermissionsToml": { + "type": "object" + }, "Personality": { "enum": [ "none", @@ -1664,6 +1689,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.", @@ -2067,7 +2096,7 @@ } ], "default": null, - "description": "Nested permissions settings." + "description": "Named permissions profiles." }, "personality": { "allOf": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8fa95d6a2..e70f7c2b2 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -13,6 +13,10 @@ use crate::config_loader::RequirementSource; use crate::features::Feature; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; use serde::Deserialize; use tempfile::tempdir; @@ -197,9 +201,18 @@ fn runtime_config_defaults_model_availability_nux() { } #[test] -fn config_toml_deserializes_permissions_network() { +fn config_toml_deserializes_permission_profiles() { let toml = r#" -[permissions.network] +default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.filesystem.":project_roots"] +"." = "write" +"docs" = "read" + +[permissions.workspace.network] enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false @@ -207,55 +220,92 @@ allow_upstream_proxy = false allowed_domains = ["openai.com"] "#; let cfg: ConfigToml = - toml::from_str(toml).expect("TOML deserialization should succeed for permissions.network"); + 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 - .and_then(|permissions| permissions.network) - .expect("permissions.network should deserialize"), - NetworkToml { - enabled: Some(true), - proxy_url: Some("http://127.0.0.1:43128".to_string()), - enable_socks5: Some(false), - socks_url: None, - enable_socks5_udp: None, - allow_upstream_proxy: Some(false), - dangerously_allow_non_loopback_proxy: None, - dangerously_allow_all_unix_sockets: None, - mode: None, - allowed_domains: Some(vec!["openai.com".to_string()]), - denied_domains: None, - allow_unix_sockets: None, - allow_local_binding: None, + 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: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + socks_url: None, + enable_socks5_udp: None, + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + mode: None, + allowed_domains: Some(vec!["openai.com".to_string()]), + denied_domains: None, + allow_unix_sockets: None, + allow_local_binding: None, + }), + }, + )]), } ); } #[test] -fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io::Result<()> { +fn permissions_profiles_network_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() - }), - }), - ..Default::default() - }; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let config = Config::load_from_base_config_with_overrides( - cfg, - ConfigOverrides::default(), + 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(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, codex_home.path().to_path_buf(), )?; let network = config .permissions .network .as_ref() - .expect("enabled permissions.network should produce a NetworkProxySpec"); + .expect("enabled profile network should produce a NetworkProxySpec"); assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); assert!(!network.socks_enabled()); @@ -263,24 +313,357 @@ fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io } #[test] -fn permissions_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> { +fn permissions_profiles_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() + 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(NetworkToml { + allowed_domains: Some(vec!["openai.com".to_string()]), + ..Default::default() + }), + }, + )]), }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + assert!(config.permissions.network.is_none()); + Ok(()) +} + +#[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::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, codex_home.path().to_path_buf(), )?; - assert!(config.permissions.network.is_none()); + + let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")).unwrap(); + assert_eq!( + config.permissions.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some("docs".into())), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: memories_root.clone(), + }, + access: FileSystemAccessMode::Write, + }, + ]), + ); + assert_eq!( + config.permissions.sandbox_policy.get(), + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![memories_root], + 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(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "config defines `[permissions]` profiles but does not set `default_permissions`" + ); + 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(), std::io::ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("filesystem writes outside the workspace root"), + "{err}" + ); + Ok(()) +} + +#[test] +fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> 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 { + 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::Scoped(BTreeMap::from([( + "docs".to_string(), + FileSystemAccessMode::Read, + )])), + )]), + }), + network: None, + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("nested entries outside :project_roots should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "filesystem path `:minimal` does not support nested entries" + ); + Ok(()) +} + +#[test] +fn permissions_profiles_reject_project_root_parent_traversal() -> 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 { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([( + "../sibling".to_string(), + FileSystemAccessMode::Read, + )])), + )]), + }), + network: None, + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("parent traversal should be rejected for project root subpaths"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "filesystem subpath `../sibling` must be a descendant path without `.` or `..` components" + ); + 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(NetworkToml { + enabled: Some(true), + ..Default::default() + }), + }, + )]), + }), + ..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(()) } @@ -2653,6 +3036,10 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { 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(), @@ -2782,6 +3169,10 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { 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(), @@ -2909,6 +3300,10 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { 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(), @@ -3022,6 +3417,10 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { 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/mod.rs b/codex-rs/core/src/config/mod.rs index e489720ba..f4a3063e9 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; @@ -72,6 +73,8 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -86,8 +89,10 @@ use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; -use crate::config::permissions::network_proxy_config_from_permissions; +use crate::config::permissions::compile_permission_profile; +use crate::config::permissions::network_proxy_config_from_profile_network; use crate::config::profile::ConfigProfile; +use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; use toml_edit::DocumentMut; @@ -107,8 +112,12 @@ 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::PermissionProfileToml; pub use permissions::PermissionsToml; +pub(crate) use permissions::resolve_permission_profile; pub use service::ConfigService; pub use service::ConfigServiceError; @@ -137,11 +146,9 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { Some(resolved_cwd.join(path)) } } - #[cfg(test)] pub(crate) fn test_config() -> Config { - use tempfile::tempdir; - let codex_home = tempdir().expect("create temp dir"); + let codex_home = tempfile::tempdir().expect("create temp dir"); Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), @@ -157,6 +164,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. @@ -1045,7 +1058,11 @@ 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, @@ -1563,6 +1580,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, + codex_protocol::permissions::FileSystemPath::Path { path: existing } + if existing == path && entry.access == codex_protocol::permissions::FileSystemAccessMode::Write + ) + }); + if !exists { + file_system_sandbox_policy.entries.push( + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { path: path.clone() }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + ); + } + } +} + /// Optional overrides for user configuration (e.g., from CLI flags). #[derive(Default, Debug, Clone)] pub struct ConfigOverrides { @@ -1750,9 +1839,6 @@ impl Config { .clone(), None => ConfigProfile::default(), }; - let configured_network_proxy_config = - network_proxy_config_from_permissions(cfg.permissions.as_ref()); - let feature_overrides = FeatureOverrides { include_apply_patch_tool: include_apply_patch_tool_override, web_search_request: override_tools_web_search_request, @@ -1779,42 +1865,123 @@ impl Config { } } }); - let additional_writable_roots: Vec = additional_writable_roots + let mut additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd)) .collect::, _>>()?; 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()); + 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 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 { - let memories_root = memory_root(&codex_home); - std::fs::create_dir_all(&memories_root)?; - let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?; - if !writable_roots - .iter() - .any(|existing| existing == &memories_root) - { - writable_roots.push(memories_root); + let memories_root = memory_root(&codex_home); + std::fs::create_dir_all(&memories_root)?; + let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?; + if !additional_writable_roots + .iter() + .any(|existing| existing == &memories_root) + { + additional_writable_roots.push(memories_root); + } + + let profiles_are_active = matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Profiles) + ) || (permission_config_syntax.is_none() + && has_permission_profiles); + let ( + configured_network_proxy_config, + 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 profile = resolve_permission_profile(permissions, default_permissions)?; + let configured_network_proxy_config = + network_proxy_config_from_profile_network(profile.network.as_ref()); + 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)?; } - for path in additional_writable_roots { - if !writable_roots.iter().any(|existing| existing == &path) { - writable_roots.push(path); + ( + configured_network_proxy_config, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + } else { + let configured_network_proxy_config = NetworkProxyConfig::default(); + 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); + ( + configured_network_proxy_config, + 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(); let mut approval_policy = approval_policy_override .or(config_profile.approval_policy) .or(cfg.approval_policy) @@ -1827,7 +1994,9 @@ impl Config { AskForApproval::default() } }); - if let Err(err) = constrained_approval_policy.can_set(&approval_policy) { + if !approval_policy_was_explicit + && let Err(err) = constrained_approval_policy.can_set(&approval_policy) + { tracing::warn!( error = %err, "default approval policy is disallowed by requirements; falling back to required default" @@ -2072,6 +2241,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", @@ -2119,6 +2289,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, @@ -2133,6 +2316,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, diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 15ecc065b..b96039cec 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,17 +1,60 @@ +use std::collections::BTreeMap; +use std::io; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::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. + #[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 NetworkToml { @@ -91,13 +134,173 @@ impl NetworkToml { } } -pub(crate) fn network_proxy_config_from_permissions( - permissions: Option<&PermissionsToml>, +pub(crate) fn network_proxy_config_from_profile_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 resolve_permission_profile<'a>( + permissions: &'a PermissionsToml, + profile_name: &str, +) -> io::Result<&'a PermissionProfileToml> { + permissions.entries.get(profile_name).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("default_permissions refers to undefined profile `{profile_name}`"), + ) + }) +} + +pub(crate) fn compile_permission_profile( + permissions: &PermissionsToml, + profile_name: &str, +) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { + let profile = resolve_permission_profile(permissions, 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<&NetworkToml>) -> 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); + } + + if let Some(special) = parse_special_path(path)? { + if !matches!(special, FileSystemSpecialPath::ProjectRoots { .. }) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("filesystem path `{path}` does not support nested entries"), + )); + } + return Ok(FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some(parse_relative_subpath(subpath)?)), + }); + } + + let subpath = parse_relative_subpath(subpath)?; + 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 special = match path { + ":root" => Some(FileSystemSpecialPath::Root), + ":minimal" => Some(FileSystemSpecialPath::Minimal), + ":project_roots" => Some(FileSystemSpecialPath::project_roots(None)), + ":tmpdir" => Some(FileSystemSpecialPath::Tmpdir), + _ if path.starts_with(':') => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unknown filesystem special path `{path}`"), + )); + } + _ => None, + }; + + Ok(special) +} + +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) +} + +fn parse_relative_subpath(subpath: &str) -> io::Result { + let path = Path::new(subpath); + if !subpath.is_empty() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Ok(path.to_path_buf()); + } + + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "filesystem subpath `{}` must be a descendant path without `.` or `..` components", + path.display() + ), + )) } diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 5ffd274be..1c8244f70 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,6 +1,7 @@ use crate::config::NetworkToml; use crate::config::PermissionsToml; use crate::config::find_codex_home; +use crate::config::resolve_permission_profile; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; @@ -118,13 +119,7 @@ fn network_constraints_from_trusted_layers( } let parsed = network_tables_from_toml(&layer.config)?; - if let Some(network) = parsed.network { - apply_network_constraints(network, &mut constraints); - } - if let Some(network) = parsed - .permissions - .and_then(|permissions| permissions.network) - { + if let Some(network) = selected_network_from_tables(parsed)? { apply_network_constraints(network, &mut constraints); } } @@ -165,7 +160,7 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx #[derive(Debug, Clone, Default, Deserialize)] struct NetworkTablesToml { - network: Option, + default_permissions: Option, permissions: Option, } @@ -176,16 +171,24 @@ fn network_tables_from_toml(value: &toml::Value) -> Result { .context("failed to deserialize network tables from config") } -fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) { - if let Some(network) = parsed.network { - network.apply_to_network_proxy_config(config); - } - if let Some(network) = parsed +fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result> { + let Some(default_permissions) = parsed.default_permissions else { + return Ok(None); + }; + + let permissions = parsed .permissions - .and_then(|permissions| permissions.network) - { + .context("default_permissions requires a `[permissions]` table for network settings")?; + let profile = resolve_permission_profile(&permissions, &default_permissions) + .map_err(anyhow::Error::from)?; + Ok(profile.network.clone()) +} + +fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> { + if let Some(network) = selected_network_from_tables(parsed)? { network.apply_to_network_proxy_config(config); } + Ok(()) } fn config_from_layers( @@ -195,7 +198,7 @@ fn config_from_layers( let mut config = NetworkProxyConfig::default(); for layer in layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) { let parsed = network_tables_from_toml(&layer.config)?; - apply_network_tables(&mut config, parsed); + apply_network_tables(&mut config, parsed)?; } apply_exec_policy_network_rules(&mut config, exec_policy); Ok(config) @@ -310,17 +313,21 @@ 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_profile_network_beats_lower_profile_network() { + let lower_network: toml::Value = toml::from_str( r#" -[permissions.network] +default_permissions = "workspace" + +[permissions.workspace.network] allowed_domains = ["lower.example.com"] "#, ) .expect("lower layer should parse"); let higher_network: toml::Value = toml::from_str( r#" -[network] +default_permissions = "workspace" + +[permissions.workspace.network] allowed_domains = ["higher.example.com"] "#, ) @@ -329,12 +336,14 @@ 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"), + ) + .expect("lower layer should apply"); apply_network_tables( &mut config, network_tables_from_toml(&higher_network).expect("higher layer should deserialize"), - ); + ) + .expect("higher layer should apply"); assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); } @@ -382,15 +391,18 @@ allowed_domains = ["higher.example.com"] fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { let config: toml::Value = toml::from_str( r#" -[network] +default_permissions = "workspace" + +[permissions.workspace.network] dangerously_allow_all_unix_sockets = true "#, ) - .expect("network table should parse"); - let network = network_tables_from_toml(&config) - .expect("network table should deserialize") - .network - .expect("network table should be present"); + .expect("permissions profile should parse"); + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("permissions profile should deserialize"), + ) + .expect("permissions profile should select a network table") + .expect("network table should be present"); let mut constraints = NetworkProxyConstraints::default(); apply_network_constraints(network, &mut constraints); 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 5a94fa0fb..5d7b0f5e0 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 @@ -537,6 +537,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: codex_protocol::permissions::FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: codex_protocol::permissions::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 4349ed136..abeb792af 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2234,7 +2234,12 @@ 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#"default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.network] enabled = true mode = "limited" allow_local_binding = true diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 15f870944..6212b9426 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -13,10 +13,12 @@ It enforces an allow/deny policy and a "limited" mode intended for read-only net `codex-network-proxy` reads from Codex's merged `config.toml` (via `codex-core` config loading). -Example config: +Network settings live under the selected permissions profile. Example config: ```toml -[network] +default_permissions = "workspace" + +[permissions.workspace.network] enabled = true proxy_url = "http://127.0.0.1:3128" # SOCKS5 listener (enabled by default). diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 5841b1187..afe78d37d 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -12,6 +12,7 @@ pub mod models; pub mod num_format; pub mod openai_models; pub mod parse_command; +pub mod permissions; pub mod plan_tool; pub mod protocol; pub mod request_user_input; diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs new file mode 100644 index 000000000..baf17e0ce --- /dev/null +++ b/codex-rs/protocol/src/permissions.rs @@ -0,0 +1,473 @@ +use std::collections::HashSet; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use strum_macros::Display; +use ts_rs::TS; + +use crate::protocol::NetworkAccess; +use crate::protocol::ReadOnlyAccess; +use crate::protocol::SandboxPolicy; + +#[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(tag = "kind", rename_all = "snake_case")] +#[ts(tag = "kind")] +pub enum FileSystemSpecialPath { + Root, + Minimal, + CurrentWorkingDirectory, + ProjectRoots { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + subpath: Option, + }, + Tmpdir, + SlashTmp, +} + +impl FileSystemSpecialPath { + pub fn project_roots(subpath: Option) -> Self { + Self::ProjectRoots { subpath } + } +} + +#[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, +} + +#[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 }, +} + +impl Default for FileSystemSandboxPolicy { + fn default() -> Self { + Self { + kind: FileSystemSandboxKind::Restricted, + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + 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 { + FileSystemSpecialPath::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; + } + }, + FileSystemSpecialPath::Minimal => { + if entry.access.can_read() { + include_platform_defaults = true; + } + } + FileSystemSpecialPath::CurrentWorkingDirectory => { + if entry.access.can_write() { + workspace_root_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); + } + } + FileSystemSpecialPath::ProjectRoots { subpath } => { + if 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); + } + } + } + FileSystemSpecialPath::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); + } + } + FileSystemSpecialPath::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(), + } + } + } + }) + } +} + +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::Root, + }, + access: FileSystemAccessMode::Read, + }), + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Read, + }); + if *include_platform_defaults { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + 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::Root, + }, + access: FileSystemAccessMode::Read, + }), + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => { + if *include_platform_defaults { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + 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::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }); + if !exclude_slash_tmp { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::SlashTmp, + }, + access: FileSystemAccessMode::Write, + }); + } + if !exclude_tmpdir_env_var { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + 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 { + FileSystemSpecialPath::Root | FileSystemSpecialPath::Minimal => None, + FileSystemSpecialPath::CurrentWorkingDirectory => { + let cwd = cwd?; + Some(cwd.clone()) + } + FileSystemSpecialPath::ProjectRoots { subpath } => { + let cwd = cwd?; + match subpath.as_ref() { + Some(subpath) => { + AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path()).ok() + } + None => Some(cwd.clone()), + } + } + FileSystemSpecialPath::Tmpdir => { + let tmpdir = std::env::var_os("TMPDIR")?; + if tmpdir.is_empty() { + None + } else { + let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?; + Some(tmpdir) + } + } + FileSystemSpecialPath::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; + } + 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 +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c5f94f974..02776ff24 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3152,6 +3152,11 @@ mod tests { use crate::items::ImageGenerationItem; use crate::items::UserMessageItem; use crate::items::WebSearchItem; + use crate::permissions::FileSystemAccessMode; + use crate::permissions::FileSystemPath; + use crate::permissions::FileSystemSandboxEntry; + use crate::permissions::FileSystemSandboxPolicy; + use crate::permissions::NetworkSandboxPolicy; use anyhow::Result; use pretty_assertions::assert_eq; use serde_json::json; @@ -3236,6 +3241,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 {