diff --git a/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json index 84842fd194..62be0cbeb7 100644 --- a/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json @@ -65,6 +65,13 @@ ], "type": "string" }, + { + "description": "User has approved this permissions request for the current session and wants the granted subset persisted for future sessions.", + "enum": [ + "approved_for_always" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 845c5eb482..908974d5da 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -5673,6 +5673,13 @@ ], "type": "string" }, + { + "description": "User has approved this permissions request for the current session and wants the granted subset persisted for future sessions.", + "enum": [ + "approved_for_always" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", diff --git a/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json index baedafa403..6c40cf5933 100644 --- a/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json @@ -65,6 +65,13 @@ ], "type": "string" }, + { + "description": "User has approved this permissions request for the current session and wants the granted subset persisted for future sessions.", + "enum": [ + "approved_for_always" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index 2637ed5dda..74f440a4c6 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -149,7 +149,8 @@ "PermissionGrantScope": { "enum": [ "turn", - "session" + "session", + "alwaysAllow" ], "type": "string" } 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 228a49d351..638f628838 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 @@ -6517,7 +6517,8 @@ "PermissionGrantScope": { "enum": [ "turn", - "session" + "session", + "alwaysAllow" ], "type": "string" }, @@ -7012,6 +7013,13 @@ ], "type": "string" }, + { + "description": "User has approved this permissions request for the current session and wants the granted subset persisted for future sessions.", + "enum": [ + "approved_for_always" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", 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 b5fdebe7dd..15fd7c393b 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 @@ -10744,6 +10744,13 @@ ], "type": "string" }, + { + "description": "User has approved this permissions request for the current session and wants the granted subset persisted for future sessions.", + "enum": [ + "approved_for_always" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", diff --git a/codex-rs/app-server-protocol/schema/typescript/ReviewDecision.ts b/codex-rs/app-server-protocol/schema/typescript/ReviewDecision.ts index b5193785d8..8a7d7cb4b3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ReviewDecision.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ReviewDecision.ts @@ -7,4 +7,4 @@ import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; /** * User's decision in response to an ExecApprovalRequest. */ -export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "abort"; +export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | "approved_for_always" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "abort"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.ts index 8ca127ebcb..44dd796015 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PermissionGrantScope = "turn" | "session"; +export type PermissionGrantScope = "turn" | "session" | "alwaysAllow"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e557ef586d..863521d07e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -894,7 +894,9 @@ impl From for CommandExecutionApprovalDecision { } => Self::AcceptWithExecpolicyAmendment { execpolicy_amendment: proposed_execpolicy_amendment.into(), }, - CoreReviewDecision::ApprovedForSession => Self::AcceptForSession, + CoreReviewDecision::ApprovedForSession | CoreReviewDecision::ApprovedForAlways => { + Self::AcceptForSession + } CoreReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => Self::ApplyNetworkPolicyAmendment { @@ -5158,7 +5160,8 @@ v2_enum_from_core!( pub enum PermissionGrantScope from CorePermissionGrantScope { #[default] Turn, - Session + Session, + AlwaysAllow } ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 64de7c3f52..402316c0a2 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -906,7 +906,7 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov } ``` -The client responds with `result.permissions`, which should be the granted subset of the requested permission profile. It may also set `result.scope` to `"session"` to make the grant persist for later turns in the same session; omitted or `"turn"` keeps the existing turn-scoped behavior: +The client responds with `result.permissions`, which should be the granted subset of the requested permission profile. It may also set `result.scope` to `"session"` to make the grant persist for later turns in the same session, or `"alwaysAllow"` to persist the granted subset into the active `config.toml` permissions profile for future sessions too; omitted or `"turn"` keeps the existing turn-scoped behavior: ```json { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 573b35218a..173f341454 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -683,6 +683,58 @@ } ] }, + "MacOsAutomationPermission": { + "oneOf": [ + { + "enum": [ + "none", + "all" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "bundle_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "bundle_ids" + ], + "type": "object" + } + ] + }, + "MacOsPermissionsToml": { + "additionalProperties": false, + "properties": { + "accessibility": { + "type": "boolean" + }, + "automations": { + "$ref": "#/definitions/MacOsAutomationPermission" + }, + "calendar": { + "type": "boolean" + }, + "preferences": { + "$ref": "#/definitions/MacOsPreferencesPermission" + } + }, + "type": "object" + }, + "MacOsPreferencesPermission": { + "enum": [ + "none", + "read_only", + "read_write" + ], + "type": "string" + }, "MemoriesToml": { "additionalProperties": false, "description": "Memories settings loaded from config.toml.", @@ -1141,6 +1193,9 @@ "filesystem": { "$ref": "#/definitions/FilesystemPermissionsToml" }, + "macos": { + "$ref": "#/definitions/MacOsPermissionsToml" + }, "network": { "$ref": "#/definitions/NetworkToml" } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 842b256fdf..58621c6ee3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -158,6 +158,7 @@ use crate::config::Constrained; use crate::config::ConstraintResult; use crate::config::GhostSnapshotConfig; use crate::config::StartedNetworkProxy; +use crate::config::persist_granted_permission_profile; use crate::config::resolve_web_search_mode_for_turn; use crate::config::types::McpServerConfig; use crate::config::types::ShellEnvironmentPolicy; @@ -3089,6 +3090,7 @@ impl Session { response: RequestPermissionsResponse, ) { let mut granted_for_session = None; + let mut granted_always_allow = None; let entry = { let mut active = self.active_turn.lock().await; match active.as_mut() { @@ -3103,6 +3105,10 @@ impl Session { PermissionGrantScope::Session => { granted_for_session = Some(response.permissions.clone()); } + PermissionGrantScope::AlwaysAllow => { + granted_for_session = Some(response.permissions.clone()); + granted_always_allow = Some(response.permissions.clone()); + } } } entry @@ -3114,6 +3120,20 @@ impl Session { let mut state = self.state.lock().await; state.record_granted_permissions(permissions); } + if let Some(permissions) = granted_always_allow { + let config = self.get_config().await; + if let Err(err) = persist_granted_permission_profile( + self.codex_home().await.as_path(), + &config, + &permissions, + ) + .await + { + warn!("failed to persist granted permissions: {err}"); + } else { + self.reload_user_config_layer().await; + } + } match entry { Some(tx_response) => { tx_response.send(response).ok(); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 480b96f7c1..aadffc65ba 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::CodexAuth; +use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::test_config; use crate::config_loader::ConfigLayerStack; @@ -15,8 +16,14 @@ use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; use codex_protocol::ThreadId; +use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -25,6 +32,8 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; +use codex_utils_absolute_path::AbsolutePathBuf; +use tokio::sync::oneshot; use tracing::Span; use crate::protocol::CompactedItem; @@ -2400,6 +2409,66 @@ async fn request_permissions_returns_empty_grant_when_reject_policy_blocks_reque ); } +#[tokio::test] +async fn notify_request_permissions_response_persists_always_allow_permissions() { + let (session, _turn_context) = make_session_and_context().await; + let active_turn = ActiveTurn::default(); + let (tx, rx) = oneshot::channel(); + active_turn + .turn_state + .lock() + .await + .insert_pending_request_permissions("call-1".to_string(), tx); + *session.active_turn.lock().await = Some(active_turn); + + let permissions = PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: None, + write: Some(vec![ + AbsolutePathBuf::try_from(session.codex_home().await.join("allowed")) + .expect("absolute path"), + ]), + }), + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Calendar".to_string(), + ]), + macos_accessibility: true, + macos_calendar: false, + }), + }; + + session + .notify_request_permissions_response( + "call-1", + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: permissions.clone(), + scope: PermissionGrantScope::AlwaysAllow, + }, + ) + .await; + + assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + session.granted_session_permissions().await, + Some(permissions) + ); + assert_eq!( + rx.await.expect("response should be delivered").scope, + PermissionGrantScope::AlwaysAllow + ); + + let serialized = std::fs::read_to_string(session.codex_home().await.join(CONFIG_TOML_FILE)) + .expect("config.toml should exist"); + let config_toml: crate::config::ConfigToml = + toml::from_str(&serialized).expect("config.toml should parse"); + assert_eq!(config_toml.default_permissions.as_deref(), Some("default")); +} + #[tokio::test] async fn submit_with_id_captures_current_span_trace_context() { let (session, _turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 27126923aa..334b4aa695 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -14,6 +14,12 @@ use crate::config_loader::RequirementSource; use crate::features::Feature; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -279,6 +285,7 @@ allowed_domains = ["openai.com"] allow_unix_sockets: None, allow_local_binding: None, }), + macos: None, }, )]), } @@ -310,6 +317,7 @@ fn permissions_profiles_network_populates_runtime_network_proxy_spec() -> std::i enable_socks5: Some(false), ..Default::default() }), + macos: None, }, )]), }), @@ -355,6 +363,7 @@ fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> st allowed_domains: Some(vec!["openai.com".to_string()]), ..Default::default() }), + macos: None, }, )]), }), @@ -400,6 +409,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re ]), }), network: None, + macos: None, }, )]), }), @@ -486,6 +496,7 @@ fn permissions_profiles_require_default_permissions() -> std::io::Result<()> { )]), }), network: None, + macos: None, }, )]), }), @@ -528,6 +539,7 @@ fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Resul )]), }), network: None, + macos: None, }, )]), }), @@ -573,6 +585,7 @@ fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> std::io )]), }), network: None, + macos: None, }, )]), }), @@ -767,6 +780,7 @@ fn permissions_profiles_reject_project_root_parent_traversal() -> std::io::Resul )]), }), network: None, + macos: None, }, )]), }), @@ -811,6 +825,7 @@ fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> { enabled: Some(true), ..Default::default() }), + macos: None, }, )]), }), @@ -4717,3 +4732,211 @@ fn test_tui_notification_method() { toml::from_str(toml).expect("deserialize notification_method=\"bel\""); assert_eq!(parsed.tui.notification_method, NotificationMethod::Bel); } + +#[test] +fn config_toml_deserializes_permissions_profile_macos_and_special_paths() { + let toml = r#" +default_permissions = "workspace" + +[permissions.workspace.filesystem] +":cwd" = "write" +":slash_tmp" = "write" + +[permissions.workspace.macos] +preferences = "read_write" +automations = ["com.apple.Calendar"] +accessibility = true +calendar = true +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for macOS permissions"); + let permissions = cfg.permissions.expect("[permissions] should deserialize"); + let workspace = permissions + .entries + .get("workspace") + .expect("workspace profile should exist"); + + assert_eq!( + workspace.filesystem, + Some(FilesystemPermissionsToml { + entries: BTreeMap::from([ + ( + ":cwd".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ), + ( + ":slash_tmp".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ), + ]), + }) + ); + assert_eq!( + workspace.macos, + Some(MacOsPermissionsToml { + preferences: Some(MacOsPreferencesPermission::ReadWrite), + automations: Some(MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Calendar".to_string(), + ])), + accessibility: Some(true), + calendar: Some(true), + }) + ); +} + +#[test] +fn default_permissions_profile_populates_runtime_macos_extensions() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.macos] +preferences = "read_write" +automations = ["com.apple.Calendar"] +accessibility = true +calendar = true +"#, + ) + .expect("config should deserialize"), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.permissions.macos_seatbelt_profile_extensions, + Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Calendar".to_string(), + ]), + macos_accessibility: true, + macos_calendar: true, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn persist_granted_permission_profile_creates_default_profile_from_legacy_config() +-> anyhow::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( + toml::from_str( + r#" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true +exclude_tmpdir_env_var = false +exclude_slash_tmp = false +"#, + ) + .expect("config should deserialize"), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + let profile_name = persist_granted_permission_profile( + codex_home.path(), + &config, + &PermissionProfile { + file_system: Some(FileSystemPermissions { + read: None, + write: Some(vec![AbsolutePathBuf::try_from(cwd.path().join("logs"))?]), + }), + network: Some(NetworkPermissions { + enabled: Some(true), + }), + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Calendar".to_string(), + ]), + macos_accessibility: true, + macos_calendar: false, + }), + }, + ) + .await?; + + assert_eq!(profile_name, "default"); + + let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + + assert_eq!(parsed.default_permissions.as_deref(), Some("default")); + let profile = parsed + .permissions + .expect("permissions should be written") + .entries + .get("default") + .cloned() + .expect("default profile should exist"); + let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories"))?; + + assert_eq!( + profile.filesystem, + Some(FilesystemPermissionsToml { + entries: BTreeMap::from([ + ( + ":root".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + ), + ( + ":cwd".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ), + ( + ":slash_tmp".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ), + ( + ":tmpdir".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ), + ( + memories_root.to_string_lossy().to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ), + ( + AbsolutePathBuf::try_from(cwd.path().join("logs"))? + .to_string_lossy() + .to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ), + ]), + }) + ); + assert_eq!( + profile.network, + Some(NetworkToml { + enabled: Some(true), + ..Default::default() + }) + ); + assert_eq!( + profile.macos, + Some(MacOsPermissionsToml { + preferences: Some(MacOsPreferencesPermission::ReadWrite), + automations: Some(MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Calendar".to_string(), + ])), + accessibility: Some(true), + calendar: Some(false), + }) + ); + + Ok(()) +} diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index cf139d3a5e..947bf5f458 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1,3 +1,5 @@ +use crate::config::PermissionProfileToml; +use crate::config::permissions::merge_permission_profile_toml; use crate::config::types::McpServerConfig; use crate::config::types::Notice; use crate::path_utils::resolve_symlink_write_paths; @@ -55,6 +57,12 @@ pub enum ConfigEdit { segments: Vec, value: TomlItem, }, + /// Merge a permissions profile and optionally set it as the default. + SetPermissionProfile { + name: String, + profile: PermissionProfileToml, + set_as_default: bool, + }, /// Remove the value stored at the exact dotted path. ClearPath { segments: Vec }, } @@ -375,6 +383,11 @@ impl ConfigDocument { Ok(self.set_skill_config(path.as_path(), *enabled)) } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), + ConfigEdit::SetPermissionProfile { + name, + profile, + set_as_default, + } => self.set_permission_profile(name, profile, *set_as_default), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { // Delegate to the existing, tested logic in config.rs to @@ -463,6 +476,45 @@ impl ConfigDocument { true } + fn set_permission_profile( + &mut self, + name: &str, + profile: &PermissionProfileToml, + set_as_default: bool, + ) -> anyhow::Result { + let root = self.doc.as_table_mut(); + if !root.contains_key("permissions") { + root.insert( + "permissions", + TomlItem::Table(document_helpers::new_implicit_table()), + ); + } + + let Some(permissions_item) = root.get_mut("permissions") else { + return Ok(false); + }; + let Some(permissions_table) = document_helpers::ensure_table_for_write(permissions_item) + else { + return Ok(false); + }; + + let merged_profile = if let Some(existing) = permissions_table.get(name) { + let existing = deserialize_permission_profile(existing)?; + merge_permission_profile_toml(Some(&existing), profile) + } else { + profile.clone() + }; + + permissions_table[name] = serialize_permission_profile(&merged_profile)?; + + let mut mutated = true; + if set_as_default { + mutated |= self.write_value(Scope::Global, &["default_permissions"], value(name)); + } + + Ok(mutated) + } + fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool { let normalized_path = normalize_skill_config_path(path); let mut remove_skills_table = false; @@ -677,6 +729,18 @@ impl ConfigDocument { } } +fn deserialize_permission_profile(item: &TomlItem) -> anyhow::Result { + toml::from_str(&item.to_string()).context("failed to deserialize permission profile") +} + +fn serialize_permission_profile(profile: &PermissionProfileToml) -> anyhow::Result { + let serialized = toml::to_string(profile).context("failed to serialize permission profile")?; + let doc = serialized + .parse::() + .context("failed to parse serialized permission profile")?; + Ok(TomlItem::Table(doc.as_table().clone())) +} + fn normalize_skill_config_path(path: &Path) -> String { dunce::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -908,6 +972,20 @@ impl ConfigEditsBuilder { self } + pub fn set_permission_profile( + mut self, + name: &str, + profile: &PermissionProfileToml, + set_as_default: bool, + ) -> Self { + self.edits.push(ConfigEdit::SetPermissionProfile { + name: name.to_string(), + profile: profile.clone(), + set_as_default, + }); + self + } + pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self { for key in [ "experimental_windows_sandbox", diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 624fef72db..dea3f37ed5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -73,6 +73,7 @@ use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemSandboxPolicy; @@ -92,7 +93,10 @@ use std::path::Path; use std::path::PathBuf; use crate::config::permissions::compile_permission_profile; +use crate::config::permissions::merge_permission_profile_toml; use crate::config::permissions::network_proxy_config_from_profile_network; +use crate::config::permissions::permission_profile_toml_from_effective_permissions; +use crate::config::permissions::permission_profile_toml_from_runtime_permissions; use crate::config::profile::ConfigProfile; use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; @@ -116,6 +120,7 @@ pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub use permissions::FilesystemPermissionToml; pub use permissions::FilesystemPermissionsToml; +pub use permissions::MacOsPermissionsToml; pub use permissions::NetworkToml; pub use permissions::PermissionProfileToml; pub use permissions::PermissionsToml; @@ -1954,6 +1959,7 @@ impl Config { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + macos_seatbelt_profile_extensions, ) = if profiles_are_active { let permissions = cfg.permissions.as_ref().ok_or_else(|| { std::io::Error::new( @@ -1970,12 +1976,11 @@ impl Config { 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, - &mut startup_warnings, - )?; + let ( + mut file_system_sandbox_policy, + network_sandbox_policy, + macos_seatbelt_profile_extensions, + ) = 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 { .. }) { @@ -1991,6 +1996,7 @@ impl Config { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + macos_seatbelt_profile_extensions, ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); @@ -2016,6 +2022,7 @@ impl Config { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + None, ) }; let approval_policy_was_explicit = approval_policy_override.is_some() @@ -2366,7 +2373,7 @@ impl Config { allow_login_shell, shell_environment_policy, windows_sandbox_mode, - macos_seatbelt_profile_extensions: None, + macos_seatbelt_profile_extensions, }, enforce_residency: enforce_residency.value, notify: cfg.notify, @@ -2687,6 +2694,92 @@ impl Config { } } +pub(crate) fn default_permissions_profile_name(config: &Config) -> Option { + config + .config_layer_stack + .effective_config() + .clone() + .try_into::() + .ok() + .and_then(|selection| selection.default_permissions) +} + +pub(crate) fn next_default_permissions_profile_name(config: &Config) -> String { + let permissions = config + .config_layer_stack + .effective_config() + .clone() + .try_into::() + .ok() + .and_then(|config_toml| config_toml.permissions) + .unwrap_or_default(); + + let base = "default".to_string(); + if !permissions.entries.contains_key(&base) { + return base; + } + + let mut suffix = 2; + loop { + let candidate = format!("default-{suffix}"); + if !permissions.entries.contains_key(&candidate) { + return candidate; + } + suffix += 1; + } +} + +pub(crate) async fn persist_granted_permission_profile( + codex_home: &Path, + config: &Config, + granted_permissions: &PermissionProfile, +) -> anyhow::Result { + let granted_profile = permission_profile_toml_from_runtime_permissions(granted_permissions); + let default_permissions = default_permissions_profile_name(config); + let (profile_name, profile) = if let Some(profile_name) = default_permissions { + let existing = config + .config_layer_stack + .effective_config() + .clone() + .try_into::() + .ok() + .and_then(|config_toml| config_toml.permissions) + .and_then(|permissions| permissions.entries.get(&profile_name).cloned()); + ( + profile_name, + merge_permission_profile_toml(existing.as_ref(), &granted_profile), + ) + } else { + let profile_name = next_default_permissions_profile_name(config); + let default_network_config = NetworkProxyConfig::default(); + let network_config = config + .permissions + .network + .as_ref() + .map(NetworkProxySpec::config) + .unwrap_or(&default_network_config); + let baseline_profile = permission_profile_toml_from_effective_permissions( + &config.permissions.file_system_sandbox_policy, + network_config, + config + .permissions + .macos_seatbelt_profile_extensions + .as_ref(), + )?; + ( + profile_name, + merge_permission_profile_toml(Some(&baseline_profile), &granted_profile), + ) + }; + + ConfigEditsBuilder::new(codex_home) + .set_permission_profile(&profile_name, &profile, true) + .apply() + .await?; + + Ok(profile_name) +} + pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { config_layer_stack .layers_high_to_low() diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 9c70cf084c..300fe6271a 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -73,6 +73,10 @@ impl NetworkProxySpec { self.config.network.enabled } + pub(crate) fn config(&self) -> &NetworkProxyConfig { + &self.config + } + pub fn proxy_host_and_port(&self) -> String { host_and_port_from_network_addr(&self.config.network.proxy_url, 3128) } diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b931c0f415..4ce553b48f 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -7,6 +7,12 @@ use std::path::PathBuf; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -35,6 +41,7 @@ impl PermissionsToml { pub struct PermissionProfileToml { pub filesystem: Option, pub network: Option, + pub macos: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] @@ -75,6 +82,15 @@ pub struct NetworkToml { pub allow_local_binding: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct MacOsPermissionsToml { + pub preferences: Option, + pub automations: Option, + pub accessibility: Option, + pub calendar: Option, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "lowercase")] enum NetworkModeSchema { @@ -160,7 +176,11 @@ pub(crate) fn compile_permission_profile( permissions: &PermissionsToml, profile_name: &str, startup_warnings: &mut Vec, -) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { +) -> io::Result<( + FileSystemSandboxPolicy, + NetworkSandboxPolicy, + Option, +)> { let profile = resolve_permission_profile(permissions, profile_name)?; let mut entries = Vec::new(); @@ -183,13 +203,403 @@ pub(crate) fn compile_permission_profile( } let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref()); + let macos_seatbelt_profile_extensions = profile.macos.as_ref().map(macos_permissions_from_toml); Ok(( FileSystemSandboxPolicy::restricted(entries), network_sandbox_policy, + macos_seatbelt_profile_extensions, )) } +pub(crate) fn merge_permission_profile_toml( + base: Option<&PermissionProfileToml>, + permissions: &PermissionProfileToml, +) -> PermissionProfileToml { + let filesystem = match ( + base.and_then(|base| base.filesystem.as_ref()), + permissions.filesystem.as_ref(), + ) { + (Some(base), Some(permissions)) => { + Some(merge_filesystem_permissions_toml(base, permissions)) + } + (Some(base), None) => Some(base.clone()), + (None, Some(permissions)) => Some(permissions.clone()), + (None, None) => None, + }; + let network = match ( + base.and_then(|base| base.network.as_ref()), + permissions.network.as_ref(), + ) { + (Some(base), Some(permissions)) => Some(merge_network_toml(base, permissions)), + (Some(base), None) => Some(base.clone()), + (None, Some(permissions)) => Some(permissions.clone()), + (None, None) => None, + }; + let macos = match ( + base.and_then(|base| base.macos.as_ref()), + permissions.macos.as_ref(), + ) { + (Some(base), Some(permissions)) => Some(merge_macos_permissions_toml(base, permissions)), + (Some(base), None) => Some(base.clone()), + (None, Some(permissions)) => Some(permissions.clone()), + (None, None) => None, + }; + + PermissionProfileToml { + filesystem, + network, + macos, + } +} + +pub(crate) fn permission_profile_toml_from_runtime_permissions( + permissions: &PermissionProfile, +) -> PermissionProfileToml { + let filesystem = permissions + .file_system + .as_ref() + .map(filesystem_permissions_toml_from_runtime) + .filter(|filesystem| !filesystem.is_empty()); + let network = permissions + .network + .as_ref() + .map(network_toml_from_runtime) + .filter(network_toml_has_values); + let macos = permissions + .macos + .as_ref() + .map(macos_permissions_toml_from_runtime) + .filter(macos_permissions_toml_has_values); + + PermissionProfileToml { + filesystem, + network, + macos, + } +} + +pub(crate) fn permission_profile_toml_from_effective_permissions( + file_system: &FileSystemSandboxPolicy, + network: &NetworkProxyConfig, + macos: Option<&MacOsSeatbeltProfileExtensions>, +) -> io::Result { + let filesystem = filesystem_permissions_toml_from_policy(file_system)?; + let network = network_toml_from_proxy_config(network); + let macos = macos + .map(macos_permissions_toml_from_runtime) + .filter(macos_permissions_toml_has_values); + + Ok(PermissionProfileToml { + filesystem: Some(filesystem).filter(|filesystem| !filesystem.is_empty()), + network: Some(network).filter(network_toml_has_values), + macos, + }) +} + +fn filesystem_permissions_toml_from_runtime( + permissions: &FileSystemPermissions, +) -> FilesystemPermissionsToml { + let mut entries = BTreeMap::new(); + + if let Some(read) = permissions.read.as_ref() { + for path in read { + merge_filesystem_permission_entry( + &mut entries, + path.to_string_lossy().as_ref(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + ); + } + } + if let Some(write) = permissions.write.as_ref() { + for path in write { + merge_filesystem_permission_entry( + &mut entries, + path.to_string_lossy().as_ref(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + ); + } + } + + FilesystemPermissionsToml { entries } +} + +fn filesystem_permissions_toml_from_policy( + policy: &FileSystemSandboxPolicy, +) -> io::Result { + let mut entries = BTreeMap::new(); + + for entry in &policy.entries { + let (path, permission) = filesystem_permission_toml_from_entry(entry)?; + merge_filesystem_permission_entry(&mut entries, &path, permission); + } + + Ok(FilesystemPermissionsToml { entries }) +} + +fn filesystem_permission_toml_from_entry( + entry: &FileSystemSandboxEntry, +) -> io::Result<(String, FilesystemPermissionToml)> { + let access = entry.access; + match &entry.path { + FileSystemPath::Path { path } => Ok(( + path.to_string_lossy().to_string(), + FilesystemPermissionToml::Access(access), + )), + FileSystemPath::Special { value } => match value { + FileSystemSpecialPath::Root => Ok(( + ":root".to_string(), + FilesystemPermissionToml::Access(access), + )), + FileSystemSpecialPath::Minimal => Ok(( + ":minimal".to_string(), + FilesystemPermissionToml::Access(access), + )), + FileSystemSpecialPath::CurrentWorkingDirectory => { + Ok((":cwd".to_string(), FilesystemPermissionToml::Access(access))) + } + FileSystemSpecialPath::ProjectRoots { subpath: None } => Ok(( + ":project_roots".to_string(), + FilesystemPermissionToml::Access(access), + )), + FileSystemSpecialPath::ProjectRoots { + subpath: Some(subpath), + } => Ok(( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([( + subpath.to_string_lossy().to_string(), + access, + )])), + )), + FileSystemSpecialPath::Tmpdir => Ok(( + ":tmpdir".to_string(), + FilesystemPermissionToml::Access(access), + )), + FileSystemSpecialPath::SlashTmp => Ok(( + ":slash_tmp".to_string(), + FilesystemPermissionToml::Access(access), + )), + }, + } +} + +fn network_toml_from_runtime(permissions: &NetworkPermissions) -> NetworkToml { + NetworkToml { + enabled: permissions.enabled, + ..Default::default() + } +} + +fn network_toml_from_proxy_config(config: &NetworkProxyConfig) -> NetworkToml { + let defaults = NetworkProxyConfig::default(); + NetworkToml { + enabled: config.network.enabled.then_some(true), + proxy_url: (config.network.proxy_url != defaults.network.proxy_url) + .then(|| config.network.proxy_url.clone()), + enable_socks5: (config.network.enable_socks5 != defaults.network.enable_socks5) + .then_some(config.network.enable_socks5), + socks_url: (config.network.socks_url != defaults.network.socks_url) + .then(|| config.network.socks_url.clone()), + enable_socks5_udp: (config.network.enable_socks5_udp != defaults.network.enable_socks5_udp) + .then_some(config.network.enable_socks5_udp), + allow_upstream_proxy: (config.network.allow_upstream_proxy + != defaults.network.allow_upstream_proxy) + .then_some(config.network.allow_upstream_proxy), + dangerously_allow_non_loopback_proxy: (config.network.dangerously_allow_non_loopback_proxy + != defaults.network.dangerously_allow_non_loopback_proxy) + .then_some(config.network.dangerously_allow_non_loopback_proxy), + dangerously_allow_all_unix_sockets: (config.network.dangerously_allow_all_unix_sockets + != defaults.network.dangerously_allow_all_unix_sockets) + .then_some(config.network.dangerously_allow_all_unix_sockets), + mode: (config.network.mode != defaults.network.mode).then_some(config.network.mode), + allowed_domains: Some(config.network.allowed_domains.clone()) + .filter(|allowed_domains| !allowed_domains.is_empty()), + denied_domains: Some(config.network.denied_domains.clone()) + .filter(|denied_domains| !denied_domains.is_empty()), + allow_unix_sockets: Some(config.network.allow_unix_sockets.clone()) + .filter(|allow_unix_sockets| !allow_unix_sockets.is_empty()), + allow_local_binding: (config.network.allow_local_binding + != defaults.network.allow_local_binding) + .then_some(config.network.allow_local_binding), + } +} + +fn macos_permissions_toml_from_runtime( + permissions: &MacOsSeatbeltProfileExtensions, +) -> MacOsPermissionsToml { + MacOsPermissionsToml { + preferences: Some(permissions.macos_preferences.clone()), + automations: Some(permissions.macos_automation.clone()), + accessibility: Some(permissions.macos_accessibility), + calendar: Some(permissions.macos_calendar), + } +} + +fn macos_permissions_from_toml( + permissions: &MacOsPermissionsToml, +) -> MacOsSeatbeltProfileExtensions { + MacOsSeatbeltProfileExtensions { + macos_preferences: permissions.preferences.clone().unwrap_or_default(), + macos_automation: permissions.automations.clone().unwrap_or_default(), + macos_accessibility: permissions.accessibility.unwrap_or(false), + macos_calendar: permissions.calendar.unwrap_or(false), + } +} + +fn merge_filesystem_permissions_toml( + base: &FilesystemPermissionsToml, + permissions: &FilesystemPermissionsToml, +) -> FilesystemPermissionsToml { + let mut entries = base.entries.clone(); + for (path, permission) in &permissions.entries { + merge_filesystem_permission_entry(&mut entries, path, permission.clone()); + } + FilesystemPermissionsToml { entries } +} + +fn merge_filesystem_permission_entry( + entries: &mut BTreeMap, + path: &str, + permission: FilesystemPermissionToml, +) { + entries + .entry(path.to_string()) + .and_modify(|existing| { + *existing = merge_filesystem_permission_toml(existing.clone(), permission.clone()); + }) + .or_insert(permission); +} + +fn merge_filesystem_permission_toml( + base: FilesystemPermissionToml, + permissions: FilesystemPermissionToml, +) -> FilesystemPermissionToml { + match (base, permissions) { + (FilesystemPermissionToml::Access(base), FilesystemPermissionToml::Access(permissions)) => { + FilesystemPermissionToml::Access(more_permissive_access(base, permissions)) + } + (FilesystemPermissionToml::Scoped(base), FilesystemPermissionToml::Scoped(permissions)) => { + FilesystemPermissionToml::Scoped(merge_scoped_permissions(base, permissions)) + } + (FilesystemPermissionToml::Access(base), FilesystemPermissionToml::Scoped(permissions)) => { + FilesystemPermissionToml::Scoped(merge_scoped_permissions( + BTreeMap::from([(".".to_string(), base)]), + permissions, + )) + } + (FilesystemPermissionToml::Scoped(base), FilesystemPermissionToml::Access(permissions)) => { + FilesystemPermissionToml::Scoped(merge_scoped_permissions( + base, + BTreeMap::from([(".".to_string(), permissions)]), + )) + } + } +} + +fn merge_scoped_permissions( + mut base: BTreeMap, + permissions: BTreeMap, +) -> BTreeMap { + for (subpath, access) in permissions { + base.entry(subpath) + .and_modify(|existing| *existing = more_permissive_access(*existing, access)) + .or_insert(access); + } + base +} + +fn more_permissive_access( + base: FileSystemAccessMode, + permissions: FileSystemAccessMode, +) -> FileSystemAccessMode { + match (base, permissions) { + (FileSystemAccessMode::Write, _) | (_, FileSystemAccessMode::Write) => { + FileSystemAccessMode::Write + } + (FileSystemAccessMode::Read, _) | (_, FileSystemAccessMode::Read) => { + FileSystemAccessMode::Read + } + _ => FileSystemAccessMode::None, + } +} + +fn merge_network_toml(base: &NetworkToml, permissions: &NetworkToml) -> NetworkToml { + NetworkToml { + enabled: permissions.enabled.or(base.enabled), + proxy_url: permissions + .proxy_url + .clone() + .or_else(|| base.proxy_url.clone()), + enable_socks5: permissions.enable_socks5.or(base.enable_socks5), + socks_url: permissions + .socks_url + .clone() + .or_else(|| base.socks_url.clone()), + enable_socks5_udp: permissions.enable_socks5_udp.or(base.enable_socks5_udp), + allow_upstream_proxy: permissions + .allow_upstream_proxy + .or(base.allow_upstream_proxy), + dangerously_allow_non_loopback_proxy: permissions + .dangerously_allow_non_loopback_proxy + .or(base.dangerously_allow_non_loopback_proxy), + dangerously_allow_all_unix_sockets: permissions + .dangerously_allow_all_unix_sockets + .or(base.dangerously_allow_all_unix_sockets), + mode: permissions.mode.or(base.mode), + allowed_domains: permissions + .allowed_domains + .clone() + .or_else(|| base.allowed_domains.clone()), + denied_domains: permissions + .denied_domains + .clone() + .or_else(|| base.denied_domains.clone()), + allow_unix_sockets: permissions + .allow_unix_sockets + .clone() + .or_else(|| base.allow_unix_sockets.clone()), + allow_local_binding: permissions.allow_local_binding.or(base.allow_local_binding), + } +} + +fn merge_macos_permissions_toml( + base: &MacOsPermissionsToml, + permissions: &MacOsPermissionsToml, +) -> MacOsPermissionsToml { + let base_runtime = macos_permissions_from_toml(base); + let permissions_runtime = macos_permissions_from_toml(permissions); + macos_permissions_toml_from_runtime( + &crate::sandboxing::macos_permissions::merge_macos_seatbelt_profile_extensions( + Some(&base_runtime), + Some(&permissions_runtime), + ) + .unwrap_or_default(), + ) +} + +fn network_toml_has_values(network: &NetworkToml) -> bool { + network.enabled.is_some() + || network.proxy_url.is_some() + || network.enable_socks5.is_some() + || network.socks_url.is_some() + || network.enable_socks5_udp.is_some() + || network.allow_upstream_proxy.is_some() + || network.dangerously_allow_non_loopback_proxy.is_some() + || network.dangerously_allow_all_unix_sockets.is_some() + || network.mode.is_some() + || network.allowed_domains.is_some() + || network.denied_domains.is_some() + || network.allow_unix_sockets.is_some() + || network.allow_local_binding.is_some() +} + +fn macos_permissions_toml_has_values(permissions: &MacOsPermissionsToml) -> bool { + permissions.preferences.is_some() + || permissions.automations.is_some() + || permissions.accessibility.is_some() + || permissions.calendar.is_some() +} + fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy { let Some(network) = network else { return NetworkSandboxPolicy::Restricted; @@ -281,8 +691,10 @@ fn parse_special_path(path: &str) -> Option { match path { ":root" => Some(FileSystemSpecialPath::Root), ":minimal" => Some(FileSystemSpecialPath::Minimal), + ":cwd" => Some(FileSystemSpecialPath::CurrentWorkingDirectory), ":project_roots" => Some(FileSystemSpecialPath::project_roots(None)), ":tmpdir" => Some(FileSystemSpecialPath::Tmpdir), + ":slash_tmp" => Some(FileSystemSpecialPath::SlashTmp), _ if path.starts_with(':') => Some(FileSystemSpecialPath::unknown(path, None)), _ => None, } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 29d1de5b6d..51bdd0b971 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -600,7 +600,9 @@ fn mcp_tool_approval_decision_from_guardian(decision: ReviewDecision) -> McpTool ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept, - ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession, + ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedForAlways => { + McpToolApprovalDecision::AcceptForSession + } ReviewDecision::Denied | ReviewDecision::Abort => McpToolApprovalDecision::Decline, } } diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 78f1bb0f44..d0b322ceda 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -378,7 +378,9 @@ impl NetworkApprovalService { ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { PendingApprovalDecision::AllowOnce } - ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession, + ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedForAlways => { + PendingApprovalDecision::AllowForSession + } ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => match network_policy_amendment.action { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 73c2da3bd7..fca2747d8e 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -152,7 +152,8 @@ impl ToolOrchestrator { } ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } - | ReviewDecision::ApprovedForSession => {} + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedForAlways => {} ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => match network_policy_amendment.action { @@ -297,7 +298,8 @@ impl ToolOrchestrator { } ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } - | ReviewDecision::ApprovedForSession => {} + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedForAlways => {} ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => match network_policy_amendment.action { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 04732b00c7..56219ea49e 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -503,7 +503,8 @@ impl CoreShellActionProvider { .await? { ReviewDecision::Approved - | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { + | ReviewDecision::ApprovedExecpolicyAmendment { .. } + | ReviewDecision::ApprovedForAlways => { if needs_escalation { EscalationDecision::escalate(escalation_execution.clone()) } else { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 935b162b26..06dbf31abe 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -80,8 +80,12 @@ where let already_approved = { let store = services.tool_approvals.lock().await; - keys.iter() - .all(|key| matches!(store.get(key), Some(ReviewDecision::ApprovedForSession))) + keys.iter().all(|key| { + matches!( + store.get(key), + Some(ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedForAlways) + ) + }) }; if already_approved { @@ -99,7 +103,10 @@ where ], ); - if matches!(decision, ReviewDecision::ApprovedForSession) { + if matches!( + decision, + ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedForAlways + ) { let mut store = services.tool_approvals.lock().await; for key in keys { store.put(key, ReviewDecision::ApprovedForSession); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 98ff8f7f55..cf3f997c6a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3042,6 +3042,10 @@ pub enum ReviewDecision { /// remainder of the session. ApprovedForSession, + /// User has approved this permissions request for the current session and + /// wants the granted subset persisted for future sessions. + ApprovedForAlways, + /// User chose to persist a network policy rule (allow/deny) for future /// requests to the same host. NetworkPolicyAmendment { @@ -3066,6 +3070,7 @@ impl ReviewDecision { ReviewDecision::Approved => "approved", ReviewDecision::ApprovedExecpolicyAmendment { .. } => "approved_with_amendment", ReviewDecision::ApprovedForSession => "approved_for_session", + ReviewDecision::ApprovedForAlways => "approved_for_always", ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => match network_policy_amendment.action { diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index fc66e9b3db..329d9d2497 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -10,6 +10,7 @@ pub enum PermissionGrantScope { #[default] Turn, Session, + AlwaysAllow, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index a13252939d..b3167bafca 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -278,19 +278,23 @@ impl ApprovalOverlay { return; }; let granted_permissions = match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => permissions.clone(), + ReviewDecision::Approved + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedForAlways => permissions.clone(), ReviewDecision::Denied | ReviewDecision::Abort => Default::default(), ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => Default::default(), }; - let scope = if matches!(decision, ReviewDecision::ApprovedForSession) { - PermissionGrantScope::Session - } else { - PermissionGrantScope::Turn + let scope = match decision { + ReviewDecision::ApprovedForSession => PermissionGrantScope::Session, + ReviewDecision::ApprovedForAlways => PermissionGrantScope::AlwaysAllow, + _ => PermissionGrantScope::Turn, }; if request.thread_label().is_none() { let message = if granted_permissions.is_empty() { "You did not grant additional permissions" + } else if matches!(scope, PermissionGrantScope::AlwaysAllow) { + "You granted additional permissions and saved them for future sessions" } else if matches!(scope, PermissionGrantScope::Session) { "You granted additional permissions for this session" } else { @@ -703,6 +707,14 @@ fn exec_options( display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }), + ReviewDecision::ApprovedForAlways => { + additional_permissions.as_ref().map(|_| ApprovalOption { + label: "Yes, and always allow these permissions".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedForAlways), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + }) + } ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => { @@ -846,6 +858,12 @@ fn permissions_options() -> Vec { display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }, + ApprovalOption { + label: "Yes, always allow these permissions".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedForAlways), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + }, ApprovalOption { label: "No, continue without permissions".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Denied), @@ -1260,6 +1278,7 @@ mod tests { vec![ "Yes, grant these permissions".to_string(), "Yes, grant these permissions for this session".to_string(), + "Yes, always allow these permissions".to_string(), "No, continue without permissions".to_string(), ] ); @@ -1292,6 +1311,33 @@ mod tests { ); } + #[test] + fn permissions_always_shortcut_submits_always_allow_scope() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = + ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::RequestPermissionsResponse { response, .. }, + .. + } = ev + { + assert_eq!(response.scope, PermissionGrantScope::AlwaysAllow); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected permission approval decision to emit an always-allow response" + ); + } + #[test] fn additional_permissions_prompt_shows_permission_rule_line() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap index e7a21c42cc..c71f291149 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap @@ -11,6 +11,7 @@ expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" › 1. Yes, grant these permissions (y) 2. Yes, grant these permissions for this session (a) - 3. No, continue without permissions (n) + 3. Yes, always allow these permissions (p) + 4. No, continue without permissions (n) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index e6feef2cfd..aa65793f33 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -833,6 +833,19 @@ pub fn new_approval_decision_cell( ], ) } + ApprovedForAlways => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " every time and saved it".bold(), + ], + ) + } NetworkPolicyAmendment { network_policy_amendment, } => match network_policy_amendment.action {