mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add safe permission profile intersection (#39242)
## What changed - Add `intersect_effective_permission_profiles` to derive the permissions shared by an authority profile and a requested profile. - Preserve the more restrictive filesystem access, read denials, protected metadata paths, temporary-directory access, and network policy. - Canonicalize concrete paths so symlinks cannot expand authority, and fail closed for policy shapes that cannot be intersected safely. ## Testing Add coverage for workspace intersections, nested carveouts, deny patterns, unsupported policies, temporary-directory restrictions, symlink escapes, and macOS path aliases. GitOrigin-RevId: a35bdc47e41afad13630f92c032514b300c14a0d
This commit is contained in:
@@ -6,6 +6,8 @@ mod session_id;
|
||||
mod thread_id;
|
||||
mod tool_name;
|
||||
pub use agent_path::AgentPath;
|
||||
pub use permission_profile_intersection::PermissionIntersectionError;
|
||||
pub use permission_profile_intersection::intersect_effective_permission_profiles;
|
||||
pub use response_item_id::ResponseItemId;
|
||||
pub use session_id::SessionId;
|
||||
pub use thread_id::RolloutId;
|
||||
@@ -31,6 +33,7 @@ pub mod network_policy;
|
||||
pub mod num_format;
|
||||
pub mod openai_models;
|
||||
pub mod parse_command;
|
||||
mod permission_profile_intersection;
|
||||
mod permission_profile_snapshot;
|
||||
pub mod permissions;
|
||||
pub mod plan_tool;
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
use std::path::Path;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::models::PermissionProfile;
|
||||
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::permissions::PROTECTED_METADATA_PATH_NAMES;
|
||||
use crate::permissions::ReadDenyMatcher;
|
||||
use crate::permissions::default_read_only_subpaths_for_writable_root;
|
||||
use crate::permissions::project_roots_glob_pattern;
|
||||
|
||||
/// A policy cannot be intersected without weakening either input.
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub enum PermissionIntersectionError {
|
||||
#[error("externally enforced filesystem permissions cannot be intersected safely")]
|
||||
ExternalSandbox,
|
||||
#[error("platform-default filesystem permissions cannot be intersected safely")]
|
||||
PlatformDefaults,
|
||||
#[error("unsupported permission path: {0}")]
|
||||
UnsupportedPath(String),
|
||||
}
|
||||
|
||||
/// Intersects already-effective filesystem permissions and network access.
|
||||
///
|
||||
/// Both profiles must already be materialized for the same local executor and
|
||||
/// cwd. Concrete grant paths are canonicalized before comparison and in the
|
||||
/// result, so symlinks cannot acquire authority beyond either input.
|
||||
/// Unsupported policy shapes fail closed.
|
||||
pub fn intersect_effective_permission_profiles(
|
||||
authority: &PermissionProfile,
|
||||
requested: &PermissionProfile,
|
||||
cwd: &Path,
|
||||
) -> Result<PermissionProfile, PermissionIntersectionError> {
|
||||
if matches!(authority, PermissionProfile::External { .. })
|
||||
|| matches!(requested, PermissionProfile::External { .. })
|
||||
{
|
||||
return Err(PermissionIntersectionError::ExternalSandbox);
|
||||
}
|
||||
AbsolutePathBuf::from_absolute_path_checked(cwd)
|
||||
.map_err(|error| PermissionIntersectionError::UnsupportedPath(error.to_string()))?;
|
||||
for profile in [authority, requested] {
|
||||
for entry in profile.file_system_sandbox_policy().entries {
|
||||
match entry.path {
|
||||
FileSystemPath::Path { path } => {
|
||||
path.to_abs_path().map_err(|error| {
|
||||
PermissionIntersectionError::UnsupportedPath(error.to_string())
|
||||
})?;
|
||||
}
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::ProjectRoots { .. },
|
||||
} => {
|
||||
return Err(PermissionIntersectionError::UnsupportedPath(
|
||||
"workspace permissions must already be materialized".to_string(),
|
||||
));
|
||||
}
|
||||
FileSystemPath::GlobPattern { pattern }
|
||||
if pattern.starts_with(&project_roots_glob_pattern(Path::new(""))) =>
|
||||
{
|
||||
return Err(PermissionIntersectionError::UnsupportedPath(pattern));
|
||||
}
|
||||
FileSystemPath::GlobPattern { .. } | FileSystemPath::Special { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut authority_policy = authority.file_system_sandbox_policy();
|
||||
let mut requested_policy = requested.file_system_sandbox_policy();
|
||||
let root_path = FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
};
|
||||
let network = if authority.network_sandbox_policy().is_enabled()
|
||||
&& requested.network_sandbox_policy().is_enabled()
|
||||
{
|
||||
NetworkSandboxPolicy::Enabled
|
||||
} else {
|
||||
NetworkSandboxPolicy::Restricted
|
||||
};
|
||||
if matches!(authority, PermissionProfile::Disabled)
|
||||
&& matches!(requested, PermissionProfile::Disabled)
|
||||
{
|
||||
return Ok(PermissionProfile::Disabled);
|
||||
}
|
||||
if authority_policy == requested_policy {
|
||||
return Ok(PermissionProfile::from_runtime_permissions(
|
||||
&authority_policy,
|
||||
network,
|
||||
));
|
||||
}
|
||||
if matches!(authority_policy.kind, FileSystemSandboxKind::Unrestricted) {
|
||||
return Ok(PermissionProfile::from_runtime_permissions(
|
||||
&requested_policy,
|
||||
network,
|
||||
));
|
||||
}
|
||||
if matches!(requested_policy.kind, FileSystemSandboxKind::Unrestricted) {
|
||||
return Ok(PermissionProfile::from_runtime_permissions(
|
||||
&authority_policy,
|
||||
network,
|
||||
));
|
||||
}
|
||||
|
||||
normalize_policy(&mut authority_policy)?;
|
||||
normalize_policy(&mut requested_policy)?;
|
||||
let tmpdir = FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Tmpdir,
|
||||
};
|
||||
let temp_entries = [&authority_policy, &requested_policy].map(|policy| {
|
||||
policy
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.path == tmpdir)
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let common_temp = match (temp_entries[0].as_slice(), temp_entries[1].as_slice()) {
|
||||
([left], [right])
|
||||
if [left, right]
|
||||
.into_iter()
|
||||
.all(|entry| entry.access.can_write() && !entry.skips_missing_path()) =>
|
||||
{
|
||||
Some(FileSystemSandboxEntry::new(tmpdir.clone(), left.access))
|
||||
}
|
||||
([], []) => None,
|
||||
([entry], []) | ([], [entry])
|
||||
if entry.access.can_write() && !entry.skips_missing_path() =>
|
||||
{
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
return Err(PermissionIntersectionError::UnsupportedPath(
|
||||
":tmpdir restrictions require executor-local bindings".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
for policy in [&mut authority_policy, &mut requested_policy] {
|
||||
policy.entries.retain(|entry| entry.path != tmpdir);
|
||||
}
|
||||
|
||||
let authority_denies = ReadDenyMatcher::try_new(&authority_policy, cwd)
|
||||
.map_err(PermissionIntersectionError::UnsupportedPath)?;
|
||||
let requested_denies = ReadDenyMatcher::try_new(&requested_policy, cwd)
|
||||
.map_err(PermissionIntersectionError::UnsupportedPath)?;
|
||||
let sources = [
|
||||
(&authority_policy, authority_denies.as_ref()),
|
||||
(&requested_policy, requested_denies.as_ref()),
|
||||
];
|
||||
let mut paths = Vec::new();
|
||||
let mut denies = Vec::new();
|
||||
let mut glob_depth: Option<Option<usize>> = None;
|
||||
for (policy, _) in sources {
|
||||
let writable_roots = policy.get_writable_roots_with_cwd(cwd);
|
||||
for entry in &policy.entries {
|
||||
if entry.access == FileSystemAccessMode::Deny && !denies.contains(entry) {
|
||||
denies.push(entry.clone());
|
||||
}
|
||||
if matches!(entry.path, FileSystemPath::GlobPattern { .. }) {
|
||||
glob_depth = Some(match glob_depth {
|
||||
None => policy.glob_scan_max_depth,
|
||||
Some(Some(previous)) => {
|
||||
policy.glob_scan_max_depth.map(|depth| previous.max(depth))
|
||||
}
|
||||
Some(None) => None,
|
||||
});
|
||||
}
|
||||
let Some(path) = entry_path(entry, cwd)? else {
|
||||
continue;
|
||||
};
|
||||
if entry.skips_missing_path()
|
||||
&& (entry.access != FileSystemAccessMode::Read
|
||||
|| !writable_roots.iter().any(|root| {
|
||||
root.read_only_subpaths.contains(&path)
|
||||
|| default_read_only_subpaths_for_writable_root(
|
||||
&root.root, /*protect_missing_dot_codex*/ false,
|
||||
)
|
||||
.contains(&path)
|
||||
|| path.parent().as_ref() == Some(&root.root)
|
||||
&& PROTECTED_METADATA_PATH_NAMES.iter().any(|name| {
|
||||
path.as_path().file_name() == Some(std::ffi::OsStr::new(name))
|
||||
})
|
||||
}))
|
||||
{
|
||||
return Err(PermissionIntersectionError::UnsupportedPath(
|
||||
"non-metadata optional permissions".to_string(),
|
||||
));
|
||||
}
|
||||
paths.push(path);
|
||||
}
|
||||
paths.extend(policy.get_readable_roots_with_cwd(cwd));
|
||||
for root in writable_roots {
|
||||
paths.push(root.root.clone());
|
||||
paths.extend(
|
||||
PROTECTED_METADATA_PATH_NAMES
|
||||
.iter()
|
||||
.map(|name| root.root.join(name)),
|
||||
);
|
||||
paths.extend(root.read_only_subpaths);
|
||||
}
|
||||
}
|
||||
paths.sort_by(|left, right| {
|
||||
left.as_path()
|
||||
.components()
|
||||
.count()
|
||||
.cmp(&right.as_path().components().count())
|
||||
.then_with(|| left.as_path().cmp(right.as_path()))
|
||||
});
|
||||
paths.dedup();
|
||||
denies.sort_by_cached_key(|entry| format!("{:?}", entry.path));
|
||||
let mut intersection = FileSystemSandboxPolicy::restricted(denies);
|
||||
intersection.glob_scan_max_depth = glob_depth.flatten();
|
||||
let intersection_denies = ReadDenyMatcher::new(&intersection, cwd);
|
||||
|
||||
for path in paths {
|
||||
let [left, right] =
|
||||
sources.map(|(policy, denies)| effective_access(policy, denies, path.as_path(), cwd));
|
||||
let access = intersect_access(left, right);
|
||||
let current = effective_access(
|
||||
&intersection,
|
||||
intersection_denies.as_ref(),
|
||||
path.as_path(),
|
||||
cwd,
|
||||
);
|
||||
let optional_metadata = sources.into_iter().any(|(policy, _)| {
|
||||
policy.entries.iter().any(|entry| {
|
||||
entry.skips_missing_path()
|
||||
&& entry_path(entry, cwd).ok().flatten().as_ref() == Some(&path)
|
||||
})
|
||||
});
|
||||
let required_restriction = sources.into_iter().any(|(policy, _)| {
|
||||
policy.entries.iter().any(|entry| {
|
||||
!entry.skips_missing_path()
|
||||
&& !entry.access.can_write()
|
||||
&& entry_path(entry, cwd).ok().flatten().as_ref() == Some(&path)
|
||||
})
|
||||
});
|
||||
if access == current && !optional_metadata && !required_restriction {
|
||||
continue;
|
||||
}
|
||||
let path = if path.as_path().parent().is_none()
|
||||
&& cwd.starts_with(path.as_path())
|
||||
&& sources
|
||||
.into_iter()
|
||||
.all(|(policy, _)| policy.entries.iter().any(|entry| entry.path == root_path))
|
||||
{
|
||||
root_path.clone()
|
||||
} else {
|
||||
path.into()
|
||||
};
|
||||
let entry =
|
||||
if optional_metadata && !required_restriction && access == FileSystemAccessMode::Read {
|
||||
FileSystemSandboxEntry::skip_missing_path(path, access)
|
||||
} else {
|
||||
FileSystemSandboxEntry::new(path, access)
|
||||
};
|
||||
if !intersection.entries.contains(&entry) {
|
||||
intersection.entries.push(entry);
|
||||
}
|
||||
}
|
||||
if let Some(temp) = common_temp {
|
||||
intersection.entries.push(temp);
|
||||
}
|
||||
Ok(PermissionProfile::from_runtime_permissions(
|
||||
&intersection,
|
||||
network,
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_policy(
|
||||
policy: &mut FileSystemSandboxPolicy,
|
||||
) -> Result<(), PermissionIntersectionError> {
|
||||
if policy.include_platform_defaults() {
|
||||
return Err(PermissionIntersectionError::PlatformDefaults);
|
||||
}
|
||||
for entry in &mut policy.entries {
|
||||
match &entry.path {
|
||||
FileSystemPath::Path { path } => {
|
||||
let path = path.to_abs_path().map_err(|error| {
|
||||
PermissionIntersectionError::UnsupportedPath(error.to_string())
|
||||
})?;
|
||||
let physical = if entry.access.can_write()
|
||||
|| entry.access == FileSystemAccessMode::Read && !entry.skips_missing_path()
|
||||
{
|
||||
path.canonicalize().map_err(|error| {
|
||||
PermissionIntersectionError::UnsupportedPath(format!(
|
||||
"{}: {error}",
|
||||
path.as_path().display()
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
physical_path(&path)?
|
||||
};
|
||||
let logical = path.ancestors().find_map(|ancestor| {
|
||||
std::fs::symlink_metadata(ancestor.as_path()).ok()?;
|
||||
let preserved = canonicalize_preserving_symlinks(ancestor.as_path()).ok()?;
|
||||
let suffix = path.as_path().strip_prefix(ancestor.as_path()).ok()?;
|
||||
Some(
|
||||
AbsolutePathBuf::from_absolute_path_checked(preserved)
|
||||
.ok()?
|
||||
.join(suffix),
|
||||
)
|
||||
});
|
||||
if !entry.access.can_write() && logical.as_ref() != Some(&physical) {
|
||||
return Err(PermissionIntersectionError::UnsupportedPath(format!(
|
||||
"symlinked restriction: {}",
|
||||
path.as_path().display()
|
||||
)));
|
||||
}
|
||||
entry.path = physical.into();
|
||||
}
|
||||
FileSystemPath::GlobPattern { pattern }
|
||||
if entry.access != FileSystemAccessMode::Deny || entry.skips_missing_path() =>
|
||||
{
|
||||
return Err(PermissionIntersectionError::UnsupportedPath(format!(
|
||||
"glob permissions: {pattern}"
|
||||
)));
|
||||
}
|
||||
FileSystemPath::GlobPattern { .. } => {}
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root | FileSystemSpecialPath::Tmpdir,
|
||||
} => {}
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::SlashTmp,
|
||||
} => {
|
||||
if cfg!(unix) {
|
||||
entry.path = physical_path(
|
||||
&AbsolutePathBuf::from_absolute_path_checked("/tmp").map_err(|error| {
|
||||
PermissionIntersectionError::UnsupportedPath(error.to_string())
|
||||
})?,
|
||||
)?
|
||||
.into();
|
||||
}
|
||||
}
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Minimal,
|
||||
} => return Err(PermissionIntersectionError::PlatformDefaults),
|
||||
FileSystemPath::Special { value } => {
|
||||
return Err(PermissionIntersectionError::UnsupportedPath(format!(
|
||||
"{value:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn entry_path(
|
||||
entry: &FileSystemSandboxEntry,
|
||||
cwd: &Path,
|
||||
) -> Result<Option<AbsolutePathBuf>, PermissionIntersectionError> {
|
||||
match &entry.path {
|
||||
FileSystemPath::Path { path } => path
|
||||
.to_abs_path()
|
||||
.map(Some)
|
||||
.map_err(|error| PermissionIntersectionError::UnsupportedPath(error.to_string())),
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
} => cwd
|
||||
.ancestors()
|
||||
.last()
|
||||
.and_then(|root| AbsolutePathBuf::from_absolute_path_checked(root).ok())
|
||||
.map(Some)
|
||||
.ok_or_else(|| PermissionIntersectionError::UnsupportedPath(cwd.display().to_string())),
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Tmpdir | FileSystemSpecialPath::SlashTmp,
|
||||
} => Ok(None),
|
||||
FileSystemPath::Special { value } => Err(PermissionIntersectionError::UnsupportedPath(
|
||||
format!("{value:?}"),
|
||||
)),
|
||||
FileSystemPath::GlobPattern { .. } => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn physical_path(path: &AbsolutePathBuf) -> Result<AbsolutePathBuf, PermissionIntersectionError> {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Ok(physical) = ancestor.canonicalize()
|
||||
&& let Ok(suffix) = path.as_path().strip_prefix(ancestor.as_path())
|
||||
{
|
||||
return Ok(physical.join(suffix));
|
||||
}
|
||||
}
|
||||
Err(PermissionIntersectionError::UnsupportedPath(
|
||||
path.as_path().display().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn effective_access(
|
||||
policy: &FileSystemSandboxPolicy,
|
||||
denies: Option<&ReadDenyMatcher>,
|
||||
path: &Path,
|
||||
cwd: &Path,
|
||||
) -> FileSystemAccessMode {
|
||||
if denies.is_some_and(|matcher| matcher.is_read_denied(path)) {
|
||||
FileSystemAccessMode::Deny
|
||||
} else if policy.can_write_path_with_cwd(path, cwd) {
|
||||
FileSystemAccessMode::Write
|
||||
} else if policy.can_read_path_with_cwd(path, cwd) {
|
||||
FileSystemAccessMode::Read
|
||||
} else {
|
||||
FileSystemAccessMode::Deny
|
||||
}
|
||||
}
|
||||
|
||||
fn intersect_access(
|
||||
left: FileSystemAccessMode,
|
||||
right: FileSystemAccessMode,
|
||||
) -> FileSystemAccessMode {
|
||||
match (left, right) {
|
||||
(FileSystemAccessMode::Deny, _) | (_, FileSystemAccessMode::Deny) => {
|
||||
FileSystemAccessMode::Deny
|
||||
}
|
||||
(FileSystemAccessMode::Write, FileSystemAccessMode::Write) => FileSystemAccessMode::Write,
|
||||
(FileSystemAccessMode::Read | FileSystemAccessMode::Write, _) => FileSystemAccessMode::Read,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "permission_profile_intersection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,369 @@
|
||||
use std::path::Path;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::PermissionIntersectionError;
|
||||
use super::intersect_effective_permission_profiles;
|
||||
use crate::models::PermissionProfile;
|
||||
use crate::permissions::FileSystemAccessMode;
|
||||
use crate::permissions::FileSystemAccessMode::Deny;
|
||||
use crate::permissions::FileSystemAccessMode::Read;
|
||||
use crate::permissions::FileSystemAccessMode::Write;
|
||||
use crate::permissions::FileSystemPath;
|
||||
use crate::permissions::FileSystemSandboxEntry;
|
||||
use crate::permissions::FileSystemSandboxPolicy;
|
||||
use crate::permissions::FileSystemSpecialPath;
|
||||
use crate::permissions::FileSystemSpecialPath::Minimal;
|
||||
use crate::permissions::FileSystemSpecialPath::Tmpdir;
|
||||
use crate::permissions::NetworkSandboxPolicy;
|
||||
use crate::permissions::NetworkSandboxPolicy::Enabled;
|
||||
use crate::permissions::NetworkSandboxPolicy::Restricted;
|
||||
use crate::permissions::ReadDenyMatcher;
|
||||
use crate::permissions::project_roots_glob_pattern;
|
||||
|
||||
fn absolute(path: &Path) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::from_absolute_path(path).expect("absolute test path")
|
||||
}
|
||||
|
||||
fn canonical(temp: &TempDir) -> AbsolutePathBuf {
|
||||
absolute(temp.path())
|
||||
.canonicalize()
|
||||
.expect("canonical root")
|
||||
}
|
||||
|
||||
fn managed(entries: Vec<FileSystemSandboxEntry>) -> PermissionProfile {
|
||||
PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(entries),
|
||||
Restricted,
|
||||
)
|
||||
}
|
||||
|
||||
fn entry(path: &Path, access: FileSystemAccessMode) -> FileSystemSandboxEntry {
|
||||
FileSystemSandboxEntry::new(absolute(path).into(), access)
|
||||
}
|
||||
|
||||
fn special(value: FileSystemSpecialPath, access: FileSystemAccessMode) -> FileSystemSandboxEntry {
|
||||
FileSystemSandboxEntry::new(FileSystemPath::Special { value }, access)
|
||||
}
|
||||
|
||||
fn skipped(path: FileSystemPath, access: FileSystemAccessMode) -> FileSystemSandboxEntry {
|
||||
FileSystemSandboxEntry::skip_missing_path(path, access)
|
||||
}
|
||||
|
||||
fn deny_glob(pattern: &str) -> FileSystemSandboxEntry {
|
||||
FileSystemSandboxEntry::new(
|
||||
FileSystemPath::GlobPattern {
|
||||
pattern: pattern.into(),
|
||||
},
|
||||
Deny,
|
||||
)
|
||||
}
|
||||
|
||||
fn root_read() -> FileSystemSandboxEntry {
|
||||
FileSystemSandboxPolicy::read_only().entries[0].clone()
|
||||
}
|
||||
|
||||
fn rooted(
|
||||
root: &AbsolutePathBuf,
|
||||
access: FileSystemAccessMode,
|
||||
entries: impl IntoIterator<Item = FileSystemSandboxEntry>,
|
||||
) -> PermissionProfile {
|
||||
let mut all_entries = vec![root_read(), entry(root.as_path(), access)];
|
||||
all_entries.extend(entries);
|
||||
managed(all_entries)
|
||||
}
|
||||
|
||||
fn workspace(root: &AbsolutePathBuf, network: NetworkSandboxPolicy) -> PermissionProfile {
|
||||
PermissionProfile::workspace_write_with(
|
||||
&[],
|
||||
network,
|
||||
/*exclude_tmpdir_env_var*/ false,
|
||||
/*exclude_slash_tmp*/ true,
|
||||
)
|
||||
.materialize_project_roots_with_workspace_roots(std::slice::from_ref(root))
|
||||
}
|
||||
|
||||
fn intersection(
|
||||
left: &PermissionProfile,
|
||||
right: &PermissionProfile,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> PermissionProfile {
|
||||
intersect_effective_permission_profiles(left, right, cwd.as_path()).expect("safe intersection")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_workspace_intersection_preserves_network_metadata_and_temp() {
|
||||
let temp = TempDir::new().expect("workspace");
|
||||
let root = canonical(&temp);
|
||||
let project = root.join("project");
|
||||
std::fs::create_dir(project.as_path()).expect("project directory");
|
||||
let gitdir = root.join("external-gitdir");
|
||||
std::fs::create_dir(gitdir.as_path()).expect("worktree gitdir");
|
||||
std::fs::write(
|
||||
project.join(".git").as_path(),
|
||||
format!("gitdir: {}", gitdir.as_path().display()),
|
||||
)
|
||||
.expect("worktree git pointer");
|
||||
let authority = workspace(&root, Enabled);
|
||||
let mut requested_policy = workspace(&project, Restricted).file_system_sandbox_policy();
|
||||
requested_policy
|
||||
.entries
|
||||
.push(skipped(gitdir.clone().into(), Read));
|
||||
let requested = PermissionProfile::from_runtime_permissions(&requested_policy, Restricted);
|
||||
let result = intersection(&authority, &requested, &project);
|
||||
let policy = result.file_system_sandbox_policy();
|
||||
|
||||
assert_eq!(
|
||||
[&root, &project]
|
||||
.map(|path| policy.resolve_access_with_cwd(path.as_path(), root.as_path())),
|
||||
[Read, Write]
|
||||
);
|
||||
assert_eq!(result.network_sandbox_policy(), Restricted);
|
||||
assert!(policy.entries.contains(&special(Tmpdir, Write)));
|
||||
for name in [".git", ".agents", ".codex"] {
|
||||
let protected = project.join(name);
|
||||
assert!(!policy.can_write_path_with_cwd(protected.as_path(), root.as_path()));
|
||||
assert!(policy.entries.contains(&skipped(protected.into(), Read)));
|
||||
}
|
||||
assert!(policy.entries.contains(&skipped(gitdir.into(), Read)));
|
||||
let strict = rooted(
|
||||
&project,
|
||||
Write,
|
||||
[entry(project.join(".git").as_path(), Read)],
|
||||
);
|
||||
let strict = intersection(&requested, &strict, &project).file_system_sandbox_policy();
|
||||
let git_protection = entry(project.join(".git").as_path(), Read);
|
||||
assert!(strict.entries.contains(&git_protection));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_denies_nested_read_carveouts_and_reopened_writes_are_preserved() {
|
||||
let temp = TempDir::new().expect("workspace");
|
||||
let root = canonical(&temp);
|
||||
let shared = root.join("shared");
|
||||
let editable = shared.join("editable");
|
||||
let left_secret = root.join("left-secret.env");
|
||||
let right_secret = root.join("right-secret.token");
|
||||
for directory in [&editable, &left_secret, &right_secret] {
|
||||
std::fs::create_dir_all(directory.as_path()).expect("policy directory");
|
||||
}
|
||||
let left = rooted(
|
||||
&root,
|
||||
Write,
|
||||
[
|
||||
entry(shared.as_path(), Read),
|
||||
entry(editable.as_path(), Write),
|
||||
entry(left_secret.as_path(), Deny),
|
||||
deny_glob("**/*.env"),
|
||||
],
|
||||
);
|
||||
let mut left_policy = left.file_system_sandbox_policy();
|
||||
left_policy.glob_scan_max_depth = Some(2);
|
||||
let left = PermissionProfile::from_runtime_permissions(&left_policy, Restricted);
|
||||
let right = rooted(
|
||||
&root,
|
||||
Write,
|
||||
[
|
||||
entry(shared.as_path(), Write),
|
||||
entry(editable.as_path(), Write),
|
||||
entry(right_secret.as_path(), Deny),
|
||||
deny_glob("**/*.token"),
|
||||
],
|
||||
);
|
||||
let mut right_policy = right.file_system_sandbox_policy();
|
||||
right_policy.glob_scan_max_depth = Some(4);
|
||||
let right = PermissionProfile::from_runtime_permissions(&right_policy, Restricted);
|
||||
let result = intersection(&left, &right, &root);
|
||||
let policy = result.file_system_sandbox_policy();
|
||||
let denies = ReadDenyMatcher::new(&policy, root.as_path()).expect("merged denies");
|
||||
assert_eq!(
|
||||
[&root, &shared, &editable]
|
||||
.map(|path| policy.resolve_access_with_cwd(path.as_path(), root.as_path())),
|
||||
[Write, Read, Write]
|
||||
);
|
||||
assert_eq!(
|
||||
[
|
||||
&left_secret,
|
||||
&right_secret,
|
||||
&root.join("credentials.env"),
|
||||
&root.join("credentials.token"),
|
||||
]
|
||||
.map(|path| denies.is_read_denied(path.as_path())),
|
||||
[true, true, true, true]
|
||||
);
|
||||
assert_eq!(policy.glob_scan_max_depth, Some(4));
|
||||
assert!(policy.entries.contains(&entry(left_secret.as_path(), Deny)));
|
||||
assert_eq!(result, intersection(&right, &left, &root));
|
||||
let unbounded = rooted(&root, Write, [deny_glob("**/*.key")]);
|
||||
assert_eq!(
|
||||
intersection(&left, &unbounded, &root)
|
||||
.file_system_sandbox_policy()
|
||||
.glob_scan_max_depth,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_directory_restrictions_fail_closed() {
|
||||
let temp = TempDir::new().expect("workspace");
|
||||
let root = canonical(&temp);
|
||||
let writable = workspace(&root, Restricted);
|
||||
for access in [Read, Deny] {
|
||||
let restricted = rooted(&root, Write, [special(Tmpdir, access)]);
|
||||
assert!(matches!(
|
||||
intersect_effective_permission_profiles(&restricted, &writable, root.as_path()),
|
||||
Err(PermissionIntersectionError::UnsupportedPath(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_unrestricted_and_disabled_profiles_keep_existing_enforcement() {
|
||||
let temp = TempDir::new().expect("workspace");
|
||||
let root = absolute(temp.path());
|
||||
let readonly = PermissionProfile::read_only();
|
||||
let disabled = PermissionProfile::Disabled;
|
||||
assert_eq!(intersection(&disabled, &readonly, &root), readonly);
|
||||
assert_eq!(intersection(&readonly, &disabled, &root), readonly);
|
||||
assert_eq!(intersection(&disabled, &disabled, &root), disabled);
|
||||
let unrestricted = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
Enabled,
|
||||
);
|
||||
assert_eq!(intersection(&unrestricted, &readonly, &root), readonly);
|
||||
|
||||
let minimal = managed(vec![special(Minimal, Read)]);
|
||||
assert_eq!(intersection(&minimal, &minimal, &root), minimal);
|
||||
let filesystem_root = root.as_path().ancestors().last().unwrap();
|
||||
let explicit = managed(vec![entry(filesystem_root, Read)]);
|
||||
assert!(
|
||||
!intersection(&explicit, &explicit, &root)
|
||||
.file_system_sandbox_policy()
|
||||
.has_full_disk_read_access()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_unresolved_and_optional_policy_shapes_fail_closed() {
|
||||
let temp = TempDir::new().expect("workspace");
|
||||
let root = canonical(&temp);
|
||||
let readonly = PermissionProfile::read_only();
|
||||
let external = PermissionProfile::External { network: Enabled };
|
||||
for (left, right) in [(&external, &readonly), (&readonly, &external)] {
|
||||
assert_eq!(
|
||||
intersect_effective_permission_profiles(left, right, root.as_path()),
|
||||
Err(PermissionIntersectionError::ExternalSandbox)
|
||||
);
|
||||
}
|
||||
let unmaterialized = PermissionProfile::workspace_write();
|
||||
assert!(matches!(
|
||||
intersect_effective_permission_profiles(&unmaterialized, &readonly, root.as_path()),
|
||||
Err(PermissionIntersectionError::UnsupportedPath(_))
|
||||
));
|
||||
let minimal = managed(vec![special(Minimal, Read)]);
|
||||
assert_eq!(
|
||||
intersect_effective_permission_profiles(&minimal, &readonly, root.as_path()),
|
||||
Err(PermissionIntersectionError::PlatformDefaults)
|
||||
);
|
||||
|
||||
let optional_write = rooted(&root, Write, [skipped(root.clone().into(), Write)]);
|
||||
let invalid_glob = rooted(&root, Write, [deny_glob("**/[z-a]")]);
|
||||
let optional_glob = rooted(&root, Write, [skipped(deny_glob("**/*.env").path, Deny)]);
|
||||
let unresolved_pattern = project_roots_glob_pattern(Path::new("**/*.env"));
|
||||
let unresolved_glob = rooted(&root, Write, [deny_glob(&unresolved_pattern)]);
|
||||
let duplicate_tmpdir = managed(vec![
|
||||
root_read(),
|
||||
special(Tmpdir, Read),
|
||||
skipped(FileSystemPath::Special { value: Tmpdir }, Write),
|
||||
]);
|
||||
for unsupported in [
|
||||
&optional_write,
|
||||
&invalid_glob,
|
||||
&optional_glob,
|
||||
&unresolved_glob,
|
||||
&duplicate_tmpdir,
|
||||
] {
|
||||
assert!(matches!(
|
||||
intersect_effective_permission_profiles(unsupported, &readonly, root.as_path()),
|
||||
Err(PermissionIntersectionError::UnsupportedPath(_))
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
intersect_effective_permission_profiles(
|
||||
&readonly,
|
||||
&readonly,
|
||||
Path::new("relative/workspace")
|
||||
),
|
||||
Err(PermissionIntersectionError::UnsupportedPath(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn canonical_grants_cannot_escape_through_readable_or_writable_symlinks() {
|
||||
let temp = TempDir::new().expect("workspace");
|
||||
let root = canonical(&temp);
|
||||
let outside_temp = TempDir::new().expect("outside workspace");
|
||||
let outside = canonical(&outside_temp);
|
||||
let escaped = root.join("escaped");
|
||||
let inside = root.join("inside");
|
||||
let internal_alias = root.join("internal-alias");
|
||||
std::fs::create_dir(inside.as_path()).expect("inside directory");
|
||||
std::os::unix::fs::symlink(outside.as_path(), escaped.as_path()).expect("outside symlink");
|
||||
std::os::unix::fs::symlink(inside.as_path(), internal_alias.as_path()).expect("inside symlink");
|
||||
|
||||
let parent = rooted(&root, Write, []);
|
||||
let child = rooted(&escaped, Write, []);
|
||||
let policy = intersection(&parent, &child, &root).file_system_sandbox_policy();
|
||||
assert!(!policy.can_write_path_with_cwd(outside.as_path(), root.as_path()));
|
||||
|
||||
let parent = managed(vec![entry(root.as_path(), Read)]);
|
||||
let child = managed(vec![entry(escaped.as_path(), Read)]);
|
||||
assert!(matches!(
|
||||
intersect_effective_permission_profiles(&parent, &child, root.as_path()),
|
||||
Err(PermissionIntersectionError::UnsupportedPath(_))
|
||||
));
|
||||
|
||||
let parent = rooted(&root, Write, []);
|
||||
let child = rooted(&internal_alias, Write, []);
|
||||
let policy = intersection(&parent, &child, &root).file_system_sandbox_policy();
|
||||
assert!(policy.can_write_path_with_cwd(inside.as_path(), root.as_path()));
|
||||
assert!(
|
||||
!policy
|
||||
.entries
|
||||
.contains(&entry(internal_alias.as_path(), Write))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_system_path_aliases_share_the_same_physical_permissions() {
|
||||
let temp = TempDir::new_in("/tmp").expect("workspace under system path alias");
|
||||
let canonical = canonical(&temp);
|
||||
let alias = absolute(
|
||||
&Path::new("/").join(
|
||||
canonical
|
||||
.as_path()
|
||||
.strip_prefix("/private")
|
||||
.expect("macOS alias"),
|
||||
),
|
||||
);
|
||||
for access in [Read, Write] {
|
||||
let left = managed(vec![entry(alias.as_path(), access)]);
|
||||
let right = managed(vec![entry(canonical.as_path(), access)]);
|
||||
let policy = intersection(&left, &right, &canonical).file_system_sandbox_policy();
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(canonical.as_path(), canonical.as_path()),
|
||||
access
|
||||
);
|
||||
}
|
||||
let policy = intersection(
|
||||
&workspace(&alias, Enabled),
|
||||
&workspace(&canonical, Restricted),
|
||||
&canonical,
|
||||
)
|
||||
.file_system_sandbox_policy();
|
||||
assert!(policy.can_write_path_with_cwd(canonical.as_path(), canonical.as_path()));
|
||||
}
|
||||
Reference in New Issue
Block a user