diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index fdaaf3e857d0..a7cc7ae349b1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5622,7 +5622,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e413d792349c..4714f84f24b4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -134,7 +134,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 3d66152a6001..cccc7e5b8d81 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index f379455b593f..f57257f94cb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 7d31c52f1cc2..02d156d7a62b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts index 73f9efcab574..ee9026b54303 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -9,7 +9,7 @@ export type ActivePermissionProfile = { */ id: string, /** - * Parent profile identifier once permissions profiles support - * inheritance. This is currently always `null`. + * Parent profile identifier from the selected permissions profile's + * `extends` setting, when present. */ extends: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index f00bcfaefb31..78d2c517fa2d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -294,8 +294,8 @@ pub struct ActivePermissionProfile { /// Identifier from `default_permissions` or the implicit built-in default, /// such as `:workspace` or a user-defined `[permissions.]` profile. pub id: String, - /// Parent profile identifier once permissions profiles support - /// inheritance. This is currently always `null`. + /// Parent profile identifier from the selected permissions profile's + /// `extends` setting, when present. #[serde(default)] pub extends: Option, } diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index fff8c677061b..b0cacbb565e5 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -9,6 +9,7 @@ use codex_protocol::permissions::FileSystemAccessMode; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use thiserror::Error; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct PermissionsToml { @@ -20,16 +21,233 @@ impl PermissionsToml { pub fn is_empty(&self) -> bool { self.entries.is_empty() } + + pub fn resolve_profile( + &self, + profile_name: &str, + mut parent_profile: F, + ) -> Result + where + F: FnMut(&str) -> Option, + { + let mut profile_names = Vec::new(); + let mut profiles = Vec::new(); + let mut next_profile_name = profile_name.to_string(); + let mut referenced_by: Option = None; + + loop { + if let Some(cycle_start) = profile_names + .iter() + .position(|name| name == &next_profile_name) + { + let cycle = profile_names[cycle_start..] + .iter() + .cloned() + .chain(std::iter::once(next_profile_name)) + .collect::>(); + return Err(PermissionProfileResolutionError::Cycle { cycle }); + } + + let profile = self + .entries + .get(&next_profile_name) + .cloned() + .or_else(|| parent_profile(&next_profile_name)) + .ok_or_else(|| { + referenced_by.as_deref().map_or_else( + || PermissionProfileResolutionError::UndefinedProfile { + profile_name: next_profile_name.clone(), + }, + |referenced_by| PermissionProfileResolutionError::UndefinedParent { + profile_name: referenced_by.to_string(), + parent_profile_name: next_profile_name.clone(), + }, + ) + })?; + let parent_profile_name = profile.extends.clone(); + + profile_names.push(next_profile_name.clone()); + + if let Some(parent_profile_name) = parent_profile_name { + profiles.push(profile); + referenced_by = Some(next_profile_name); + next_profile_name = parent_profile_name; + continue; + } + + let profile = profiles + .into_iter() + .rev() + .fold(profile, merge_permission_profiles); + return Ok(ResolvedPermissionProfileToml { + profile, + inherited_profile_names: profile_names.into_iter().skip(1).collect(), + }); + } + } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PermissionProfileToml { + pub extends: Option, pub workspace_roots: Option, pub filesystem: Option, pub network: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedPermissionProfileToml { + pub profile: PermissionProfileToml, + pub inherited_profile_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PermissionProfileResolutionError { + #[error("default_permissions refers to undefined profile `{profile_name}`")] + UndefinedProfile { profile_name: String }, + #[error( + "permissions profile `{profile_name}` extends undefined profile `{parent_profile_name}`" + )] + UndefinedParent { + profile_name: String, + parent_profile_name: String, + }, + #[error( + "permissions profile inheritance cycle detected: {}", + cycle.join(" -> ") + )] + Cycle { cycle: Vec }, +} + +fn merge_permission_profiles( + parent: PermissionProfileToml, + child: PermissionProfileToml, +) -> PermissionProfileToml { + PermissionProfileToml { + extends: child.extends, + workspace_roots: merge_workspace_roots(parent.workspace_roots, child.workspace_roots), + filesystem: merge_filesystem_permissions(parent.filesystem, child.filesystem), + network: merge_network_permissions(parent.network, child.network), + } +} + +fn merge_workspace_roots( + parent: Option, + child: Option, +) -> Option { + match (parent, child) { + (Some(mut parent), Some(child)) => { + parent.entries.extend(child.entries); + Some(parent) + } + (Some(parent), None) => Some(parent), + (None, Some(child)) => Some(child), + (None, None) => None, + } +} + +fn merge_filesystem_permissions( + parent: Option, + child: Option, +) -> Option { + match (parent, child) { + (Some(mut parent), Some(child)) => { + if child.glob_scan_max_depth.is_some() { + parent.glob_scan_max_depth = child.glob_scan_max_depth; + } + for (path, child_permission) in child.entries { + match (parent.entries.remove(&path), child_permission) { + ( + Some(FilesystemPermissionToml::Scoped(mut parent_entries)), + FilesystemPermissionToml::Scoped(child_entries), + ) => { + parent_entries.extend(child_entries); + parent + .entries + .insert(path, FilesystemPermissionToml::Scoped(parent_entries)); + } + (_, child_permission) => { + parent.entries.insert(path, child_permission); + } + } + } + Some(parent) + } + (Some(parent), None) => Some(parent), + (None, Some(child)) => Some(child), + (None, None) => None, + } +} + +fn merge_network_permissions( + parent: Option, + child: Option, +) -> Option { + match (parent, child) { + (Some(mut parent), Some(child)) => { + parent.enabled = child.enabled.or(parent.enabled); + parent.proxy_url = child.proxy_url.or(parent.proxy_url); + parent.enable_socks5 = child.enable_socks5.or(parent.enable_socks5); + parent.socks_url = child.socks_url.or(parent.socks_url); + parent.enable_socks5_udp = child.enable_socks5_udp.or(parent.enable_socks5_udp); + parent.allow_upstream_proxy = + child.allow_upstream_proxy.or(parent.allow_upstream_proxy); + parent.dangerously_allow_non_loopback_proxy = child + .dangerously_allow_non_loopback_proxy + .or(parent.dangerously_allow_non_loopback_proxy); + parent.dangerously_allow_all_unix_sockets = child + .dangerously_allow_all_unix_sockets + .or(parent.dangerously_allow_all_unix_sockets); + parent.mode = child.mode.or(parent.mode); + parent.allow_local_binding = child.allow_local_binding.or(parent.allow_local_binding); + parent.domains = merge_network_domain_permissions(parent.domains, child.domains); + parent.unix_sockets = + merge_network_unix_socket_permissions(parent.unix_sockets, child.unix_sockets); + Some(parent) + } + (Some(parent), None) => Some(parent), + (None, Some(child)) => Some(child), + (None, None) => None, + } +} + +fn merge_network_domain_permissions( + parent: Option, + child: Option, +) -> Option { + match (parent, child) { + (Some(parent), Some(child)) => { + let mut entries = BTreeMap::new(); + for (pattern, permission) in parent.entries { + entries.insert(normalize_host(&pattern), permission); + } + for (pattern, permission) in child.entries { + entries.insert(normalize_host(&pattern), permission); + } + Some(NetworkDomainPermissionsToml { entries }) + } + (Some(parent), None) => Some(parent), + (None, Some(child)) => Some(child), + (None, None) => None, + } +} + +fn merge_network_unix_socket_permissions( + parent: Option, + child: Option, +) -> Option { + match (parent, child) { + (Some(mut parent), Some(child)) => { + parent.entries.extend(child.entries); + Some(parent) + } + (Some(parent), None) => Some(parent), + (None, Some(child)) => Some(child), + (None, None) => None, + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct WorkspaceRootsToml { #[serde(flatten)] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 07f49a3cd630..351169cd1938 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1966,6 +1966,9 @@ "PermissionProfileToml": { "additionalProperties": false, "properties": { + "extends": { + "type": "string" + }, "filesystem": { "$ref": "#/definitions/FilesystemPermissionsToml" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 50b03b5051ca..603f8c93b4ac 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -764,6 +764,7 @@ allow_upstream_proxy = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([ ("~/code/ignored".to_string(), false), @@ -825,6 +826,56 @@ async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_ entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, + workspace_roots: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + 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.abs(), + ) + .await?; + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert!( + config.permissions.network.is_none(), + "bare profile network.enabled should not start the managed network proxy" + ); + Ok(()) +} + +#[tokio::test] +async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> 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 { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -979,6 +1030,7 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1130,6 +1182,7 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1233,6 +1286,7 @@ enabled = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1282,6 +1336,7 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1329,6 +1384,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1424,6 +1480,63 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: Ok(()) } +#[tokio::test] +async fn default_permissions_extended_profile_preserves_parent_metadata() -> 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([ + ( + "base".to_string(), + PermissionProfileToml { + extends: None, + workspace_roots: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: None, + }, + ), + ( + "workspace".to_string(), + PermissionProfileToml { + extends: Some("base".to_string()), + workspace_roots: None, + filesystem: None, + network: None, + }, + ), + ]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile { + id: "workspace".to_string(), + extends: Some("base".to_string()), + }) + ); + Ok(()) +} + #[tokio::test] async fn permission_profile_override_populates_runtime_permissions() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -1633,6 +1746,7 @@ async fn permission_profile_override_preserves_configured_network_policy_without entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1693,6 +1807,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), @@ -1772,6 +1887,7 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1897,6 +2013,7 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots( entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { + extends: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([( profile_root.to_string_lossy().into_owned(), @@ -2017,6 +2134,61 @@ async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_setti Ok(()) } +#[tokio::test] +async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace-with-network".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace-with-network".to_string(), + PermissionProfileToml { + extends: Some(":workspace".to_string()), + workspace_roots: None, + filesystem: None, + network: Some(NetworkToml { + enabled: Some(true), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert!( + policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected profile extending :workspace to keep project-root writes, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()), + "expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}" + ); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile { + id: "workspace-with-network".to_string(), + extends: Some(":workspace".to_string()), + }) + ); + Ok(()) +} + #[tokio::test] async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -2355,6 +2527,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2412,6 +2585,7 @@ async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2473,6 +2647,7 @@ async fn load_workspace_permission_profile( #[tokio::test] async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2517,6 +2692,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2554,6 +2730,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() #[tokio::test] async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: None, network: None, @@ -2583,6 +2760,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io #[tokio::test] async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2619,6 +2797,7 @@ async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::i entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2666,6 +2845,7 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 583c2a42b6f1..df59c8062766 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -149,6 +149,8 @@ pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; +pub(crate) use permissions::is_builtin_permission_profile_name; +pub(crate) use permissions::reject_unknown_builtin_permission_profile; pub(crate) use permissions::resolve_permission_profile; pub use resolved_permission_profile::PermissionProfileSnapshot; pub(crate) use resolved_permission_profile::PermissionProfileState; @@ -2780,7 +2782,15 @@ impl Config { // when doing so would lose roots, network, or tmp settings. None } else { - Some(ActivePermissionProfile::new(default_permissions)) + let selected_profile_extends = cfg + .permissions + .as_ref() + .and_then(|permissions| permissions.entries.get(default_permissions)) + .and_then(|profile| profile.extends.clone()); + let mut active_permission_profile = + ActivePermissionProfile::new(default_permissions); + active_permission_profile.extends = selected_profile_extends; + Some(active_permission_profile) }; ( configured_network_proxy_config, diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 9f8fcd9ee3ae..8794ad48b0bb 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -13,6 +13,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::ResolvedPermissionProfileToml; use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::types::SandboxWorkspaceWrite; use codex_features::NetworkProxyConfigToml; @@ -189,16 +190,17 @@ pub(crate) fn apply_network_proxy_feature_config( .apply_to_network_proxy_config(config); } -pub(crate) fn resolve_permission_profile<'a>( - permissions: &'a PermissionsToml, +pub(crate) fn resolve_permission_profile( + permissions: &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}`"), - ) - }) +) -> io::Result { + permissions + .resolve_profile(profile_name, builtin_parent_permission_profile) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string())) +} + +fn builtin_parent_permission_profile(profile_name: &str) -> Option { + (profile_name == BUILT_IN_WORKSPACE_PROFILE).then(PermissionProfileToml::default) } pub(crate) fn network_proxy_config_for_profile_selection( @@ -216,7 +218,7 @@ pub(crate) fn network_proxy_config_for_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - let profile = resolve_permission_profile(permissions, profile_name)?; + let profile = resolve_permission_profile(permissions, profile_name)?.profile; Ok(network_proxy_config_from_profile_network( profile.network.as_ref(), )) @@ -228,11 +230,39 @@ pub(crate) fn compile_permission_profile( policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { - let profile = resolve_permission_profile(permissions, profile_name)?; + let ResolvedPermissionProfileToml { + profile, + inherited_profile_names, + } = resolve_permission_profile(permissions, profile_name)?; + let base_permissions = inherited_profile_names + .iter() + .any(|name| name == BUILT_IN_WORKSPACE_PROFILE) + .then(|| PermissionProfile::workspace_write().to_runtime_permissions()); + compile_resolved_permission_profile( + profile, + profile_name, + policy_cwd, + startup_warnings, + base_permissions, + ) +} - let mut entries = Vec::new(); +fn compile_resolved_permission_profile( + profile: PermissionProfileToml, + profile_name: &str, + policy_cwd: &Path, + startup_warnings: &mut Vec, + base_permissions: Option<(FileSystemSandboxPolicy, NetworkSandboxPolicy)>, +) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { + let (mut file_system_sandbox_policy, base_network_sandbox_policy) = base_permissions + .unwrap_or_else(|| { + ( + FileSystemSandboxPolicy::restricted(Vec::new()), + NetworkSandboxPolicy::Restricted, + ) + }); if let Some(filesystem) = profile.filesystem.as_ref() { - if filesystem.is_empty() { + if filesystem.is_empty() && file_system_sandbox_policy.entries.is_empty() { push_warning( startup_warnings, missing_filesystem_entries_warning(profile_name), @@ -257,15 +287,17 @@ pub(crate) fn compile_permission_profile( } } for (path, permission) in &filesystem.entries { - entries.extend(compile_filesystem_permission( - path, - permission, - policy_cwd, - startup_warnings, - )?); + file_system_sandbox_policy + .entries + .extend(compile_filesystem_permission( + path, + permission, + policy_cwd, + startup_warnings, + )?); } } - } else { + } else if file_system_sandbox_policy.entries.is_empty() { push_warning( startup_warnings, missing_filesystem_entries_warning(profile_name), @@ -277,10 +309,11 @@ pub(crate) fn compile_permission_profile( .as_ref() .and_then(|filesystem| filesystem.glob_scan_max_depth), )?; - - let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref()); - let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries); - file_system_sandbox_policy.glob_scan_max_depth = glob_scan_max_depth; + if let Some(glob_scan_max_depth) = glob_scan_max_depth { + file_system_sandbox_policy.glob_scan_max_depth = Some(glob_scan_max_depth); + } + let network_sandbox_policy = + compile_network_sandbox_policy(profile.network.as_ref(), base_network_sandbox_policy); Ok((file_system_sandbox_policy, network_sandbox_policy)) } @@ -323,7 +356,7 @@ pub(crate) fn compile_permission_profile_workspace_roots( })?; let profile = resolve_permission_profile(permissions, profile_name)?; Ok(compile_workspace_roots( - profile.workspace_roots.as_ref(), + profile.profile.workspace_roots.as_ref(), policy_cwd, )) } @@ -340,7 +373,7 @@ fn compile_workspace_roots( }) } -fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { +pub(crate) fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { if profile_name.starts_with(':') { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -382,14 +415,18 @@ pub(crate) fn get_readable_roots_required_for_codex_runtime( readable_roots } -fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy { +fn compile_network_sandbox_policy( + network: Option<&NetworkToml>, + base_network_sandbox_policy: NetworkSandboxPolicy, +) -> NetworkSandboxPolicy { let Some(network) = network else { - return NetworkSandboxPolicy::Restricted; + return base_network_sandbox_policy; }; match network.enabled { Some(true) => NetworkSandboxPolicy::Enabled, - _ => NetworkSandboxPolicy::Restricted, + Some(false) => NetworkSandboxPolicy::Restricted, + None => base_network_sandbox_policy, } } diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 51cf13912e7f..f0a75529550d 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -67,6 +67,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -238,6 +239,229 @@ fn network_toml_overlays_unix_socket_permissions_by_path() { ); } +#[test] +fn permissions_profiles_resolve_extends_parent_first_with_child_overrides() { + let permissions = PermissionsToml { + entries: BTreeMap::from([ + ( + "base".to_string(), + PermissionProfileToml { + extends: None, + workspace_roots: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: Some(1), + entries: BTreeMap::from([ + ( + "/tmp/base".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + ), + ( + "/tmp/shared".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + ), + ( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + ("**/*.env".to_string(), FileSystemAccessMode::None), + ("docs".to_string(), FileSystemAccessMode::Read), + ])), + ), + ]), + }), + network: Some(NetworkToml { + enabled: Some(true), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "base.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "SHARED.EXAMPLE.COM.".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }), + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/base.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }), + ..Default::default() + }), + }, + ), + ( + "child".to_string(), + PermissionProfileToml { + extends: Some("base".to_string()), + workspace_roots: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: Some(3), + entries: BTreeMap::from([ + ( + "/tmp/shared".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ), + ( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + ("docs".to_string(), FileSystemAccessMode::Write), + ("src".to_string(), FileSystemAccessMode::Read), + ])), + ), + ]), + }), + network: Some(NetworkToml { + enabled: Some(false), + allow_local_binding: Some(true), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "child.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "shared.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ]), + }), + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/child.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }), + ..Default::default() + }), + }, + ), + ]), + }; + + let resolved = permissions + .resolve_profile("child", |_| None) + .expect("child profile should resolve"); + + assert_eq!( + resolved.profile, + PermissionProfileToml { + extends: Some("base".to_string()), + workspace_roots: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: Some(3), + entries: BTreeMap::from([ + ( + "/tmp/base".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + ), + ( + "/tmp/shared".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ), + ( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + ("**/*.env".to_string(), FileSystemAccessMode::None), + ("docs".to_string(), FileSystemAccessMode::Write), + ("src".to_string(), FileSystemAccessMode::Read), + ])), + ), + ]), + }), + network: Some(NetworkToml { + enabled: Some(false), + allow_local_binding: Some(true), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "base.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "child.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "shared.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ]), + }), + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/base.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/child.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ]), + }), + ..Default::default() + }), + } + ); + assert_eq!(resolved.inherited_profile_names, vec!["base".to_string()]); +} + +#[test] +fn permissions_profiles_reject_undefined_extends_parent() { + let permissions = PermissionsToml { + entries: BTreeMap::from([( + "child".to_string(), + PermissionProfileToml { + extends: Some("base".to_string()), + ..Default::default() + }, + )]), + }; + + let err = permissions + .resolve_profile("child", |_| None) + .expect_err("missing parent should be rejected"); + + assert_eq!( + err.to_string(), + "permissions profile `child` extends undefined profile `base`" + ); +} + +#[test] +fn permissions_profiles_reject_extends_cycles() { + let permissions = PermissionsToml { + entries: BTreeMap::from([ + ( + "alpha".to_string(), + PermissionProfileToml { + extends: Some("beta".to_string()), + ..Default::default() + }, + ), + ( + "beta".to_string(), + PermissionProfileToml { + extends: Some("alpha".to_string()), + ..Default::default() + }, + ), + ]), + }; + + let err = permissions + .resolve_profile("alpha", |_| None) + .expect_err("cycle should be rejected"); + + assert_eq!( + err.to_string(), + "permissions profile inheritance cycle detected: alpha -> beta -> alpha" + ); +} + #[test] fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() { let config = network_proxy_config_from_profile_network(Some(&NetworkToml { @@ -285,6 +509,7 @@ fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([ ("backend".to_string(), true), @@ -394,6 +619,7 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 1c49be2daf78..5506d3dc5549 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,4 +1,6 @@ use crate::config::find_codex_home; +use crate::config::is_builtin_permission_profile_name; +use crate::config::reject_unknown_builtin_permission_profile; use crate::config::resolve_permission_profile; use crate::exec_policy::ExecPolicyError; use crate::exec_policy::format_exec_policy_error_with_source; @@ -13,6 +15,7 @@ use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; use codex_config::loader::load_config_layers_state; +use codex_config::merge_toml_values; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionsToml; use codex_config::permissions_toml::overlay_network_domain_permissions; @@ -117,6 +120,7 @@ fn network_constraints_from_trusted_layers( layers: &ConfigLayerStack, ) -> Result { let mut constraints = NetworkProxyConstraints::default(); + let mut merged = toml::Value::Table(toml::map::Map::new()); for layer in layers.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, @@ -125,7 +129,8 @@ fn network_constraints_from_trusted_layers( continue; } - let parsed = network_tables_from_toml(&layer.config)?; + merge_toml_values(&mut merged, &layer.config); + let parsed = network_tables_from_toml(&merged)?; if let Some(network) = selected_network_from_tables(parsed)? { apply_network_constraints(network, &mut constraints); } @@ -189,13 +194,17 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result Result<()> { @@ -210,11 +219,13 @@ fn config_from_layers( exec_policy: &codex_execpolicy::Policy, ) -> Result { let mut config = NetworkProxyConfig::default(); + let mut merged = toml::Value::Table(toml::map::Map::new()); for layer in layers.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, ) { - let parsed = network_tables_from_toml(&layer.config)?; + merge_toml_values(&mut merged, &layer.config); + let parsed = network_tables_from_toml(&merged)?; apply_network_tables(&mut config, parsed)?; } apply_exec_policy_network_rules(&mut config, exec_policy); diff --git a/codex-rs/core/src/network_proxy_loader_tests.rs b/codex-rs/core/src/network_proxy_loader_tests.rs index b5adb935ec77..10eb05f5e342 100644 --- a/codex-rs/core/src/network_proxy_loader_tests.rs +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -1,9 +1,17 @@ use super::*; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; +use codex_config::permissions_toml::NetworkDomainPermissionToml; +use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_execpolicy::Decision; use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; #[test] fn higher_precedence_profile_network_overlays_domain_entries() { @@ -170,6 +178,184 @@ dangerously_allow_all_unix_sockets = true assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true)); } +#[test] +fn selected_network_from_tables_ignores_builtin_profile_without_permissions_table() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = ":workspace" +"#, + ) + .expect("built-in profile config should parse"); + + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("built-in profile config should deserialize"), + ) + .expect("built-in profile selection should not require permissions tables"); + + assert_eq!(network, None); +} + +#[test] +fn selected_network_from_tables_rejects_unknown_builtin_profile_without_permissions_table() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = ":unknown" +"#, + ) + .expect("unknown built-in config should parse"); + + let err = selected_network_from_tables( + network_tables_from_toml(&config).expect("unknown built-in config should deserialize"), + ) + .expect_err("unknown built-in profile should be rejected"); + + assert_eq!( + err.to_string(), + "default_permissions refers to unknown built-in profile `:unknown`" + ); +} + +#[test] +fn selected_network_from_tables_resolves_builtin_workspace_parent() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace] +extends = ":workspace" + +[permissions.workspace.network] +enabled = true + +[permissions.workspace.network.domains] +"child.example.com" = "allow" +"#, + ) + .expect("workspace extension config should parse"); + + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("workspace extension config should deserialize"), + ) + .expect("workspace extension should resolve") + .expect("workspace extension should expose child network config"); + + assert_eq!( + network, + NetworkToml { + enabled: Some(true), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "child.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), + ..Default::default() + } + ); +} + +#[test] +fn selected_network_from_tables_resolves_permission_profile_inheritance() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.base.network] +enabled = true +dangerously_allow_all_unix_sockets = true + +[permissions.base.network.domains] +"base.example.com" = "allow" +"shared.example.com" = "deny" + +[permissions.workspace] +extends = "base" + +[permissions.workspace.network] +allow_local_binding = true + +[permissions.workspace.network.domains] +"child.example.com" = "allow" +"shared.example.com" = "allow" +"#, + ) + .expect("permissions profiles should parse"); + + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("permissions profiles should deserialize"), + ) + .expect("permissions profiles should select a network table") + .expect("network table should be present"); + + assert_eq!( + network, + NetworkToml { + enabled: Some(true), + dangerously_allow_all_unix_sockets: Some(true), + allow_local_binding: Some(true), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "base.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "child.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "shared.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ]), + }), + ..Default::default() + } + ); +} + +#[test] +fn config_from_layers_resolves_inherited_profiles_across_layers() { + let lower_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + [permissions.base.network.domains] + "base.example.com" = "allow" + } + .into(), + ); + let higher_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + default_permissions = "workspace" + + [permissions.workspace] + extends = "base" + + [permissions.workspace.network.domains] + "child.example.com" = "allow" + } + .into(), + ); + let layers = ConfigLayerStack::new( + vec![lower_layer, higher_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let config = + config_from_layers(&layers, &Policy::empty()).expect("inherited profiles should load"); + + assert_eq!( + config.network.allowed_domains(), + Some(vec![ + "base.example.com".to_string(), + "child.example.com".to_string(), + ]) + ); +} + #[test] fn apply_network_constraints_skips_empty_domain_sides() { let config: toml::Value = toml::from_str( diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 5335f7c46b00..35d1484d5e21 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -339,8 +339,8 @@ pub struct ActivePermissionProfile { /// profile. pub id: String, - /// Optional parent profile identifier once permissions profiles support - /// inheritance. This is always `None` until that config feature exists. + /// Optional parent profile identifier from the selected permissions + /// profile's `extends` setting. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub extends: Option,