protocol: canonicalize file system permissions (#18274)

## Why

`PermissionProfile` needs stable, canonical file-system semantics before
it can become the primary runtime permissions abstraction. Without a
canonical form, callers have to keep re-deriving legacy sandbox maps and
profile comparisons remain lossy or order-dependent.

## What changed

This adds canonicalization helpers for `FileSystemPermissions` and
`PermissionProfile`, expands special paths into explicit sandbox
entries, and updates permission request/conversion paths to consume
those canonical entries. It also tightens the legacy bridge so root-wide
write profiles with narrower carveouts are not silently projected as
full-disk legacy access.

## Verification

- `cargo test -p codex-protocol
root_write_with_read_only_child_is_not_full_disk_write -- --nocapture`
- `cargo test -p codex-sandboxing permission -- --nocapture`
- `cargo test -p codex-tui permissions -- --nocapture`
This commit is contained in:
Michael Bolin
2026-04-20 09:57:03 -07:00
committed by GitHub
parent ac7c9a685f
commit dcec516313
41 changed files with 2076 additions and 385 deletions

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
@@ -16,6 +17,13 @@ use ts_rs::TS;
use crate::config_types::ApprovalsReviewer;
use crate::config_types::CollaborationMode;
use crate::config_types::SandboxMode;
use crate::permissions::FileSystemAccessMode;
use crate::permissions::FileSystemPath;
use crate::permissions::FileSystemSandboxEntry;
use crate::permissions::FileSystemSandboxKind;
use crate::permissions::FileSystemSandboxPolicy;
use crate::permissions::FileSystemSpecialPath;
use crate::permissions::NetworkSandboxPolicy;
use crate::protocol::AskForApproval;
use crate::protocol::COLLABORATION_MODE_CLOSE_TAG;
use crate::protocol::COLLABORATION_MODE_OPEN_TAG;
@@ -135,15 +143,126 @@ impl SandboxPermissions {
}
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, JsonSchema, TS)]
pub struct FileSystemPermissions {
pub read: Option<Vec<AbsolutePathBuf>>,
pub write: Option<Vec<AbsolutePathBuf>>,
pub entries: Vec<FileSystemSandboxEntry>,
}
pub type LegacyReadWriteRoots = (Option<Vec<AbsolutePathBuf>>, Option<Vec<AbsolutePathBuf>>);
impl FileSystemPermissions {
pub fn is_empty(&self) -> bool {
self.read.is_none() && self.write.is_none()
self.entries.is_empty()
}
pub fn from_read_write_roots(
read: Option<Vec<AbsolutePathBuf>>,
write: Option<Vec<AbsolutePathBuf>>,
) -> Self {
let mut entries = Vec::new();
if let Some(read) = read {
entries.extend(read.into_iter().map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
}));
}
if let Some(write) = write {
entries.extend(write.into_iter().map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
}));
}
Self { entries }
}
pub fn explicit_path_entries(
&self,
) -> impl Iterator<Item = (&AbsolutePathBuf, FileSystemAccessMode)> {
self.entries.iter().filter_map(|entry| match &entry.path {
FileSystemPath::Path { path } => Some((path, entry.access)),
FileSystemPath::GlobPattern { .. } | FileSystemPath::Special { .. } => None,
})
}
pub fn legacy_read_write_roots(&self) -> Option<LegacyReadWriteRoots> {
self.as_legacy_permissions()
.map(|legacy| (legacy.read, legacy.write))
}
fn as_legacy_permissions(&self) -> Option<LegacyFileSystemPermissions> {
let mut read = Vec::new();
let mut write = Vec::new();
for entry in &self.entries {
let FileSystemPath::Path { path } = &entry.path else {
return None;
};
match entry.access {
FileSystemAccessMode::Read => read.push(path.clone()),
FileSystemAccessMode::Write => write.push(path.clone()),
FileSystemAccessMode::None => return None,
}
}
Some(LegacyFileSystemPermissions {
read: (!read.is_empty()).then_some(read),
write: (!write.is_empty()).then_some(write),
})
}
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyFileSystemPermissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
read: Option<Vec<AbsolutePathBuf>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
write: Option<Vec<AbsolutePathBuf>>,
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CanonicalFileSystemPermissions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
entries: Vec<FileSystemSandboxEntry>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum FileSystemPermissionsDe {
Canonical(CanonicalFileSystemPermissions),
Legacy(LegacyFileSystemPermissions),
}
impl Serialize for FileSystemPermissions {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Some(legacy) = self.as_legacy_permissions() {
legacy.serialize(serializer)
} else {
CanonicalFileSystemPermissions {
entries: self.entries.clone(),
}
.serialize(serializer)
}
}
}
impl<'de> Deserialize<'de> for FileSystemPermissions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
match FileSystemPermissionsDe::deserialize(deserializer)? {
FileSystemPermissionsDe::Canonical(CanonicalFileSystemPermissions { entries }) => {
Ok(Self { entries })
}
FileSystemPermissionsDe::Legacy(LegacyFileSystemPermissions { read, write }) => {
Ok(Self::from_read_write_roots(read, write))
}
}
}
}
@@ -168,6 +287,79 @@ impl PermissionProfile {
pub fn is_empty(&self) -> bool {
self.network.is_none() && self.file_system.is_none()
}
pub fn from_runtime_permissions(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
) -> Self {
Self {
network: Some(network_sandbox_policy.into()),
file_system: Some(file_system_sandbox_policy.into()),
}
}
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
Self::from_runtime_permissions(
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, cwd),
NetworkSandboxPolicy::from(sandbox_policy),
)
}
pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy {
self.file_system.as_ref().map_or_else(
|| FileSystemSandboxPolicy::restricted(Vec::new()),
FileSystemSandboxPolicy::from,
)
}
pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy {
if self
.network
.as_ref()
.and_then(|network| network.enabled)
.unwrap_or(false)
{
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
}
}
pub fn to_legacy_sandbox_policy(&self, cwd: &Path) -> io::Result<SandboxPolicy> {
self.file_system_sandbox_policy()
.to_legacy_sandbox_policy(self.network_sandbox_policy(), cwd)
}
}
impl From<NetworkSandboxPolicy> for NetworkPermissions {
fn from(value: NetworkSandboxPolicy) -> Self {
Self {
enabled: Some(value.is_enabled()),
}
}
}
impl From<&FileSystemSandboxPolicy> for FileSystemPermissions {
fn from(value: &FileSystemSandboxPolicy) -> Self {
let entries = match value.kind {
FileSystemSandboxKind::Restricted => value.entries.clone(),
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]
}
};
Self { entries }
}
}
impl From<&FileSystemPermissions> for FileSystemSandboxPolicy {
fn from(value: &FileSystemPermissions) -> Self {
FileSystemSandboxPolicy::restricted(value.entries.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]

View File

@@ -46,6 +46,7 @@ impl NetworkSandboxPolicy {
Debug,
Clone,
Copy,
Hash,
PartialEq,
Eq,
PartialOrd,
@@ -74,7 +75,7 @@ impl FileSystemAccessMode {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[ts(tag = "kind")]
pub enum FileSystemSpecialPath {
@@ -117,7 +118,7 @@ impl FileSystemSpecialPath {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
pub struct FileSystemSandboxEntry {
pub path: FileSystemPath,
pub access: FileSystemAccessMode,
@@ -239,7 +240,7 @@ impl ReadDenyMatcher {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum FileSystemPath {
@@ -755,13 +756,14 @@ impl FileSystemSandboxPolicy {
FileSystemSandboxKind::Restricted => {
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
let mut include_platform_defaults = false;
let mut has_full_disk_read_access = false;
let mut has_full_disk_write_access = false;
let has_full_disk_read_access = self.has_full_disk_read_access();
let has_full_disk_write_access = self.has_full_disk_write_access();
let mut workspace_root_writable = false;
let mut writable_roots = Vec::new();
let mut readable_roots = Vec::new();
let mut tmpdir_writable = false;
let mut slash_tmp_writable = false;
let mut unbridgeable_root_write = false;
for entry in &self.entries {
match &entry.path {
@@ -780,10 +782,15 @@ impl FileSystemSandboxPolicy {
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => match entry.access {
FileSystemAccessMode::None => {}
FileSystemAccessMode::Read => has_full_disk_read_access = true,
FileSystemAccessMode::Read => {
if !has_full_disk_read_access
&& let Some(cwd) = cwd_absolute.as_ref()
{
readable_roots.push(absolute_root_path_for_cwd(cwd));
}
}
FileSystemAccessMode::Write => {
has_full_disk_read_access = true;
has_full_disk_write_access = true;
unbridgeable_root_write = true;
}
},
FileSystemSpecialPath::Minimal => {
@@ -878,7 +885,11 @@ impl FileSystemSandboxPolicy {
exclude_tmpdir_env_var: !tmpdir_writable,
exclude_slash_tmp: !slash_tmp_writable,
}
} else if !writable_roots.is_empty() || tmpdir_writable || slash_tmp_writable {
} else if unbridgeable_root_write
|| !writable_roots.is_empty()
|| tmpdir_writable
|| slash_tmp_writable
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly",
@@ -2087,6 +2098,11 @@ mod tests {
assert!(
policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
);
assert!(
policy
.to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd.path())
.is_err()
);
}
#[test]