protocol: preserve glob scan depth in permission profiles

This commit is contained in:
Michael Bolin
2026-04-20 12:58:10 -07:00
parent 6b17adc231
commit cb3ad47a67
18 changed files with 326 additions and 12 deletions
@@ -16,6 +16,14 @@
"null"
]
},
"globScanMaxDepth": {
"format": "uint",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"read": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
@@ -16,6 +16,14 @@
"null"
]
},
"globScanMaxDepth": {
"format": "uint",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"read": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
@@ -16,6 +16,14 @@
"null"
]
},
"globScanMaxDepth": {
"format": "uint",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"read": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
@@ -16,6 +16,14 @@
"null"
]
},
"globScanMaxDepth": {
"format": "uint",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"read": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
@@ -16,6 +16,14 @@
"null"
]
},
"globScanMaxDepth": {
"format": "uint",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"read": {
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
@@ -4,4 +4,4 @@
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry";
export type AdditionalFileSystemPermissions = { read: Array<AbsolutePathBuf> | null, write: Array<AbsolutePathBuf> | null, entries?: Array<FileSystemSandboxEntry>, };
export type AdditionalFileSystemPermissions = { read: Array<AbsolutePathBuf> | null, write: Array<AbsolutePathBuf> | null, globScanMaxDepth?: number, entries?: Array<FileSystemSandboxEntry>, };
@@ -2056,6 +2056,7 @@ mod tests {
file_system: Some(v2::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]),
write: None,
glob_scan_max_depth: None,
entries: None,
}),
}),
@@ -1162,6 +1162,9 @@ pub struct AdditionalFileSystemPermissions {
pub write: Option<Vec<AbsolutePathBuf>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub glob_scan_max_depth: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub entries: Option<Vec<FileSystemSandboxEntry>>,
}
@@ -1171,12 +1174,14 @@ impl From<CoreFileSystemPermissions> for AdditionalFileSystemPermissions {
Self {
read,
write,
glob_scan_max_depth: None,
entries: None,
}
} else {
Self {
read: None,
write: None,
glob_scan_max_depth: value.glob_scan_max_depth,
entries: Some(
value
.entries
@@ -1191,16 +1196,19 @@ impl From<CoreFileSystemPermissions> for AdditionalFileSystemPermissions {
impl From<AdditionalFileSystemPermissions> for CoreFileSystemPermissions {
fn from(value: AdditionalFileSystemPermissions) -> Self {
if let Some(entries) = value.entries {
let mut permissions = if let Some(entries) = value.entries {
Self {
entries: entries
.into_iter()
.map(CoreFileSystemSandboxEntry::from)
.collect(),
glob_scan_max_depth: None,
}
} else {
CoreFileSystemPermissions::from_read_write_roots(value.read, value.write)
}
};
permissions.glob_scan_max_depth = value.glob_scan_max_depth;
permissions
}
}
@@ -7084,6 +7092,7 @@ mod tests {
AbsolutePathBuf::try_from(PathBuf::from(read_write_path))
.expect("path must be absolute"),
]),
glob_scan_max_depth: None,
entries: None,
}),
}
@@ -7155,6 +7164,7 @@ mod tests {
access: CoreFileSystemAccessMode::None,
},
],
glob_scan_max_depth: Some(2),
};
let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone());
@@ -7163,6 +7173,7 @@ mod tests {
AdditionalFileSystemPermissions {
read: None,
write: None,
glob_scan_max_depth: Some(2),
entries: Some(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -7225,6 +7236,7 @@ mod tests {
AbsolutePathBuf::try_from(PathBuf::from(read_write_path))
.expect("path must be absolute"),
]),
glob_scan_max_depth: None,
entries: None,
}),
}
+2
View File
@@ -781,6 +781,7 @@ mod tests {
codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]),
write: None,
glob_scan_max_depth: None,
entries: None,
},
),
@@ -844,6 +845,7 @@ mod tests {
codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]),
write: None,
glob_scan_max_depth: None,
entries: None,
},
),
@@ -93,6 +93,7 @@ async fn request_permissions_round_trip() -> Result<()> {
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions {
read: None,
write: Some(vec![requested_writes[0].clone()]),
glob_scan_max_depth: None,
entries: None,
}),
},
+80 -6
View File
@@ -146,6 +146,7 @@ impl SandboxPermissions {
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, JsonSchema, TS)]
pub struct FileSystemPermissions {
pub entries: Vec<FileSystemSandboxEntry>,
pub glob_scan_max_depth: Option<usize>,
}
pub type LegacyReadWriteRoots = (Option<Vec<AbsolutePathBuf>>, Option<Vec<AbsolutePathBuf>>);
@@ -172,7 +173,10 @@ impl FileSystemPermissions {
access: FileSystemAccessMode::Write,
}));
}
Self { entries }
Self {
entries,
glob_scan_max_depth: None,
}
}
pub fn explicit_path_entries(
@@ -190,6 +194,10 @@ impl FileSystemPermissions {
}
fn as_legacy_permissions(&self) -> Option<LegacyFileSystemPermissions> {
if self.glob_scan_max_depth.is_some() {
return None;
}
let mut read = Vec::new();
let mut write = Vec::new();
@@ -225,6 +233,8 @@ struct LegacyFileSystemPermissions {
struct CanonicalFileSystemPermissions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
entries: Vec<FileSystemSandboxEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
glob_scan_max_depth: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -244,6 +254,7 @@ impl Serialize for FileSystemPermissions {
} else {
CanonicalFileSystemPermissions {
entries: self.entries.clone(),
glob_scan_max_depth: self.glob_scan_max_depth,
}
.serialize(serializer)
}
@@ -256,9 +267,13 @@ impl<'de> Deserialize<'de> for FileSystemPermissions {
D: Deserializer<'de>,
{
match FileSystemPermissionsDe::deserialize(deserializer)? {
FileSystemPermissionsDe::Canonical(CanonicalFileSystemPermissions { entries }) => {
Ok(Self { entries })
}
FileSystemPermissionsDe::Canonical(CanonicalFileSystemPermissions {
entries,
glob_scan_max_depth,
}) => Ok(Self {
entries,
glob_scan_max_depth,
}),
FileSystemPermissionsDe::Legacy(LegacyFileSystemPermissions { read, write }) => {
Ok(Self::from_read_write_roots(read, write))
}
@@ -352,13 +367,18 @@ impl From<&FileSystemSandboxPolicy> for FileSystemPermissions {
}]
}
};
Self { entries }
Self {
entries,
glob_scan_max_depth: value.glob_scan_max_depth,
}
}
}
impl From<&FileSystemPermissions> for FileSystemSandboxPolicy {
fn from(value: &FileSystemPermissions) -> Self {
FileSystemSandboxPolicy::restricted(value.entries.clone())
let mut policy = FileSystemSandboxPolicy::restricted(value.entries.clone());
policy.glob_scan_max_depth = value.glob_scan_max_depth;
policy
}
}
@@ -1828,6 +1848,60 @@ mod tests {
assert_eq!(permission_profile.is_empty(), false);
}
#[test]
fn permission_profile_round_trip_preserves_glob_scan_max_depth() {
let mut file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
}]);
file_system_sandbox_policy.glob_scan_max_depth = Some(2);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
assert_eq!(
permission_profile.file_system_sandbox_policy(),
file_system_sandbox_policy
);
}
#[test]
fn file_system_permissions_with_glob_scan_depth_uses_canonical_json() -> Result<()> {
let path = AbsolutePathBuf::try_from(PathBuf::from(if cfg!(windows) {
r"C:\tmp\allowed"
} else {
"/tmp/allowed"
}))
.expect("absolute path");
let file_system_permissions = FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
}],
glob_scan_max_depth: Some(2),
};
let serialized = serde_json::to_value(&file_system_permissions)?;
assert_eq!(serialized.get("read"), None);
assert_eq!(serialized.get("write"), None);
assert_eq!(
serialized.get("glob_scan_max_depth"),
Some(&serde_json::json!(2))
);
assert!(serialized.get("entries").is_some());
assert_eq!(
serde_json::from_value::<FileSystemPermissions>(serialized)?,
file_system_permissions
);
Ok(())
}
#[test]
fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
let contents = vec![serde_json::json!({
+69 -3
View File
@@ -45,6 +45,7 @@ pub fn normalize_additional_permissions(
let file_system = match additional_permissions.file_system {
Some(file_system) => {
let mut entries = Vec::with_capacity(file_system.entries.len());
let glob_scan_max_depth = file_system.glob_scan_max_depth;
for entry in file_system.entries {
if matches!(&entry.path, FileSystemPath::GlobPattern { .. })
&& entry.access != FileSystemAccessMode::None
@@ -73,7 +74,10 @@ pub fn normalize_additional_permissions(
entries.push(normalized_entry);
}
}
let file_system = FileSystemPermissions { entries };
let file_system = FileSystemPermissions {
entries,
glob_scan_max_depth,
};
(!file_system.is_empty()).then_some(file_system)
}
None => None,
@@ -114,6 +118,12 @@ pub fn merge_permission_profiles(
let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) {
(Some(base), Some(permissions)) => Some(FileSystemPermissions {
entries: merge_permission_entries(&base.entries, &permissions.entries),
glob_scan_max_depth: merge_glob_scan_max_depth(
&base.entries,
base.glob_scan_max_depth,
&permissions.entries,
permissions.glob_scan_max_depth,
),
})
.filter(|file_system| !file_system.is_empty()),
(Some(base), None) => Some(base.clone()),
@@ -139,12 +149,20 @@ pub fn intersect_permission_profiles(
.file_system
.map(|requested_file_system| {
let granted_file_system = granted.file_system.unwrap_or_default();
let entries = requested_file_system
let entries: Vec<_> = requested_file_system
.entries
.into_iter()
.filter(|entry| granted_file_system.entries.contains(entry))
.collect();
FileSystemPermissions { entries }
FileSystemPermissions {
glob_scan_max_depth: merge_glob_scan_max_depth(
&entries,
requested_file_system.glob_scan_max_depth,
&entries,
granted_file_system.glob_scan_max_depth,
),
entries,
}
})
.filter(|file_system| !file_system.is_empty());
let network = match (requested.network, granted.network) {
@@ -167,6 +185,48 @@ pub fn intersect_permission_profiles(
}
}
fn merge_glob_scan_max_depth(
left_entries: &[FileSystemSandboxEntry],
left_depth: Option<usize>,
right_entries: &[FileSystemSandboxEntry],
right_depth: Option<usize>,
) -> Option<usize> {
let left_depth = effective_glob_scan_depth(left_entries, left_depth);
let right_depth = effective_glob_scan_depth(right_entries, right_depth);
match (left_depth, right_depth) {
(Some(GlobScanDepth::Unbounded), _) | (_, Some(GlobScanDepth::Unbounded)) => None,
(Some(GlobScanDepth::Bounded(left)), Some(GlobScanDepth::Bounded(right))) => {
Some(left.max(right))
}
(Some(GlobScanDepth::Bounded(depth)), None)
| (None, Some(GlobScanDepth::Bounded(depth))) => Some(depth),
(None, None) => None,
}
}
fn effective_glob_scan_depth(
entries: &[FileSystemSandboxEntry],
depth: Option<usize>,
) -> Option<GlobScanDepth> {
entries
.iter()
.any(|entry| {
entry.access == FileSystemAccessMode::None
&& matches!(&entry.path, FileSystemPath::GlobPattern { .. })
})
.then_some(match depth {
Some(depth) => GlobScanDepth::Bounded(depth),
None => GlobScanDepth::Unbounded,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GlobScanDepth {
Bounded(usize),
Unbounded,
}
fn merge_permission_entries(
base: &[FileSystemSandboxEntry],
permissions: &[FileSystemSandboxEntry],
@@ -234,6 +294,12 @@ fn merge_file_system_policy_with_additional_permissions(
merged_policy.entries.push(entry.clone());
}
}
merged_policy.glob_scan_max_depth = merge_glob_scan_max_depth(
&file_system_policy.entries,
file_system_policy.glob_scan_max_depth,
&additional_permissions.entries,
additional_permissions.glob_scan_max_depth,
);
merged_policy
}
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
@@ -171,6 +171,7 @@ fn normalize_additional_permissions_rejects_glob_read_grants() {
},
access: FileSystemAccessMode::Read,
}],
glob_scan_max_depth: None,
}),
..Default::default()
})
@@ -192,6 +193,7 @@ fn normalize_additional_permissions_preserves_deny_globs() {
},
access: FileSystemAccessMode::None,
}],
glob_scan_max_depth: Some(2),
}),
..Default::default()
})
@@ -207,6 +209,7 @@ fn normalize_additional_permissions_preserves_deny_globs() {
},
access: FileSystemAccessMode::None,
}],
glob_scan_max_depth: Some(2),
}),
..Default::default()
}
@@ -288,6 +291,76 @@ fn intersect_permission_profiles_drops_explicit_empty_reads_without_grant() {
);
}
#[test]
fn intersect_permission_profiles_uses_granted_bounded_glob_scan_depth() {
let deny_env_files = FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
};
let requested = PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![deny_env_files.clone()],
glob_scan_max_depth: Some(2),
}),
..Default::default()
};
let granted = PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![deny_env_files.clone()],
glob_scan_max_depth: Some(4),
}),
..Default::default()
};
assert_eq!(
intersect_permission_profiles(requested, granted),
PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![deny_env_files],
glob_scan_max_depth: Some(4),
}),
..Default::default()
}
);
}
#[test]
fn intersect_permission_profiles_uses_granted_unbounded_glob_scan_depth() {
let deny_env_files = FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
};
let requested = PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![deny_env_files.clone()],
glob_scan_max_depth: Some(2),
}),
..Default::default()
};
let granted = PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![deny_env_files.clone()],
glob_scan_max_depth: None,
}),
..Default::default()
};
assert_eq!(
intersect_permission_profiles(requested, granted),
PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![deny_env_files],
glob_scan_max_depth: None,
}),
..Default::default()
}
);
}
#[test]
fn read_only_additional_permissions_can_enable_network_without_writes() {
let temp_dir = TempDir::new().expect("create temp dir");
@@ -402,6 +475,42 @@ fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roo
);
}
#[test]
fn merge_file_system_policy_with_additional_permissions_carries_bounded_glob_scan_depth() {
let deny_env_files = FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
};
let merged_policy = merge_file_system_policy_with_additional_permissions(
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]),
&FileSystemPermissions {
entries: vec![deny_env_files.clone()],
glob_scan_max_depth: Some(2),
},
);
assert_eq!(merged_policy, {
let mut policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
},
deny_env_files,
]);
policy.glob_scan_max_depth = Some(2);
policy
});
}
#[test]
fn effective_file_system_sandbox_policy_returns_base_policy_without_additional_permissions() {
let temp_dir = TempDir::new().expect("create temp dir");
+2
View File
@@ -9665,6 +9665,7 @@ guardian_approval = true
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]),
write: Some(vec![test_absolute_path("/tmp/write")]),
glob_scan_max_depth: None,
entries: None,
}),
});
@@ -9773,6 +9774,7 @@ guardian_approval = true
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]),
write: Some(vec![test_absolute_path("/tmp/write")]),
glob_scan_max_depth: None,
entries: None,
}),
},
@@ -558,6 +558,7 @@ mod tests {
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![absolute_path(read_path)]),
write: Some(vec![absolute_path(write_path)]),
glob_scan_max_depth: None,
entries: None,
}),
},
@@ -92,6 +92,7 @@ mod tests {
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/read-only")]),
write: Some(vec![absolute_path("/tmp/write")]),
glob_scan_max_depth: None,
entries: None,
}),
}
@@ -109,6 +110,7 @@ mod tests {
},
access: FileSystemAccessMode::Write,
}],
glob_scan_max_depth: None,
}),
..Default::default()
}),
@@ -117,6 +119,7 @@ mod tests {
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions {
read: None,
write: None,
glob_scan_max_depth: None,
entries: Some(vec![codex_app_server_protocol::FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Special {
value: codex_app_server_protocol::FileSystemSpecialPath::Root,
@@ -1372,6 +1372,7 @@ mod tests {
access: FileSystemAccessMode::None,
},
],
glob_scan_max_depth: None,
}),
..Default::default()
};
@@ -113,6 +113,7 @@ fn app_server_exec_approval_request_preserves_permissions_context() {
file_system: Some(AppServerAdditionalFileSystemPermissions {
read: Some(vec![read_path.clone()]),
write: Some(vec![write_path.clone()]),
glob_scan_max_depth: None,
entries: None,
}),
}),
@@ -163,6 +164,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() {
file_system: Some(AppServerAdditionalFileSystemPermissions {
read: Some(vec![read_path.clone()]),
write: Some(vec![write_path.clone()]),
glob_scan_max_depth: None,
entries: None,
}),
},