Centralize filesystem permission resolution for sandbox enforcement (#59009)

## What changed

- Add `resolve_for_enforcement` to collect filesystem grants, denials, glob patterns, and platform defaults with backend-specific path handling.
- Use the shared result in Linux bubblewrap and macOS Seatbelt policy generation, including Seatbelt scratch access and filesystem policy equivalence checks.
- Pass already-derived filesystem and network policies to Linux seccomp setup instead of converting the permission profile again.

## Testing

Extend the Seatbelt glob test to execute sandboxed commands and verify that relative deny patterns through a symlink block reads and writes while sibling files remain editable.

GitOrigin-RevId: 748603f6b5a6db739b198a0f93a899041612370a
This commit is contained in:
viyatb-oai
2026-11-02 23:19:47 +00:00
committed by copyberry
parent 26f73fba8f
commit d92aae5501
9 changed files with 359 additions and 252 deletions
+121 -126
View File
@@ -25,6 +25,7 @@ use crate::protocol::SandboxPolicy;
use crate::protocol::WritableRoot;
mod deny_read_validator;
mod enforcement;
mod local_aliases;
mod target;
mod windows_glob;
@@ -33,6 +34,8 @@ use local_aliases::LocalPolicyContext;
pub use deny_read_validator::DenyReadValidator;
pub use deny_read_validator::DenyReadViolation;
pub use enforcement::FileSystemPolicyProjection;
pub use enforcement::ResolvedFileSystemPermissions;
pub use local_aliases::LocalFileSystemPolicyMatcher;
pub use windows_glob::WindowsDenyReadGlobScan;
pub use windows_glob::windows_deny_read_glob_scan;
@@ -244,27 +247,6 @@ pub struct FileSystemSandboxPolicy {
pub entries: Vec<FileSystemSandboxEntry>,
}
#[derive(Clone, Copy)]
enum WritableRootPathResolution {
Effective,
PreserveMutableComponents,
}
#[derive(Clone, Copy)]
enum RootMetadataWriteMounts {
Separate,
InheritWritableRoot,
}
impl WritableRootPathResolution {
fn resolve(self, path: AbsolutePathBuf) -> AbsolutePathBuf {
match self {
Self::Effective => normalize_effective_absolute_path(path),
Self::PreserveMutableComponents => path.normalize_system_aliases().unwrap_or(path),
}
}
}
/// Serialized filesystem policy used at legacy string-based seams.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[schemars(rename = "FileSystemSandboxPolicy")]
@@ -301,17 +283,6 @@ struct PreparedFileSystemEntry<'a> {
effective_path: AbsolutePathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FileSystemSemanticSignature {
has_full_disk_read_access: bool,
has_full_disk_write_access: bool,
include_platform_defaults: bool,
readable_roots: Vec<AbsolutePathBuf>,
writable_roots: Vec<WritableRoot>,
unreadable_roots: Vec<AbsolutePathBuf>,
unreadable_globs: Vec<String>,
}
/// Runtime matcher for read-deny entries in a filesystem sandbox policy.
pub struct ReadDenyMatcher {
native_cwd: Option<AbsolutePathBuf>,
@@ -690,6 +661,7 @@ impl FileSystemSandboxPolicy {
self.has_denied_read_restrictions_for_convention(Some(PathConvention::native()))
}
/// Returns whether this policy denies reads on the selected executor.
fn has_denied_read_restrictions_for_convention(
&self,
convention: Option<PathConvention>,
@@ -1440,15 +1412,21 @@ impl FileSystemSandboxPolicy {
return true;
}
self.semantic_signature(cwd)
!= legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd)
.semantic_signature(cwd)
!self.is_semantically_equivalent_to(
&legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd),
cwd,
)
}
/// Returns true when two policies resolve to the same filesystem access
/// model for `cwd`, ignoring incidental entry ordering.
pub fn is_semantically_equivalent_to(&self, other: &Self, cwd: &Path) -> bool {
self.semantic_signature(cwd) == other.semantic_signature(cwd)
let Ok(signature) = self.semantic_signature(cwd) else {
return false;
};
other
.semantic_signature(cwd)
.is_ok_and(|other| signature == other)
}
/// Returns the explicit readable roots resolved against the provided cwd.
@@ -1463,16 +1441,23 @@ impl FileSystemSandboxPolicy {
return Vec::new();
};
Self::readable_roots(&self.resolved_entries_with_cwd(cwd), &matching)
}
fn readable_roots(
entries: &[ResolvedFileSystemEntry],
matching: &LocalFileSystemPolicyMatcher<'_>,
) -> Vec<AbsolutePathBuf> {
dedup_absolute_paths(
self.resolved_entries_with_cwd(cwd)
.into_iter()
entries
.iter()
.filter(|entry| entry.access.can_read())
.filter(|entry| {
matching
.resolve_access(&PathUri::from_abs_path(&entry.path))
.can_read()
})
.map(|entry| entry.path)
.map(|entry| entry.path.clone())
.collect(),
/*normalize_effective_paths*/ true,
)
@@ -1481,11 +1466,7 @@ impl FileSystemSandboxPolicy {
/// Returns the writable roots together with read-only carveouts resolved
/// against the provided cwd.
pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
self.get_writable_roots_with_cwd_impl(
cwd,
WritableRootPathResolution::Effective,
RootMetadataWriteMounts::Separate,
)
self.get_writable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::Native)
}
/// Omits redundant root-metadata mounts when the filesystem root is writable.
@@ -1497,11 +1478,7 @@ impl FileSystemSandboxPolicy {
&self,
cwd: &Path,
) -> Vec<WritableRoot> {
self.get_writable_roots_with_cwd_impl(
cwd,
WritableRootPathResolution::Effective,
RootMetadataWriteMounts::InheritWritableRoot,
)
self.get_writable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::LinuxMounts)
}
/// Reports configured writable roots for diagnostics without inspecting the filesystem.
@@ -1539,18 +1516,13 @@ impl FileSystemSandboxPolicy {
&self,
cwd: &Path,
) -> Vec<WritableRoot> {
self.get_writable_roots_with_cwd_impl(
cwd,
WritableRootPathResolution::PreserveMutableComponents,
RootMetadataWriteMounts::Separate,
)
self.get_writable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::PreserveMutableRoots)
}
fn get_writable_roots_with_cwd_impl(
&self,
cwd: &Path,
path_resolution: WritableRootPathResolution,
root_metadata_mounts: RootMetadataWriteMounts,
projection: FileSystemPolicyProjection,
) -> Vec<WritableRoot> {
if self.has_full_disk_write_access() {
return Vec::new();
@@ -1569,6 +1541,15 @@ impl FileSystemSandboxPolicy {
let Ok(matching) = self.prepare_local_matching(&local_context.as_context()) else {
return Vec::new();
};
Self::writable_roots(cwd, &resolved_entries, &matching, projection)
}
fn writable_roots(
cwd: &Path,
resolved_entries: &[ResolvedFileSystemEntry],
matching: &LocalFileSystemPolicyMatcher<'_>,
projection: FileSystemPolicyProjection,
) -> Vec<WritableRoot> {
// Resolve precedence and filesystem aliases once per entry, rather than repeating
// that work for every writable root while collecting its read-only carveouts.
let effective_entries: Vec<&ResolvedFileSystemEntry> = resolved_entries
@@ -1588,23 +1569,25 @@ impl FileSystemSandboxPolicy {
}
// Include resolved gitdirs in the entries used to carve out broader grants.
// Seatbelt grants are independent, and later bubblewrap binds cover earlier mounts.
let include_resolved_gitdirs = match path_resolution {
WritableRootPathResolution::Effective => cfg!(target_os = "linux"),
WritableRootPathResolution::PreserveMutableComponents => cfg!(target_os = "macos"),
let include_resolved_gitdirs = match projection {
FileSystemPolicyProjection::Native | FileSystemPolicyProjection::LinuxMounts => {
cfg!(target_os = "linux")
}
FileSystemPolicyProjection::PreserveMutableRoots => cfg!(target_os = "macos"),
};
let resolved_gitdir_entries: Vec<ResolvedFileSystemEntry> = if include_resolved_gitdirs {
effective_entries
.iter()
.filter(|entry| entry.access.can_write())
.filter_map(|entry| {
let dot_git = path_resolution
.resolve(entry.path.clone())
let dot_git = projection
.resolve_writable_path(entry.path.clone())
.join(PROTECTED_METADATA_GIT_PATH_NAME);
is_git_pointer_file(&dot_git)
.then(|| resolve_gitdir_from_file(&dot_git))
.flatten()
})
.filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path))
.filter(|path| !has_explicit_resolved_path_entry(resolved_entries, path))
.map(|path| ResolvedFileSystemEntry {
path,
access: FileSystemAccessMode::Read,
@@ -1617,7 +1600,7 @@ impl FileSystemSandboxPolicy {
.into_iter()
.map(|entry| PreparedFileSystemEntry {
entry,
effective_path: path_resolution.resolve(entry.path.clone()),
effective_path: projection.resolve_writable_path(entry.path.clone()),
})
.chain(resolved_gitdir_entries.iter().map(|entry| {
PreparedFileSystemEntry {
@@ -1629,18 +1612,14 @@ impl FileSystemSandboxPolicy {
.canonicalize()
.ok()
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
.unwrap_or_else(|| {
WritableRootPathResolution::Effective.resolve(entry.path.clone())
}),
.unwrap_or_else(|| normalize_effective_absolute_path(entry.path.clone())),
}
}))
.collect();
let inherits_root_metadata = matches!(
root_metadata_mounts,
RootMetadataWriteMounts::InheritWritableRoot
) && prepared_entries.iter().any(|entry| {
entry.entry.access.can_write() && entry.entry.path.as_path().parent().is_none()
});
let inherits_root_metadata = projection == FileSystemPolicyProjection::LinuxMounts
&& prepared_entries.iter().any(|entry| {
entry.entry.access.can_write() && entry.entry.path.as_path().parent().is_none()
});
let writable_entries: Vec<&PreparedFileSystemEntry<'_>> = prepared_entries
.iter()
.filter(|entry| entry.entry.access.can_write())
@@ -1654,7 +1633,7 @@ impl FileSystemSandboxPolicy {
let effective_cwd = AbsolutePathBuf::from_absolute_path(cwd)
.ok()
.map(|cwd| path_resolution.resolve(cwd));
.map(|cwd| projection.resolve_writable_path(cwd));
dedup_absolute_paths(
writable_entries
@@ -1679,12 +1658,12 @@ impl FileSystemSandboxPolicy {
.map(|entry| &entry.entry.path)
.collect();
let protected_metadata_names =
protected_metadata_names_for_writable_root(&matching, &root, &raw_writable_roots);
protected_metadata_names_for_writable_root(matching, &root, &raw_writable_roots);
let protect_missing_dot_codex = effective_cwd.as_ref() == Some(&root);
let mut read_only_subpaths: Vec<AbsolutePathBuf> =
default_read_only_subpaths_for_writable_root(&root, protect_missing_dot_codex)
.into_iter()
.filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path))
.filter(|path| !has_explicit_resolved_path_entry(resolved_entries, path))
.collect();
// Narrower explicit non-write entries carve out broader writable roots.
// More specific write entries still remain writable because they appear
@@ -1761,37 +1740,7 @@ impl FileSystemSandboxPolicy {
/// Returns explicit unreadable roots resolved against the provided cwd.
pub fn get_unreadable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
return Vec::new();
}
let root = AbsolutePathBuf::from_absolute_path(cwd)
.ok()
.map(|cwd| absolute_root_path_for_cwd(&cwd));
let local_context = LocalPolicyContext::new(cwd);
let matching = local_context
.as_ref()
.and_then(|context| self.prepare_local_matching(&context.as_context()).ok());
dedup_absolute_paths(
self.resolved_entries_with_cwd(cwd)
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
.filter(|entry| {
!matching.as_ref().is_some_and(|matching| {
matching
.resolve_access(&PathUri::from_abs_path(&entry.path))
.can_read()
})
})
// Restricted policies already deny reads outside explicit allow roots,
// so materializing the filesystem root here would erase narrower
// readable carveouts when downstream sandboxes apply deny masks last.
.filter(|entry| root.as_ref() != Some(&entry.path))
.map(|entry| entry.path.clone())
.collect(),
/*normalize_effective_paths*/ true,
)
self.get_unreadable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::Native)
}
/// Includes literal denies alongside their resolved targets so Linux can reject a denial
@@ -1800,16 +1749,62 @@ impl FileSystemSandboxPolicy {
&self,
cwd: &Path,
) -> Vec<AbsolutePathBuf> {
let mut roots = self.get_unreadable_roots_with_cwd(cwd);
if matches!(self.kind, FileSystemSandboxKind::Restricted) {
for entry in self.resolved_entries_with_cwd(cwd) {
if entry.access == FileSystemAccessMode::Deny
&& entry.path.as_path().parent().is_some()
&& !self.can_read_local_path_with_cwd(entry.path.as_path(), cwd)
&& !roots.contains(&entry.path)
{
roots.push(entry.path);
}
self.get_unreadable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::LinuxMounts)
}
fn get_unreadable_roots_with_cwd_impl(
&self,
cwd: &Path,
projection: FileSystemPolicyProjection,
) -> Vec<AbsolutePathBuf> {
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
return Vec::new();
}
let local_context = LocalPolicyContext::new(cwd);
let matching = local_context
.as_ref()
.and_then(|context| self.prepare_local_matching(&context.as_context()).ok());
Self::unreadable_roots(
cwd,
&self.resolved_entries_with_cwd(cwd),
matching.as_ref(),
projection,
)
}
fn unreadable_roots(
cwd: &Path,
entries: &[ResolvedFileSystemEntry],
matching: Option<&LocalFileSystemPolicyMatcher<'_>>,
projection: FileSystemPolicyProjection,
) -> Vec<AbsolutePathBuf> {
let root = AbsolutePathBuf::from_absolute_path(cwd)
.ok()
.map(|cwd| absolute_root_path_for_cwd(&cwd));
let denied: Vec<_> = entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
.filter(|entry| {
!matching.is_some_and(|matching| {
matching
.resolve_access(&PathUri::from_abs_path(&entry.path))
.can_read()
})
})
// A root denial is the default, not a mask that should hide narrower readable grants.
.filter(|entry| root.as_ref() != Some(&entry.path))
.map(|entry| entry.path.clone())
.collect();
if projection != FileSystemPolicyProjection::LinuxMounts {
return dedup_absolute_paths(denied, /*normalize_effective_paths*/ true);
}
// A mount must protect both the configured name and the target it resolves to now.
let mut roots =
dedup_absolute_paths(denied.clone(), /*normalize_effective_paths*/ true);
for path in denied {
if path.as_path().parent().is_some() && !roots.contains(&path) {
roots.push(path);
}
}
roots
@@ -2028,16 +2023,16 @@ impl FileSystemSandboxPolicy {
.collect()
}
fn semantic_signature(&self, cwd: &Path) -> FileSystemSemanticSignature {
FileSystemSemanticSignature {
has_full_disk_read_access: self.has_full_disk_read_access(),
has_full_disk_write_access: self.has_full_disk_write_access(),
include_platform_defaults: self.include_platform_defaults(),
readable_roots: sorted_absolute_paths(self.get_readable_roots_with_cwd(cwd)),
writable_roots: sorted_writable_roots(self.get_writable_roots_with_cwd(cwd)),
unreadable_roots: sorted_absolute_paths(self.get_unreadable_roots_with_cwd(cwd)),
unreadable_globs: self.get_unreadable_globs_with_cwd(cwd),
}
fn semantic_signature(&self, cwd: &Path) -> io::Result<ResolvedFileSystemPermissions> {
let resolved = self.resolve_for_enforcement(cwd, FileSystemPolicyProjection::Native)?;
Ok(ResolvedFileSystemPermissions {
readable_roots: sorted_absolute_paths(resolved.readable_roots),
writable_roots: sorted_writable_roots(resolved.writable_roots),
unreadable_roots: sorted_absolute_paths(resolved.unreadable_roots),
// Compatibility compares access rules, independently of the backend's scan limit.
glob_scan_max_depth: None,
..resolved
})
}
}
@@ -0,0 +1,91 @@
//! Projects filesystem permissions for each sandbox, preserving mutable paths where the backend binds them.
use super::FileSystemSandboxPolicy;
use super::LocalPolicyContext;
use super::normalize_effective_absolute_path;
use crate::protocol::WritableRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::io;
use std::path::Path;
/// How a sandbox binds filesystem roots after access and precedence are resolved.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileSystemPolicyProjection {
/// Resolve roots and carveouts to their effective native paths.
Native,
/// Normalize trusted system aliases, leaving mutable components for the sandbox to bind.
PreserveMutableRoots,
/// Inherit root metadata writes from the root mount and retain literal paths for read denials.
LinuxMounts,
}
impl FileSystemPolicyProjection {
pub(super) fn resolve_writable_path(self, path: AbsolutePathBuf) -> AbsolutePathBuf {
match self {
Self::Native | Self::LinuxMounts => normalize_effective_absolute_path(path),
Self::PreserveMutableRoots => path.normalize_system_aliases().unwrap_or(path),
}
}
}
/// Filesystem permissions resolved on the host that will enforce them.
///
/// Roots retain policy order. Deny globs remain patterns for the sandbox to enforce or expand.
/// Resolving these paths does not pin their targets; the sandbox still binds them at launch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedFileSystemPermissions {
pub has_full_disk_read_access: bool,
pub has_full_disk_write_access: bool,
pub include_platform_defaults: bool,
pub readable_roots: Vec<AbsolutePathBuf>,
pub writable_roots: Vec<WritableRoot>,
pub unreadable_roots: Vec<AbsolutePathBuf>,
pub unreadable_globs: Vec<String>,
pub glob_scan_max_depth: Option<usize>,
}
impl FileSystemSandboxPolicy {
/// Resolve the roots for one enforcement operation after binding executor-owned paths.
pub fn resolve_for_enforcement(
&self,
cwd: &Path,
projection: FileSystemPolicyProjection,
) -> io::Result<ResolvedFileSystemPermissions> {
let mut resolved = ResolvedFileSystemPermissions {
has_full_disk_read_access: self.has_full_disk_read_access(),
has_full_disk_write_access: self.has_full_disk_write_access(),
include_platform_defaults: self.include_platform_defaults(),
readable_roots: Vec::new(),
writable_roots: Vec::new(),
unreadable_roots: Vec::new(),
unreadable_globs: self.get_unreadable_globs_with_cwd(cwd),
glob_scan_max_depth: self.glob_scan_max_depth,
};
if resolved.has_full_disk_read_access && resolved.has_full_disk_write_access {
return Ok(resolved);
}
let entries = self.resolved_entries_with_cwd(cwd);
if entries.is_empty() {
return Ok(resolved);
}
let context = LocalPolicyContext::new(cwd);
let matching = context
.as_ref()
.and_then(|context| self.prepare_local_matching(&context.as_context()).ok());
if let Some(matching) = &matching {
if !resolved.has_full_disk_read_access {
resolved.readable_roots = Self::readable_roots(&entries, matching);
}
if !resolved.has_full_disk_write_access
&& entries.iter().any(|entry| entry.access.can_write())
{
resolved.writable_roots = Self::writable_roots(cwd, &entries, matching, projection);
}
}
// If the matcher couldn't be prepared, withhold grants but keep explicit denials.
resolved.unreadable_roots =
Self::unreadable_roots(cwd, &entries, matching.as_ref(), projection);
Ok(resolved)
}
}