core: derive built-in permission profiles from raw policies (#25739)

## Why

Permission profiles that extend a built-in profile should behave like
other TOML inheritance: parent entries provide defaults, and child keys
override matching fields before the profile is compiled.

That was not true for `:workspace`. Previously, a profile with `extends
= ":workspace"` seeded the compiled runtime
`PermissionProfile::workspace_write()` policy and then appended child
filesystem entries. A child override such as `":tmpdir" = "read"`
therefore left the inherited `":tmpdir" = "write"` entry in the final
policy. Since same-target `write` wins over `read` during runtime
resolution, the child override was ineffective.

This also needs a clear source of truth for the built-in profiles. The
protocol-level sandbox policy constructors now define the raw built-in
filesystem entries, and both `PermissionProfile` presets and
config-profile inheritance derive from those same values.

## What Changed

- Add a canonical `FileSystemSandboxPolicy::read_only()` constructor
while keeping the read-only and workspace-write raw filesystem entries
explicit and independent.
- Derive `PermissionProfile::read_only()` from
`FileSystemSandboxPolicy::read_only()`;
`PermissionProfile::workspace_write()` continues to derive from
`FileSystemSandboxPolicy::workspace_write()`.
- Build extensible `:read-only` and `:workspace` parent profiles by
projecting those canonical sandbox policies into
`PermissionProfileToml`, then merge user overrides at the TOML layer
before compilation.
- Add config parsing support for `:slash_tmp` so the built-in
`:workspace` parent can be expressed in the same TOML-shaped filesystem
table as user profiles.
- Document that `PermissionsToml::resolve_profile()` returns an
already-merged `PermissionProfileToml`, and return that profile directly
after removing the resolved-profile wrapper.
- Extend the config test for `extends = ":workspace"` to assert that
inherited `":slash_tmp" = "write"` is preserved and that a child
`":tmpdir" = "read"` entry replaces the inherited `write` entry.

## Verification

- `just test -p codex-config`
- `just test -p codex-protocol`
- `just test -p codex-core
permissions_profiles_resolve_extends_parent_first_with_child_overrides`
- `just test -p codex-core
default_permissions_profile_can_extend_builtin_workspace`
- `just test -p codex-core`
  - Result: 2596 passed, 4 failed, 1 timed out.
- The failures were existing sandbox/environment-sensitive tests
unrelated to this permissions change:

`suite::user_shell_cmd::user_shell_command_does_not_set_network_sandbox_env_var`,

`suite::user_shell_cmd::user_shell_command_history_is_persisted_and_shared_with_model`,

`suite::abort_tasks::interrupt_persists_turn_aborted_marker_in_next_request`,
    `suite::abort_tasks::interrupt_tool_records_history_entries`, and

`thread_manager::tests::start_thread_uses_all_default_environments_from_codex_home`.
This commit is contained in:
Michael Bolin
2026-06-02 10:57:35 -07:00
committed by GitHub
Unverified
parent ebb7980369
commit 593df8773d
7 changed files with 195 additions and 74 deletions
+9 -16
View File
@@ -30,11 +30,18 @@ impl PermissionsToml {
self.entries.is_empty()
}
/// Resolve `profile_name` and all of its `extends` ancestors into one TOML
/// profile.
///
/// Parent profiles are merged before their children, so child keys override
/// matching parent keys before callers compile the profile into runtime
/// permissions. The returned profile keeps the selected profile's
/// declaration metadata, such as `description` and `extends`.
pub fn resolve_profile<F>(
&self,
profile_name: &str,
mut parent_profile: F,
) -> Result<ResolvedPermissionProfileToml, PermissionProfileResolutionError>
) -> Result<PermissionProfileToml, PermissionProfileResolutionError>
where
F: FnMut(&str) -> Option<PermissionProfileToml>,
{
@@ -96,10 +103,7 @@ impl PermissionsToml {
.into_iter()
.rev()
.try_fold(profile, merge_permission_profiles)?;
return Ok(ResolvedPermissionProfileToml {
profile,
inherited_profile_names: profile_names.into_iter().skip(1).collect(),
});
return Ok(profile);
}
}
}
@@ -114,17 +118,6 @@ pub struct PermissionProfileToml {
pub network: Option<NetworkToml>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedPermissionProfileToml {
pub profile: PermissionProfileToml,
/// Names of profiles inherited while resolving `profile`, ordered from the
/// selected profile's direct parent to the farthest ancestor.
///
/// Callers use this to preserve which built-in baseline contributed the
/// resolved permissions after the parent profiles have been merged away.
pub inherited_profile_names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PermissionProfileResolutionError {
#[error("default_permissions refers to undefined profile `{profile_name}`")]