permissions: make runtime config profile-backed (#19606)

## Why

This supersedes #19391. During stack repair, GitHub marked #19391 as
merged into a temporary stack branch rather than into `main`, so the
runtime-config change needed a fresh PR.

`PermissionProfile` is now the canonical permissions shape after #19231
because it can distinguish `Managed`, `Disabled`, and `External`
enforcement while also carrying filesystem rules that legacy
`SandboxPolicy` cannot represent cleanly. Core config and session state
still needed to accept profile-backed permissions without forcing every
profile through the strict legacy bridge, which rejected valid runtime
profiles such as direct write roots.

The unrelated CI/test hardening that previously rode along with this PR
has been split into #19683 so this PR stays focused on the permissions
model migration.

## What Changed

- Adds `Permissions.permission_profile` and
`SessionConfiguration.permission_profile` as constrained runtime state,
while keeping `sandbox_policy` as a legacy compatibility projection.
- Introduces profile setters that keep `PermissionProfile`, split
filesystem/network policies, and legacy `SandboxPolicy` projections
synchronized.
- Uses a compatibility projection for requirement checks and legacy
consumers instead of rejecting profiles that cannot round-trip through
`SandboxPolicy` exactly.
- Updates config loading, config overrides, session updates, turn
context plumbing, prompt permission text, sandbox tags, and exec request
construction to carry profile-backed runtime permissions.
- Preserves configured deny-read entries and `glob_scan_max_depth` when
command/session profiles are narrowed.
- Adds `PermissionProfile::read_only()` and
`PermissionProfile::workspace_write()` presets that match legacy
defaults.

## Verification

- `cargo test -p codex-core direct_write_roots`
- `cargo test -p codex-core runtime_roots_to_legacy_projection`
- `cargo test -p codex-app-server
requested_permissions_trust_project_uses_permission_profile_intent`




---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/19606).
* #19395
* #19394
* #19393
* #19392
* __->__ #19606
This commit is contained in:
Michael Bolin
2026-04-26 13:29:54 -07:00
committed by GitHub
parent fed0a8f4fa
commit 4d7ce3447d
62 changed files with 1601 additions and 671 deletions

View File

@@ -4,6 +4,7 @@ use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as SerdeError;
use std::borrow::Cow;
use std::cell::RefCell;
use std::path::Display;
use std::path::Path;
@@ -46,16 +47,23 @@ impl AbsolutePathBuf {
base_path: B,
) -> Self {
let expanded = Self::maybe_expand_home_directory(path.as_ref());
Self(absolutize::absolutize_from(&expanded, base_path.as_ref()))
let expanded = normalize_path_for_platform(&expanded);
let base_path = normalize_path_for_platform(base_path.as_ref());
Self(absolutize::absolutize_from(
expanded.as_ref(),
base_path.as_ref(),
))
}
pub fn from_absolute_path<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
let expanded = Self::maybe_expand_home_directory(path.as_ref());
Ok(Self(absolutize::absolutize(&expanded)?))
let expanded = normalize_path_for_platform(&expanded);
Ok(Self(absolutize::absolutize(expanded.as_ref())?))
}
pub fn from_absolute_path_checked<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
let expanded = Self::maybe_expand_home_directory(path.as_ref());
let expanded = normalize_path_for_platform(&expanded);
if !expanded.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
@@ -63,15 +71,14 @@ impl AbsolutePathBuf {
));
}
Ok(Self(absolutize::absolutize_from(&expanded, Path::new("/"))))
Ok(Self(absolutize::absolutize_from(
expanded.as_ref(),
Path::new("/"),
)))
}
pub fn current_dir() -> std::io::Result<Self> {
let current_dir = std::env::current_dir()?;
Ok(Self(absolutize::absolutize_from(
&current_dir,
&current_dir,
)))
Self::from_absolute_path(std::env::current_dir()?)
}
/// Construct an absolute path from `path`, resolving relative paths against
@@ -132,6 +139,45 @@ impl AbsolutePathBuf {
}
}
fn normalize_path_for_platform(path: &Path) -> Cow<'_, Path> {
if cfg!(windows)
&& let Some(path) = path.to_str()
&& let Some(normalized) = normalize_windows_device_path(path)
{
return Cow::Owned(PathBuf::from(normalized));
}
Cow::Borrowed(path)
}
fn normalize_windows_device_path(path: &str) -> Option<String> {
if let Some(unc) = path.strip_prefix(r"\\?\UNC\") {
return Some(format!(r"\\{unc}"));
}
if let Some(unc) = path.strip_prefix(r"\\.\UNC\") {
return Some(format!(r"\\{unc}"));
}
if let Some(path) = path.strip_prefix(r"\\?\")
&& is_windows_drive_absolute_path(path)
{
return Some(path.to_string());
}
if let Some(path) = path.strip_prefix(r"\\.\")
&& is_windows_drive_absolute_path(path)
{
return Some(path.to_string());
}
None
}
fn is_windows_drive_absolute_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
}
/// Canonicalize a path when possible, but preserve the logical absolute path
/// whenever canonicalization would rewrite it through a nested symlink.
///
@@ -391,6 +437,43 @@ mod tests {
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn normalize_windows_device_path_strips_supported_verbatim_prefixes() {
assert_eq!(
normalize_windows_device_path(r"\\?\D:\c\x\worktrees\2508\swift-base"),
Some(r"D:\c\x\worktrees\2508\swift-base".to_string())
);
assert_eq!(
normalize_windows_device_path(r"\\.\D:\c\x\worktrees\2508\swift-base"),
Some(r"D:\c\x\worktrees\2508\swift-base".to_string())
);
assert_eq!(
normalize_windows_device_path(r"\\?\UNC\server\share\workspace"),
Some(r"\\server\share\workspace".to_string())
);
assert_eq!(
normalize_windows_device_path(r"\\.\UNC\server\share\workspace"),
Some(r"\\server\share\workspace".to_string())
);
assert_eq!(
normalize_windows_device_path(r"\\?\GLOBALROOT\Device"),
None
);
}
#[cfg(target_os = "windows")]
#[test]
fn from_absolute_path_strips_windows_verbatim_prefix() {
let path =
AbsolutePathBuf::from_absolute_path_checked(r"\\?\D:\c\x\worktrees\2508\swift-base")
.expect("verbatim drive path should be absolute");
assert_eq!(
path.as_path(),
Path::new(r"D:\c\x\worktrees\2508\swift-base")
);
}
#[test]
fn relative_path_is_resolved_against_base_path() {
let temp_dir = tempdir().expect("base dir");