Centralize permission config selection and legacy translation (#60702)

## What changed

Move permission selection into `codex-config` with a shared `PermissionConfig` representation for legacy sandbox settings and named profiles. Resolve selectors using config layer precedence while preserving explicit session profile selection and sandbox mode overrides.

Share legacy permission translation between config derivation and core loading, preserving merged workspace roots, network access, temporary-directory exclusions, and platform sandbox availability without modifying source layers or field origins.

## Testing

Add coverage for mixed selector precedence, merged legacy settings, unchanged source metadata, and the Windows read-only fallback. Extend the managed preferences loader test to verify settings merged across layers and the absence of an active named profile for legacy permissions.

GitOrigin-RevId: a435be7af38424ac33711e6a7557f52b9b672577
This commit is contained in:
ioanbudea-OAI
2026-11-11 02:29:34 +00:00
committed by copyberry
parent 83e3d63ba1
commit fe8e540a80
7 changed files with 327 additions and 110 deletions
@@ -1198,6 +1198,14 @@ async fn managed_preferences_expand_home_directory_in_workspace_write_roots() ->
};
let tmp = tempdir()?;
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
r#"
default_permissions = ":read-only"
[sandbox_workspace_write]
network_access = true
"#,
)?;
let mut loader_overrides =
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
loader_overrides.managed_preferences_base64 = Some(
@@ -1215,12 +1223,20 @@ writable_roots = ["~/code"]
.codex_home(tmp.path().to_path_buf())
.fallback_cwd(Some(tmp.path().to_path_buf()))
.loader_overrides(loader_overrides)
.cloud_config_bundle(CloudConfigBundleFixture::loader_with_enterprise_config(
"[sandbox_workspace_write]\nexclude_tmpdir_env_var = true",
))
.build()
.await?;
let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?;
match &config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
} => {
assert_eq!(
writable_roots
.iter()
@@ -1232,6 +1248,7 @@ writable_roots = ["~/code"]
other => panic!("expected workspace-write policy, got {other:?}"),
}
assert_eq!(config.permissions.active_permission_profile(), None);
Ok(())
}
+27 -79
View File
@@ -19,6 +19,7 @@ use codex_config::FeatureRequirementsToml;
use codex_config::ManagedAuthPolicy;
use codex_config::McpEnterpriseManagedAuthConfig;
use codex_config::McpServerRequirement;
use codex_config::PermissionConfig;
use codex_config::PluginRequirementsToml;
use codex_config::ProfileV2Name;
use codex_config::ResidencyRequirement;
@@ -38,7 +39,9 @@ use codex_config::loader::load_config_layers_state;
use codex_config::loader::project_trust_key;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::resolve_permission_config;
use codex_config::sandbox_mode_requirement_for_permission_profile;
use codex_config::translate_legacy_permissions;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::AuthCredentialsStoreMode;
use codex_config::types::AuthKeyringBackendKind;
@@ -142,6 +145,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
use codex_utils_path_uri::Platform;
use http::HeaderValue;
use rmcp::model::ElicitationCapability;
use rmcp::model::FormElicitationCapability;
@@ -2696,18 +2700,6 @@ fn is_session_layer(source: &ConfigLayerSource) -> bool {
matches!(source, ConfigLayerSource::SessionFlags)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionConfigSyntax {
Legacy,
Profiles,
}
#[derive(Debug, Deserialize, Default)]
struct PermissionSelectionToml {
default_permissions: Option<String>,
sandbox_mode: Option<SandboxMode>,
}
// Resolve the named-profile catalog and selected profile id together. Runtime
// profile constraints are applied later after this selection compiles into a
// concrete `PermissionProfile`.
@@ -2729,68 +2721,16 @@ impl EffectivePermissionSelection<'_> {
fn profiles_are_active(
&self,
default_permissions_override: Option<&str>,
permission_config_syntax: Option<PermissionConfigSyntax>,
permission_config: Option<PermissionConfig<'_>>,
) -> bool {
self.persisted_profile_id_was_provided
|| self.requirements_force_profile_selection
|| default_permissions_override.is_some()
|| matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
)
|| permission_config_syntax.is_none()
|| matches!(permission_config, Some(PermissionConfig::Named { .. }))
|| permission_config.is_none()
}
}
fn resolve_permission_config_syntax(
config_layer_stack: &ConfigLayerStack,
cfg: &ConfigToml,
sandbox_mode_override: Option<SandboxMode>,
) -> Option<PermissionConfigSyntax> {
if sandbox_mode_override.is_some() {
return Some(PermissionConfigSyntax::Legacy);
}
let session_flags_select_profiles = config_layer_stack
.layers_high_to_low()
.find(|layer| matches!(layer.name, ConfigLayerSource::SessionFlags))
.and_then(|layer| {
layer
.config
.clone()
.try_into::<PermissionSelectionToml>()
.ok()
})
.is_some_and(|selection| selection.default_permissions.is_some());
if session_flags_select_profiles {
return Some(PermissionConfigSyntax::Profiles);
}
let mut selection = None;
for layer in config_layer_stack.layers_low_to_high() {
let Ok(layer_selection) = layer.config.clone().try_into::<PermissionSelectionToml>() else {
continue;
};
if layer_selection.sandbox_mode.is_some() {
selection = Some(PermissionConfigSyntax::Legacy);
}
if layer_selection.default_permissions.is_some() {
selection = Some(PermissionConfigSyntax::Profiles);
}
}
selection.or_else(|| {
if cfg.default_permissions.is_some() {
Some(PermissionConfigSyntax::Profiles)
} else if cfg.sandbox_mode.is_some() {
Some(PermissionConfigSyntax::Legacy)
} else {
None
}
})
}
/// Optional overrides for user configuration (e.g., from CLI flags).
#[derive(Default, Debug, Clone)]
pub struct ConfigOverrides {
@@ -3710,7 +3650,7 @@ impl Config {
repo_root.as_ref().map(AbsolutePathBuf::as_path),
)
.unwrap_or(ProjectConfig { trust_level: None });
let permission_config_syntax = resolve_permission_config_syntax(
let permission_config = resolve_permission_config(
&config_layer_stack,
&cfg,
sandbox_mode,
@@ -3758,8 +3698,8 @@ impl Config {
)?;
if effective_permission_selection.has_profiles()
&& !matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Legacy)
permission_config,
Some(PermissionConfig::Legacy { .. })
)
&& effective_permission_selection.selected_profile_id.is_none()
&& !effective_permission_selection.requirements_force_profile_selection
@@ -3788,7 +3728,7 @@ impl Config {
let profiles_are_active = effective_permission_selection.profiles_are_active(
default_permissions_override.as_deref(),
permission_config_syntax,
permission_config,
);
let prefer_mxc = features.enabled(Feature::PreferMxc)
&& config_allows_mxc(
@@ -3811,8 +3751,8 @@ impl Config {
.persisted_profile_id_was_provided
|| default_permissions_override.is_some()
|| matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
permission_config,
Some(PermissionConfig::Named { .. })
);
let custom_permission_profiles = permission_profile_catalog_from_permissions(
&config_layer_stack,
@@ -3824,14 +3764,14 @@ impl Config {
.collect();
let using_implicit_builtin_profile = !effective_permission_selection
.persisted_profile_id_was_provided
&& permission_config_syntax.is_none()
&& permission_config.is_none()
&& effective_permission_selection.selected_profile_id.is_none();
let should_seed_legacy_workspace_roots = effective_permission_selection
.selected_profile_id
.is_none()
&& matches!(
permission_config_syntax,
None | Some(PermissionConfigSyntax::Legacy)
permission_config,
None | Some(PermissionConfig::Legacy { .. })
);
let legacy_workspace_roots_explicit = should_seed_legacy_workspace_roots
&& cfg
@@ -3964,14 +3904,22 @@ impl Config {
// Derive the old `sandbox_mode` defaults as a profile first, then
// keep a legacy-compatible projection only for the remaining code
// paths that still speak `SandboxPolicy`.
let mut permission_profile = cfg
.derive_permission_profile(
let mut permission_profile = if let Some(PermissionConfig::Legacy { mode, workspace_write }) = permission_config {
translate_legacy_permissions(
mode,
workspace_write,
Platform::native(),
legacy_windows_sandbox_level,
)
} else {
cfg.derive_permission_profile(
sandbox_mode,
legacy_windows_sandbox_level,
Some(&active_project),
Some(&constrained_permission_profile),
)
.await;
.await
};
// The legacy-derived profiles above are expected to be
// representable as `SandboxPolicy`. This guard keeps the old safe
// fallback behavior if future changes make this branch derive a