permissions: start using PermissionProfile as the canonical runtime model

This commit is contained in:
Michael Bolin
2026-03-26 19:43:49 -07:00
parent e6e2999209
commit 00a3cea3bb
34 changed files with 2035 additions and 366 deletions
+245 -4
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::io;
use std::path::Path;
use codex_utils_image::PromptImageMode;
@@ -12,6 +13,12 @@ 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::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;
@@ -65,15 +72,119 @@ 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>,
}
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::Special { .. } => None,
})
}
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))
}
}
}
}
@@ -98,6 +209,80 @@ 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 {
crate::permissions::FileSystemSandboxKind::Restricted => value.entries.clone(),
crate::permissions::FileSystemSandboxKind::Unrestricted
| crate::permissions::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)]
@@ -1418,6 +1603,10 @@ mod tests {
use std::path::PathBuf;
use tempfile::tempdir;
fn absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(path).expect("path must be absolute")
}
#[test]
fn sandbox_permissions_helpers_match_documented_semantics() {
let cases = [
@@ -1528,6 +1717,58 @@ mod tests {
assert_eq!(permission_profile.is_empty(), false);
}
#[test]
fn file_system_permissions_deserialize_legacy_read_write_shape() {
let file_system = serde_json::from_value::<FileSystemPermissions>(serde_json::json!({
"read": [absolute_path("/tmp/read-only")],
"write": [absolute_path("/tmp/read-write")],
}))
.expect("deserialize legacy filesystem permissions");
assert_eq!(
file_system,
FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/read-only")]),
Some(vec![absolute_path("/tmp/read-write")]),
)
);
}
#[test]
fn file_system_permissions_serialize_explicit_paths_as_legacy_read_write_shape() {
let file_system = FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/read-only")]),
Some(vec![absolute_path("/tmp/read-write")]),
);
let value = serde_json::to_value(file_system).expect("serialize filesystem permissions");
assert_eq!(
value,
serde_json::json!({
"read": [absolute_path("/tmp/read-only")],
"write": [absolute_path("/tmp/read-write")],
})
);
}
#[test]
fn file_system_permissions_round_trip_special_entries_through_canonical_shape() {
let file_system = FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
}],
};
let value = serde_json::to_value(&file_system).expect("serialize filesystem permissions");
let reparsed = serde_json::from_value::<FileSystemPermissions>(value)
.expect("deserialize filesystem permissions");
assert_eq!(reparsed, file_system);
}
#[test]
fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
let contents = vec![serde_json::json!({
+48 -3
View File
@@ -45,6 +45,7 @@ impl NetworkSandboxPolicy {
Copy,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
Serialize,
@@ -71,7 +72,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 {
@@ -114,7 +115,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,
@@ -155,7 +156,7 @@ struct FileSystemSemanticSignature {
unreadable_roots: Vec<AbsolutePathBuf>,
}
#[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 {
@@ -369,6 +370,50 @@ impl FileSystemSandboxPolicy {
self.resolve_access_with_cwd(path, cwd).can_write()
}
pub fn covers_entry_with_cwd(&self, entry: &FileSystemSandboxEntry, cwd: &Path) -> bool {
match &entry.path {
FileSystemPath::Path { path } => match entry.access {
FileSystemAccessMode::Read => self.can_read_path_with_cwd(path.as_path(), cwd),
FileSystemAccessMode::Write => self.can_write_path_with_cwd(path.as_path(), cwd),
FileSystemAccessMode::None => {
self.resolve_access_with_cwd(path.as_path(), cwd) == FileSystemAccessMode::None
}
},
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => match entry.access {
FileSystemAccessMode::Read => self.has_full_disk_read_access(),
FileSystemAccessMode::Write => self.has_full_disk_write_access(),
FileSystemAccessMode::None => {
!self.has_full_disk_read_access() && !self.has_full_disk_write_access()
}
},
FileSystemSpecialPath::Minimal => match entry.access {
FileSystemAccessMode::Read => {
self.has_full_disk_read_access() || self.include_platform_defaults()
}
FileSystemAccessMode::Write => self.has_full_disk_write_access(),
FileSystemAccessMode::None => {
!self.has_full_disk_read_access() && !self.include_platform_defaults()
}
},
_ => resolve_file_system_special_path(
value,
AbsolutePathBuf::from_absolute_path(cwd).ok().as_ref(),
)
.is_some_and(|path| match entry.access {
FileSystemAccessMode::Read => self.can_read_path_with_cwd(path.as_path(), cwd),
FileSystemAccessMode::Write => {
self.can_write_path_with_cwd(path.as_path(), cwd)
}
FileSystemAccessMode::None => {
self.resolve_access_with_cwd(path.as_path(), cwd)
== FileSystemAccessMode::None
}
}),
},
}
}
pub fn with_additional_readable_roots(
mut self,
cwd: &Path,