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

@@ -21,6 +21,10 @@ use codex_features::Features;
use codex_protocol::ThreadId;
use codex_protocol::mcp::RequestId;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::ElicitationAction;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::NetworkApprovalContext;
@@ -798,22 +802,33 @@ pub(crate) fn format_additional_permissions_rule(
parts.push("network".to_string());
}
if let Some(file_system) = additional_permissions.file_system.as_ref() {
if let Some(read) = file_system.read.as_ref() {
let reads = read
let reads = format_file_system_entry_paths(
file_system
.entries
.iter()
.map(|path| format!("`{}`", path.display()))
.collect::<Vec<_>>()
.join(", ");
.filter(|entry| entry.access == FileSystemAccessMode::Read),
);
if !reads.is_empty() {
parts.push(format!("read {reads}"));
}
if let Some(write) = file_system.write.as_ref() {
let writes = write
let writes = format_file_system_entry_paths(
file_system
.entries
.iter()
.map(|path| format!("`{}`", path.display()))
.collect::<Vec<_>>()
.join(", ");
.filter(|entry| entry.access == FileSystemAccessMode::Write),
);
if !writes.is_empty() {
parts.push(format!("write {writes}"));
}
let denied_reads = format_file_system_entry_paths(
file_system
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None),
);
if !denied_reads.is_empty() {
parts.push(format!("deny read {denied_reads}"));
}
}
if parts.is_empty() {
None
@@ -828,6 +843,38 @@ pub(crate) fn format_requested_permissions_rule(
format_additional_permissions_rule(&permissions.clone().into())
}
fn format_file_system_entry_paths<'a>(
entries: impl Iterator<Item = &'a FileSystemSandboxEntry>,
) -> String {
entries
.map(|entry| match &entry.path {
FileSystemPath::Path { path } => format!("`{}`", path.display()),
FileSystemPath::GlobPattern { pattern } => format!("glob `{pattern}`"),
FileSystemPath::Special { value } => format!("`{}`", special_path_label(value)),
})
.collect::<Vec<_>>()
.join(", ")
}
fn special_path_label(value: &FileSystemSpecialPath) -> String {
match value {
FileSystemSpecialPath::Root => ":root".to_string(),
FileSystemSpecialPath::Minimal => ":minimal".to_string(),
FileSystemSpecialPath::CurrentWorkingDirectory => ":cwd".to_string(),
FileSystemSpecialPath::ProjectRoots { subpath } => path_label(":project_roots", subpath),
FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(),
FileSystemSpecialPath::SlashTmp => "/tmp".to_string(),
FileSystemSpecialPath::Unknown { path, subpath } => path_label(path, subpath),
}
}
fn path_label(base: &str, subpath: &Option<PathBuf>) -> String {
match subpath {
Some(subpath) => format!("{base}/{}", subpath.display()),
None => base.to_string(),
}
}
fn patch_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
@@ -903,6 +950,9 @@ mod tests {
use crate::app_event::AppEvent;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::ExecPolicyAmendment;
use codex_protocol::protocol::NetworkApprovalProtocol;
use codex_protocol::protocol::NetworkPolicyAmendment;
@@ -965,10 +1015,10 @@ mod tests {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![absolute_path("/tmp/readme.txt")]),
write: Some(vec![absolute_path("/tmp/out.txt")]),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)),
},
}
}
@@ -1266,10 +1316,10 @@ mod tests {
#[test]
fn additional_permissions_exec_options_hide_execpolicy_amendment() {
let additional_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![absolute_path("/tmp/readme.txt")]),
write: Some(vec![absolute_path("/tmp/out.txt")]),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)),
..Default::default()
};
let options = exec_options(
@@ -1304,6 +1354,34 @@ mod tests {
);
}
#[test]
fn additional_permissions_rule_shows_non_path_file_system_entries() {
let additional_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
},
],
}),
..Default::default()
};
assert_eq!(
format_additional_permissions_rule(&additional_permissions),
Some("write `:root`; deny read glob `**/*.env`".to_string())
);
}
#[test]
fn permissions_session_shortcut_submits_session_scope() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
@@ -1347,10 +1425,10 @@ mod tests {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![absolute_path("/tmp/readme.txt")]),
write: Some(vec![absolute_path("/tmp/out.txt")]),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)),
}),
};
@@ -1397,10 +1475,10 @@ mod tests {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![absolute_path("/tmp/readme.txt")]),
write: Some(vec![absolute_path("/tmp/out.txt")]),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)),
}),
};