diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 321e3ef892..ba04c2b693 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -30,6 +30,7 @@ use codex_protocol::SanitizedGitUrl; use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErrorDetails; use codex_protocol::mcp::ClientMcpExtensions; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadHistoryMode; use codex_thread_store::PersistContext; @@ -96,6 +97,7 @@ struct ResumeConfigState { workspace_roots: Option>, persisted_metadata: Option, persisted_settings: Option, + persisted_permission_profile: Option, } struct PreparedResumeConfig { @@ -4131,7 +4133,7 @@ impl ThreadRequestProcessor { } let has_explicit_model_resume_override = has_model_resume_override(request_overrides.as_ref(), &typesafe_overrides); - let persisted_permission_profile = history_settings + typesafe_overrides.persisted_permission_profile = history_settings .filter(|settings| { settings.active_permission_profile.is_none() && !has_permission_override(request_overrides.as_ref(), &typesafe_overrides) @@ -4170,6 +4172,7 @@ impl ThreadRequestProcessor { workspace_roots: typesafe_overrides.workspace_roots.clone(), persisted_metadata, persisted_settings, + persisted_permission_profile: typesafe_overrides.persisted_permission_profile.clone(), }; let mut config = match prepared_config.take() { Some(prepared) if prepared.state == config_state => prepared.config, @@ -4200,7 +4203,6 @@ impl ThreadRequestProcessor { .thread_manager .start_thread(StartThreadOptions { initial_history: thread_history, - persisted_permission_profile, parent_trace: match target { ThreadResumeTarget::Client(request_id) => { self.request_trace_context(request_id).await diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index b5f3d24c79..8ef7965f48 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1817,6 +1817,8 @@ async fn cold_resume_permission_overrides_win_over_persisted_profile() -> Result // Unelevated Windows sandboxes cannot enforce managed deny-read. #[cfg_attr(unix, test_case( + SandboxMode::DangerFullAccess, + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, "[permissions.filesystem]\ndeny_read = [\"./private\"]", AppSandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), @@ -1826,19 +1828,37 @@ async fn cold_resume_permission_overrides_win_over_persisted_profile() -> Result }; "managed deny-read" ))] -#[test_case("", AppSandboxPolicy::DangerFullAccess; "restore saved full access")] #[test_case( + SandboxMode::DangerFullAccess, + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + "", AppSandboxPolicy::DangerFullAccess; + "restore saved full access" +)] +#[test_case( + SandboxMode::DangerFullAccess, + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, "allowed_sandbox_modes = [\"read-only\"]", AppSandboxPolicy::ReadOnly { network_access: false }; "managed modes" )] #[test_case( + SandboxMode::DangerFullAccess, + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, "default_permissions = \":read-only\"\n[allowed_permission_profiles]\n\":read-only\" = true", AppSandboxPolicy::ReadOnly { network_access: false }; "managed catalog" )] +#[test_case( + SandboxMode::ReadOnly, + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, + "allowed_sandbox_modes = [\"read-only\"]", + AppSandboxPolicy::ReadOnly { network_access: false }; + "saved read-only wins before incompatible default validation" +)] #[tokio::test] async fn cold_resume_without_active_permission_profile_restores_base_permissions( + saved_sandbox: SandboxMode, + configured_default: &str, requirements: &str, expected: AppSandboxPolicy, ) -> Result<()> { @@ -1857,7 +1877,7 @@ async fn cold_resume_without_active_permission_profile_restores_base_permissions let mut mcp = builder.build_initialized().await?; let params = ThreadStartParams { model: Some("mock-model".to_string()), - sandbox: Some(SandboxMode::DangerFullAccess), + sandbox: Some(saved_sandbox), approval_policy: Some(AskForApproval::Never), ..Default::default() }; @@ -1873,7 +1893,7 @@ async fn cold_resume_without_active_permission_profile_restores_base_permissions mcp.start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread.id.clone(), input: vec![UserInput::Text { - text: "persist full access".to_string(), + text: "persist permissions".to_string(), text_elements: Vec::new(), }], ..Default::default() @@ -1884,9 +1904,8 @@ async fn cold_resume_without_active_permission_profile_restores_base_permissions }; MockResponsesConfig::new(&server.uri()) - .with_root_config(&format!( - "default_permissions = \"{BUILT_IN_PERMISSION_PROFILE_WORKSPACE}\"" - )) + .with_approval_policy("on-request") + .with_root_config(&format!("default_permissions = \"{configured_default}\"")) .write(codex_home.path())?; std::fs::write(codex_home.path().join("requirements.toml"), requirements)?; let mut builder = TestAppServer::builder().with_codex_home(codex_home.path()); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 8029c1e011..e2c30ad988 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -124,7 +124,6 @@ pub(crate) async fn run_codex_thread_interactive( history_initialization, disabled_plugin_ids: (isolation == codex_extension_api::SessionIsolation::Inherit) .then(|| parent_ctx.disabled_plugin_ids.clone()), - persisted_permission_profile: None, requested_history_mode: None, fork_persistence: ForkPersistence::Copied, session_source, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index abcb8c3208..6846d4c863 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2615,6 +2615,71 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: Ok(()) } +#[tokio::test] +async fn persisted_permissions_initialize_proxy_with_expandable_managed_allowlist() +-> anyhow::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + "default_permissions = \":danger-full-access\"\n", + )?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .harness_overrides(ConfigOverrides { + persisted_permission_profile: Some(PermissionProfile::read_only()), + ..Default::default() + }) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + "[experimental_network]\nenabled = true\n[experimental_network.domains]\n'managed.example' = 'allow'\n", + ), + ) + .build() + .await?; + assert_eq!( + ( + config.permissions.permission_profile(), + config.permissions.active_permission_profile() + ), + (&PermissionProfile::read_only(), None), + ); + + let mut rules = codex_execpolicy::Policy::empty(); + rules.add_network_rule( + "remembered.example", + codex_execpolicy::NetworkRuleProtocol::Https, + codex_execpolicy::Decision::Allow, + /*justification*/ None, + )?; + let spec = config + .permissions + .network + .as_ref() + .expect("managed proxy") + .with_exec_policy_network_rules(&rules)?; + let state = spec.build_state_with_audit_metadata( + Default::default(), + codex_utils_path_uri::Platform::native(), + codex_network_proxy::LocalBindingPolicy::DefaultFalse, + )?; + let proxy = codex_network_proxy::NetworkProxy::builder() + .state(Arc::new(state)) + .managed_by_codex(/*managed_by_codex*/ false) + .build() + .await?; + proxy.add_allowed_domain("newly-approved.example").await?; + assert_eq!( + proxy.current_cfg().await?.allowed_domains(), + Some(vec![ + "managed.example".to_string(), + "remembered.example".to_string(), + "newly-approved.example".to_string(), + ]), + ); + Ok(()) +} + #[tokio::test] async fn persisted_permission_profile_id_wins_over_configured_default() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 49bd951e06..f4cd90538c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2682,6 +2682,9 @@ pub struct ConfigOverrides { /// permission overrides take precedence, and stale profile IDs fall back /// to the configured default. pub persisted_permission_profile_id: Option, + /// Unnamed permissions from a thread-owned rollout snapshot. These are + /// fallback state: explicit overrides and current requirements take priority. + pub persisted_permission_profile: Option, pub model_provider: Option, pub service_tier: Option>, pub codex_self_exe: Option, @@ -3381,6 +3384,7 @@ impl Config { permission_profile, default_permissions: default_permissions_override, persisted_permission_profile_id, + persisted_permission_profile, model_provider, service_tier: service_tier_override, codex_self_exe, @@ -3525,6 +3529,24 @@ impl Config { sandbox_mode, ); let requirements_toml = config_layer_stack.requirements_toml(); + let persisted_permission_profile = persisted_permission_profile.filter(|profile| { + sandbox_mode.is_none() + && permission_profile.is_none() + && default_permissions_override.is_none() + && persisted_permission_profile_id.is_none() + && requirements_toml.allowed_permission_profiles.is_none() + // Executor-owned enforcement and materialized grants are not portable + // thread defaults. Reject the original mode before preserving denials. + && !matches!(profile, PermissionProfile::External { .. }) + && !profile.file_system_sandbox_policy().entries.iter().any(|entry| { + entry.access.can_read() + && matches!(entry.path, FileSystemPath::Path { .. } | FileSystemPath::GlobPattern { .. }) + }) + && permission_profile_catalog::permission_profile_satisfies_requirements( + &config_layer_stack, + profile, + ) + }); let persisted_permission_profile_id = if sandbox_mode.is_some() || permission_profile.is_some() || default_permissions_override.is_some() @@ -3637,8 +3659,8 @@ impl Config { dedupe_absolute_paths(&mut workspace_roots); let ( configured_network_proxy_config, - permission_profile, - file_system_sandbox_policy, + mut permission_profile, + mut file_system_sandbox_policy, mut active_permission_profile, mut profile_workspace_roots, ) = if let Some(permission_profile) = permission_profile { @@ -3773,6 +3795,24 @@ impl Config { Vec::new(), ) }; + if let Some(saved) = persisted_permission_profile { + let (mut saved_file_system, saved_network) = saved.to_runtime_permissions(); + saved_file_system.preserve_deny_read_restrictions_from(&file_system_sandbox_policy); + let restored = PermissionProfile::from_runtime_permissions_with_enforcement( + saved.enforcement(), + &saved_file_system, + saved_network, + ); + if permission_profile_catalog::permission_profile_satisfies_requirements( + &config_layer_stack, + &restored, + ) { + permission_profile = restored; + file_system_sandbox_policy = saved_file_system; + active_permission_profile = None; + profile_workspace_roots.clear(); + } + } let prepared_network = PreparedNetworkConfig::from_inputs(NetworkConfigInputs { configured_proxy: configured_network_proxy_config, feature_enabled: enable_network_proxy, diff --git a/codex-rs/core/src/config/permission_profile_catalog.rs b/codex-rs/core/src/config/permission_profile_catalog.rs index 224465ce7d..1ebad6ab9b 100644 --- a/codex-rs/core/src/config/permission_profile_catalog.rs +++ b/codex-rs/core/src/config/permission_profile_catalog.rs @@ -99,6 +99,14 @@ pub(super) fn permission_profile_is_allowed( .allowed_permission_profiles .as_ref() .is_none_or(|allowed| is_permission_allowed(allowed, profile_id)); + allowed_by_id + && permission_profile_satisfies_requirements(config_layer_stack, permission_profile) +} + +pub(super) fn permission_profile_satisfies_requirements( + config_layer_stack: &ConfigLayerStack, + permission_profile: &PermissionProfile, +) -> bool { let allowed_by_sandbox_mode = config_layer_stack .requirements() .permission_profile @@ -112,7 +120,7 @@ pub(super) fn permission_profile_is_allowed( value.deny_read.is_empty() || validate_permission_profile_for_deny_read(permission_profile, source).is_ok() }); - allowed_by_id && allowed_by_sandbox_mode && allowed_by_filesystem + allowed_by_sandbox_mode && allowed_by_filesystem } /// Validates that a permission candidate can enforce managed filesystem denials. diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1bc14e7981..e844a5cdb1 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -137,7 +137,6 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; -use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSandboxPolicyContext; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -460,7 +459,6 @@ pub(crate) struct SessionSpawnArgs { pub(crate) conversation_history: InitialHistory, pub(crate) history_initialization: HistoryInitialization, pub(crate) disabled_plugin_ids: Option>, - pub(crate) persisted_permission_profile: Option, pub(crate) requested_history_mode: Option, pub(crate) fork_persistence: ForkPersistence, pub(crate) session_source: SessionSource, @@ -576,7 +574,6 @@ impl Session { conversation_history, history_initialization, disabled_plugin_ids, - persisted_permission_profile, requested_history_mode, fork_persistence, session_source, @@ -872,7 +869,7 @@ impl Session { let service_tier = get_service_tier(config.service_tier.clone(), &config.features, &model_info); let storage_originator = AuthStorageOriginator::from_client_name(&originator); - let mut session_configuration = SessionConfiguration { + let session_configuration = SessionConfiguration { turn_extension_init, provider: create_model_provider( config.model_provider.clone(), @@ -915,35 +912,6 @@ impl Session { dynamic_tools, user_shell_override, }; - // Restore the thread's base policy, not an executor's enforcement or - // materialized grants that might outlive the workspace they came from. - if let Some(profile) = persisted_permission_profile - && !matches!(profile, PermissionProfile::External { .. }) - && !profile.file_system_sandbox_policy().entries.iter().any(|entry| { - entry.access.can_read() - && matches!( - entry.path, - FileSystemPath::Path { .. } | FileSystemPath::GlobPattern { .. } - ) - }) - && config - .config_layer_stack - .requirements_toml() - .allowed_permission_profiles - .is_none() - // Reject a disallowed saved mode before the live-update path can - // narrow it into a different policy (for example, Full + denies). - && config.permissions.can_set_permission_profile(&profile).is_ok() - && let Ok(restored) = session_configuration.apply( - &SessionSettingsUpdate { - permission_profile: Some(profile), - ..Default::default() - }, - &environment_selections, - ) - { - session_configuration = restored; - } session_configuration .validate(&environment_selections) .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 1d19731835..ab9a2be02e 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -1305,7 +1305,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { conversation_history: InitialHistory::New, history_initialization: HistoryInitialization::New, disabled_plugin_ids: None, - persisted_permission_profile: None, requested_history_mode: None, fork_persistence: ForkPersistence::Copied, session_source: SessionSource::SubAgent(SubAgentSource::Other( diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 4b256b1a39..2fb6da8d71 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -304,9 +304,6 @@ pub struct StartThreadOptions { pub reserved_thread_id: Option, /// Initial thread-owned plugin selection; omission restores persisted settings. pub disabled_plugin_ids: Option>, - /// Unnamed permissions recovered from an owned rollout snapshot on resume. - /// The session applies these only if current managed requirements allow them. - pub persisted_permission_profile: Option, } impl StartThreadOptions { @@ -331,7 +328,6 @@ impl StartThreadOptions { client_mcp_extensions: ClientMcpExtensions::default(), reserved_thread_id: None, disabled_plugin_ids: None, - persisted_permission_profile: None, } } } @@ -2125,7 +2121,6 @@ impl ThreadManagerState { client_mcp_extensions, mut reserved_thread_id, disabled_plugin_ids, - persisted_permission_profile, } = options; let inherited_environments = captured_environments.or(inherited_environments); let session_source = session_source.unwrap_or_else(|| self.session_source.clone()); @@ -2346,7 +2341,6 @@ impl ThreadManagerState { conversation_history: initial_history, history_initialization, disabled_plugin_ids, - persisted_permission_profile, requested_history_mode: history_mode, fork_persistence, // Keep only the manager registration internal. The session and its saved diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index df8e3bc53d..a8d40a31d7 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -594,6 +594,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result permission_profile: None, default_permissions: None, persisted_permission_profile_id: None, + persisted_permission_profile: None, cwd: resolved_cwd, workspace_roots: None, model_provider: model_provider.clone(),