feat: support managed permission profiles in requirements.toml (#23433)

## Why

Cloud-managed `requirements.toml` should be able to define the managed
permission profiles a client may select and constrain that selectable
set without requiring local user config to recreate the profile catalog.

This keeps requirements focused on restrictions. The selected default
remains a config or session choice, while requirements contribute the
managed profile bodies and `allowed_permissions` allowlist that the
config-loading boundary validates before a resolved runtime
`PermissionProfile` is installed.

## What changed

- Add `requirements.toml` support for a managed permission-profile
catalog plus its allowlist:

```toml
allowed_permissions = ["review", "build"]

[permissions.review]
extends = ":read-only"

[permissions.build]
extends = ":workspace"
```

- Merge requirements-defined profile bodies into the effective
permission catalog and reject profile ids that collide with
config-defined profiles.
- Validate that every `allowed_permissions` entry resolves to a built-in
or catalog profile before selection uses it.
- Preserve allowed configured named-profile selections. When a
configured named profile is disallowed, fall back to the first allowed
requirements profile with a startup warning.
- Keep built-in selections and the stock trust-based `:read-only` /
`:workspace` fallback path intact when no permission profile is
explicitly selected.
- Centralize the managed catalog and allowlist selection path in
`EffectivePermissionSelection` so the requirements boundary is visible
in config loading.
- Surface `allowedPermissions` through `configRequirements/read`, and
update the generated app-server schema fixtures plus the app-server
README.

## Validation

- `cargo test -p codex-config`
- `cargo test -p codex-core system_requirements_`
- `cargo test -p codex-core system_allowed_permissions_`
- `cargo test -p codex-app-server-protocol`
- `just write-app-server-schema`

## Related work

- Uses merged permission-profile inheritance support from #22270 and
#23705.
- Kept separate from the in-flight permission profile listing API in
#23412.
This commit is contained in:
viyatb-oai
2026-05-20 17:33:01 -07:00
committed by GitHub
Unverified
parent c9ff067e31
commit 713a5b1b00
14 changed files with 638 additions and 35 deletions
@@ -7744,6 +7744,15 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"allowedSandboxModes": {
"items": {
"$ref": "#/definitions/v2/SandboxMode"
@@ -4113,6 +4113,15 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"allowedSandboxModes": {
"items": {
"$ref": "#/definitions/SandboxMode"
@@ -88,6 +88,15 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"allowedSandboxModes": {
"items": {
"$ref": "#/definitions/SandboxMode"
@@ -7,4 +7,4 @@ import type { ComputerUseRequirements } from "./ComputerUseRequirements";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedPermissions: Array<string> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
@@ -386,6 +386,7 @@ pub struct ConfigRequirements {
#[experimental("configRequirements/read.allowedApprovalsReviewers")]
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_permissions: Option<Vec<String>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub computer_use: Option<ComputerUseRequirements>,
@@ -1728,6 +1728,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
}]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
+1 -1
View File
@@ -226,7 +226,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -419,6 +419,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
.filter_map(map_sandbox_mode_requirement_to_api)
.collect()
}),
allowed_permissions: requirements.allowed_permissions,
allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| {
let mut normalized = modes
.into_iter()
@@ -638,10 +639,21 @@ mod tests {
#[test]
fn requirements_api_includes_allow_managed_hooks_only() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
allowed_permissions: Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
]),
allow_managed_hooks_only: Some(true),
..ConfigRequirementsToml::default()
});
assert_eq!(
mapped.allowed_permissions,
Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
])
);
assert_eq!(mapped.allow_managed_hooks_only, Some(true));
assert_eq!(mapped.hooks, None);
}
+16
View File
@@ -1216,6 +1216,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1300,6 +1301,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1335,6 +1337,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1387,6 +1390,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1568,6 +1572,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1650,6 +1655,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1730,6 +1736,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1938,6 +1945,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1980,6 +1988,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2042,6 +2051,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2100,6 +2110,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2160,6 +2171,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2221,6 +2233,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2286,6 +2299,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2377,6 +2391,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2414,6 +2429,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+122 -1
View File
@@ -19,6 +19,7 @@ use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedHooksRequirementsToml;
use crate::mcp_types::AppToolApproval;
use crate::permissions_toml::PermissionProfileToml;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequirementSource {
@@ -432,14 +433,58 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FilesystemRequirementsToml {
pub deny_read: Option<Vec<FilesystemDenyReadPattern>>,
}
#[derive(Deserialize)]
struct RawFilesystemRequirementsToml {
deny_read: Option<Vec<FilesystemDenyReadPattern>>,
description: Option<serde::de::IgnoredAny>,
extends: Option<serde::de::IgnoredAny>,
workspace_roots: Option<serde::de::IgnoredAny>,
filesystem: Option<serde::de::IgnoredAny>,
network: Option<serde::de::IgnoredAny>,
}
impl<'de> Deserialize<'de> for FilesystemRequirementsToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawFilesystemRequirementsToml::deserialize(deserializer)?;
let RawFilesystemRequirementsToml {
deny_read,
description,
extends,
workspace_roots,
filesystem,
network,
} = raw;
if description.is_some()
|| extends.is_some()
|| workspace_roots.is_some()
|| filesystem.is_some()
|| network.is_some()
{
return Err(D::Error::custom(
"`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile",
));
}
Ok(Self { deny_read })
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct PermissionsRequirementsToml {
pub filesystem: Option<FilesystemRequirementsToml>,
// For legacy reasons, `filesystem` stays reserved for requirements-level
// filesystem constraints and cannot name a profile.
#[serde(default, flatten)]
pub profiles: BTreeMap<String, PermissionProfileToml>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -701,6 +746,7 @@ pub struct ConfigRequirementsToml {
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub allowed_permissions: Option<Vec<String>>,
pub remote_sandbox_config: Option<Vec<RemoteSandboxConfigToml>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub allow_managed_hooks_only: Option<bool>,
@@ -752,6 +798,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
pub allowed_approvals_reviewers: Option<Sourced<Vec<ApprovalsReviewer>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_permissions: Option<Sourced<Vec<String>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub computer_use: Option<Sourced<ComputerUseRequirementsToml>>,
@@ -789,6 +836,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies: _,
allowed_approvals_reviewers: _,
allowed_sandbox_modes: _,
allowed_permissions: _,
remote_sandbox_config: _,
allowed_web_search_modes: _,
allow_managed_hooks_only: _,
@@ -821,6 +869,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
computer_use,
@@ -850,6 +899,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
computer_use,
@@ -868,6 +918,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value),
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
allowed_permissions: allowed_permissions.map(|sourced| sourced.value),
remote_sandbox_config: None,
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value),
@@ -953,6 +1004,7 @@ impl ConfigRequirementsToml {
self.allowed_approval_policies.is_none()
&& self.allowed_approvals_reviewers.is_none()
&& self.allowed_sandbox_modes.is_none()
&& self.allowed_permissions.is_none()
&& self.remote_sandbox_config.is_none()
&& self.allowed_web_search_modes.is_none()
&& self.allow_managed_hooks_only.is_none()
@@ -992,10 +1044,14 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
type Error = ConstraintError;
fn try_from(toml: ConfigRequirementsWithSources) -> Result<Self, Self::Error> {
// Profile catalog selection remains on ConfigRequirementsToml for
// config loading and requirements API projection. The normalized
// constraints below only need the compiled PermissionProfile envelope.
let ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions: _,
allowed_web_search_modes,
allow_managed_hooks_only,
computer_use,
@@ -1301,6 +1357,7 @@ mod tests {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
remote_sandbox_config: _,
allowed_web_search_modes,
allow_managed_hooks_only,
@@ -1323,6 +1380,8 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_sandbox_modes: allowed_sandbox_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_permissions: allowed_permissions
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_web_search_modes: allowed_web_search_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allow_managed_hooks_only: allow_managed_hooks_only
@@ -1370,6 +1429,61 @@ mod tests {
Ok(())
}
#[test]
fn deserialize_managed_permission_profiles() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
allowed_permissions = ["managed-standard", "managed-build"]
[permissions.managed-standard]
extends = ":workspace"
[permissions.managed-build]
extends = "managed-standard"
"#,
)?;
assert_eq!(
requirements.allowed_permissions,
Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
])
);
let permissions = requirements
.permissions
.as_ref()
.expect("managed permission profiles");
assert!(permissions.profiles.contains_key("managed-standard"));
assert!(
permissions
.profiles
.get("managed-build")
.and_then(|profile| profile.extends.as_deref())
.is_some()
);
assert!(!requirements.is_empty());
Ok(())
}
#[test]
fn filesystem_requirements_table_cannot_define_a_permission_profile() {
let err = from_str::<ConfigRequirementsToml>(
r#"
[permissions.filesystem]
extends = ":workspace"
"#,
)
.expect_err("filesystem requirements cannot define a permission profile");
assert!(
err.to_string().contains(
"`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile"
),
"unexpected error: {err:#}"
);
}
#[test]
fn deserialize_computer_use_requirements() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
@@ -1421,6 +1535,7 @@ mod tests {
allowed_approval_policies: Some(allowed_approval_policies.clone()),
allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()),
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
allowed_permissions: Some(vec!["managed".to_string()]),
remote_sandbox_config: None,
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
allow_managed_hooks_only: Some(true),
@@ -1451,6 +1566,10 @@ mod tests {
source.clone(),
)),
allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)),
allowed_permissions: Some(Sourced::new(
vec!["managed".to_string()],
source.clone(),
)),
allowed_web_search_modes: Some(Sourced::new(
allowed_web_search_modes,
enforce_source.clone(),
@@ -1501,6 +1620,7 @@ mod tests {
)),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
@@ -1550,6 +1670,7 @@ mod tests {
)),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
@@ -27,6 +27,8 @@ use codex_config::loader::load_requirements_toml;
use codex_exec_server::LOCAL_FS;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -1095,6 +1097,7 @@ allowed_approval_policies = ["on-request"]
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1154,6 +1157,7 @@ allowed_approval_policies = ["on-request"]
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1362,6 +1366,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1411,6 +1416,264 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
Ok(())
}
#[tokio::test]
async fn system_requirements_define_managed_permission_profiles() -> anyhow::Result<()> {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
default_permissions = "managed-standard"
"#,
)
.await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
[permissions.managed-standard]
extends = ":workspace"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.config_layer_stack
.requirements_toml()
.allowed_permissions,
Some(vec!["managed-standard".to_string()])
);
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some("managed-standard".to_string())
);
Ok(())
}
#[tokio::test]
async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyhow::Result<()> {
for (trust_level, expected_profile) in [
(
Some(TrustLevel::Trusted),
if cfg!(target_os = "windows") {
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
} else {
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
},
),
(
Some(TrustLevel::Untrusted),
if cfg!(target_os = "windows") {
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
} else {
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
},
),
(None, BUILT_IN_PERMISSION_PROFILE_READ_ONLY),
] {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
if let Some(trust_level) = trust_level {
make_config_for_test(
&codex_home,
tmp.path(),
trust_level,
/*project_root_markers*/ None,
)
.await?;
}
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
[permissions.managed-standard.filesystem]
":workspace_roots" = "read"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some(expected_profile.to_string()),
"trust level {trust_level:?}",
);
}
Ok(())
}
#[tokio::test]
async fn system_allowed_permissions_keep_explicit_builtin_defaults() -> anyhow::Result<()> {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
default_permissions = ":workspace"
"#,
)
.await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
[permissions.managed-standard.filesystem]
":workspace_roots" = "read"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string())
);
Ok(())
}
#[tokio::test]
async fn system_requirements_preserve_allowed_configured_permission_default() -> anyhow::Result<()>
{
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
default_permissions = "managed-build"
"#,
)
.await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard", "managed-build"]
[permissions.managed-standard]
extends = ":read-only"
[permissions.managed-build]
extends = ":workspace"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some("managed-build".to_string())
);
Ok(())
}
#[tokio::test]
async fn system_requirements_warn_for_disallowed_explicit_permission_override() -> anyhow::Result<()>
{
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
[permissions.managed-standard]
extends = ":workspace"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.harness_overrides(ConfigOverrides {
default_permissions: Some("managed-build".to_string()),
..ConfigOverrides::default()
})
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some("managed-standard".to_string())
);
assert!(
config.startup_warnings.iter().any(|warning| warning
.contains("Configured value for `permission_profile` is disallowed by requirements")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> {
let tmp = tempdir()?;
+2
View File
@@ -9091,6 +9091,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
allow_managed_hooks_only: None,
@@ -9875,6 +9876,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+190 -32
View File
@@ -33,6 +33,7 @@ use codex_config::config_toml::ThreadStoreToml;
use codex_config::config_toml::validate_model_providers;
use codex_config::loader::load_config_layers_state;
use codex_config::loader::project_trust_key;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::profile_toml::ConfigProfile;
use codex_config::sandbox_mode_requirement_for_permission_profile;
use codex_config::types::ApprovalsReviewer;
@@ -1997,6 +1998,38 @@ struct PermissionSelectionToml {
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`.
#[derive(Debug)]
struct EffectivePermissionSelection<'a> {
profiles: Option<PermissionsToml>,
selected_profile_id: Option<&'a str>,
requirements_force_profile_selection: bool,
}
impl EffectivePermissionSelection<'_> {
fn has_profiles(&self) -> bool {
self.profiles
.as_ref()
.is_some_and(|profiles| !profiles.is_empty())
}
fn profiles_are_active(
&self,
default_permissions_override: Option<&str>,
permission_config_syntax: Option<PermissionConfigSyntax>,
) -> bool {
self.requirements_force_profile_selection
|| default_permissions_override.is_some()
|| matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
)
|| permission_config_syntax.is_none()
}
}
fn resolve_permission_config_syntax(
config_layer_stack: &ConfigLayerStack,
cfg: &ConfigToml,
@@ -2621,20 +2654,21 @@ impl Config {
sandbox_mode,
config_profile.sandbox_mode,
);
let has_permission_profiles = cfg
.permissions
.as_ref()
.is_some_and(|profiles| !profiles.is_empty());
let default_permissions = default_permissions_override
.as_deref()
.or(cfg.default_permissions.as_deref());
validate_user_permission_profile_names(cfg.permissions.as_ref())?;
if has_permission_profiles
let requirements_toml = config_layer_stack.requirements_toml();
let effective_permission_selection = resolve_effective_permission_selection(
cfg.permissions.as_ref(),
default_permissions_override.as_deref(),
cfg.default_permissions.as_deref(),
requirements_toml,
&mut startup_warnings,
)?;
if effective_permission_selection.has_profiles()
&& !matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Legacy)
)
&& default_permissions.is_none()
&& effective_permission_selection.selected_profile_id.is_none()
&& !effective_permission_selection.requirements_force_profile_selection
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
@@ -2651,12 +2685,10 @@ impl Config {
std::fs::create_dir_all(&memories_root)?;
let internal_writable_roots = vec![memories_root];
let profiles_are_active = default_permissions_override.is_some()
|| matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
)
|| permission_config_syntax.is_none();
let profiles_are_active = effective_permission_selection.profiles_are_active(
default_permissions_override.as_deref(),
permission_config_syntax,
);
let explicit_permission_profile_mode = default_permissions_override.is_some()
|| matches!(
permission_config_syntax,
@@ -2668,9 +2700,11 @@ impl Config {
.map_or_else(Vec::new, |permissions| {
permissions.entries.keys().cloned().collect()
});
let using_implicit_builtin_profile =
permission_config_syntax.is_none() && default_permissions.is_none();
let should_seed_legacy_workspace_roots = default_permissions.is_none()
let using_implicit_builtin_profile = permission_config_syntax.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)
@@ -2720,14 +2754,16 @@ impl Config {
// PermissionProfile carries the active network sandbox bit, not the configured
// proxy/allowlist policy. Keep that config so active profiles can round-trip
// without broadening network behavior.
let default_permissions = default_permissions.unwrap_or_else(|| {
default_builtin_permission_profile_name(
&active_project,
windows_sandbox_level,
)
});
let default_permissions = effective_permission_selection
.selected_profile_id
.unwrap_or_else(|| {
default_builtin_permission_profile_name(
&active_project,
windows_sandbox_level,
)
});
network_proxy_config_for_profile_selection(
cfg.permissions.as_ref(),
effective_permission_selection.profiles.as_ref(),
default_permissions,
)?
} else {
@@ -2765,28 +2801,30 @@ impl Config {
Vec::new(),
)
} else if profiles_are_active {
let default_permissions = default_permissions.unwrap_or_else(|| {
default_builtin_permission_profile_name(&active_project, windows_sandbox_level)
});
let default_permissions = effective_permission_selection
.selected_profile_id
.unwrap_or_else(|| {
default_builtin_permission_profile_name(&active_project, windows_sandbox_level)
});
let builtin_workspace_write_settings = if using_implicit_builtin_profile {
cfg.sandbox_workspace_write.as_ref()
} else {
None
};
let configured_network_proxy_config = network_proxy_config_for_profile_selection(
cfg.permissions.as_ref(),
effective_permission_selection.profiles.as_ref(),
default_permissions,
)?;
let (mut file_system_sandbox_policy, network_sandbox_policy) =
compile_permission_profile_selection(
cfg.permissions.as_ref(),
effective_permission_selection.profiles.as_ref(),
default_permissions,
builtin_workspace_write_settings,
resolved_cwd.as_path(),
&mut startup_warnings,
)?;
let mut configured_workspace_roots = compile_permission_profile_workspace_roots(
cfg.permissions.as_ref(),
effective_permission_selection.profiles.as_ref(),
default_permissions,
resolved_cwd.as_path(),
)?;
@@ -3779,6 +3817,126 @@ fn guardian_policy_config_from_requirements(
normalize_guardian_policy_config(requirements_toml.guardian_policy_config.as_deref())
}
fn merge_managed_permission_profiles(
configured_permissions: Option<&PermissionsToml>,
requirements_toml: &ConfigRequirementsToml,
) -> std::io::Result<Option<PermissionsToml>> {
let managed_profiles = requirements_toml
.permissions
.as_ref()
.map(|permissions| &permissions.profiles)
.filter(|profiles| !profiles.is_empty());
let Some(managed_profiles) = managed_profiles else {
return Ok(configured_permissions.cloned());
};
let mut merged_permissions = configured_permissions.cloned().unwrap_or_default();
for (profile_id, managed_profile) in managed_profiles {
if merged_permissions.entries.contains_key(profile_id) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
format!(
"requirements.toml permissions profile `{profile_id}` conflicts with a config-defined profile of the same name"
),
));
}
merged_permissions
.entries
.insert(profile_id.clone(), managed_profile.clone());
}
Ok(Some(merged_permissions))
}
fn resolve_effective_permission_selection<'a>(
configured_permissions: Option<&PermissionsToml>,
default_permissions_override: Option<&'a str>,
configured_default_permissions: Option<&'a str>,
requirements_toml: &'a ConfigRequirementsToml,
startup_warnings: &mut Vec<String>,
) -> std::io::Result<EffectivePermissionSelection<'a>> {
let profiles = merge_managed_permission_profiles(configured_permissions, requirements_toml)?;
validate_user_permission_profile_names(profiles.as_ref())?;
validate_required_permission_profile_catalog(requirements_toml, profiles.as_ref())?;
let selected_profile_id = resolve_default_permissions(
default_permissions_override,
configured_default_permissions,
requirements_toml,
startup_warnings,
)?;
Ok(EffectivePermissionSelection {
profiles,
selected_profile_id,
requirements_force_profile_selection: requirements_toml.allowed_permissions.is_some(),
})
}
fn resolve_default_permissions<'a>(
default_permissions_override: Option<&'a str>,
configured_default_permissions: Option<&'a str>,
requirements_toml: &'a ConfigRequirementsToml,
startup_warnings: &mut Vec<String>,
) -> std::io::Result<Option<&'a str>> {
let allowed_permissions = requirements_toml.allowed_permissions.as_ref();
let mut default_permissions = default_permissions_override.or(configured_default_permissions);
if let (Some(selected_permissions), Some(allowed_permissions)) =
(default_permissions, allowed_permissions)
&& !is_builtin_permission_profile_name(selected_permissions)
&& !allowed_permissions
.iter()
.any(|allowed_permission| allowed_permission == selected_permissions)
{
let Some(fallback_permissions) = allowed_permissions.first().map(String::as_str) else {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"requirements.toml allowed_permissions must include at least one profile",
));
};
startup_warnings.push(format!(
"Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`."
));
default_permissions = Some(fallback_permissions);
}
Ok(default_permissions)
}
fn validate_required_permission_profile_catalog(
requirements_toml: &ConfigRequirementsToml,
available_permissions: Option<&PermissionsToml>,
) -> std::io::Result<()> {
let is_known_profile = |profile_id: &str| {
is_builtin_permission_profile_name(profile_id)
|| available_permissions
.as_ref()
.is_some_and(|permissions| permissions.entries.contains_key(profile_id))
};
let Some(allowed_permissions) = requirements_toml.allowed_permissions.as_ref() else {
return Ok(());
};
if allowed_permissions.is_empty() {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"requirements.toml allowed_permissions must include at least one profile",
));
}
for profile_id in allowed_permissions {
if !is_known_profile(profile_id) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
format!(
"requirements.toml allowed_permissions refers to undefined profile `{profile_id}`"
),
));
}
}
Ok(())
}
fn normalize_guardian_policy_config(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
+2
View File
@@ -697,6 +697,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]),
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]),
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]),
allow_managed_hooks_only: Some(true),
@@ -912,6 +913,7 @@ approval_policy = "never"
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(Vec::new()),
allow_managed_hooks_only: None,