mirror of
https://github.com/openai/codex.git
synced 2026-05-17 09:43:19 +00:00
## Summary Make FileSystemSandboxPolicy the semantic source of truth for project root metadata protection. Under writable roots, `.git`, `.codex`, and `.agents` stay protected unless user policy grants an explicit write rule for that metadata path. ## Scope 1. Add `protected_metadata_names` to `WritableRoot`. 2. Teach `FileSystemSandboxPolicy::can_write_path_with_cwd` to reject protected metadata writes under writable roots unless explicitly allowed. 3. Default workspace write profiles to protect `.git`, `.codex`, and `.agents`. 4. Add the Linux fallback setup needed before Linux enforcement lands later in the stack. ## Reviewer Focus 1. The policy decision belongs in FileSystemSandboxPolicy, not shell command parsing. 2. Legacy SandboxPolicy remains a compatibility projection, not the source of the new rule. 3. Explicit user write rules can still opt into these metadata paths. ## Stack 1. Policy primitive: this PR 2. macOS Seatbelt adapter: #19847 3. Shell preflight UX: #19848 4. Runtime profile propagation: #19849 5. Linux bubblewrap adapter: #19852 ## Validation 1. codex protocol permissions tests 2. formatting for codex protocol and codex linux sandbox 3. diff whitespace check
2967 lines
109 KiB
Rust
2967 lines
109 KiB
Rust
use std::collections::HashSet;
|
|
use std::ffi::OsStr;
|
|
use std::io;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
|
|
use globset::GlobBuilder;
|
|
use globset::GlobMatcher;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use strum_macros::Display;
|
|
use tracing::error;
|
|
use ts_rs::TS;
|
|
|
|
use crate::protocol::NetworkAccess;
|
|
use crate::protocol::SandboxPolicy;
|
|
use crate::protocol::WritableRoot;
|
|
|
|
const PROTECTED_METADATA_GIT_PATH_NAME: &str = ".git";
|
|
const PROTECTED_METADATA_AGENTS_PATH_NAME: &str = ".agents";
|
|
const PROTECTED_METADATA_CODEX_PATH_NAME: &str = ".codex";
|
|
|
|
/// Top-level workspace metadata paths that stay protected under writable roots.
|
|
pub const PROTECTED_METADATA_PATH_NAMES: &[&str] = &[
|
|
PROTECTED_METADATA_GIT_PATH_NAME,
|
|
PROTECTED_METADATA_AGENTS_PATH_NAME,
|
|
PROTECTED_METADATA_CODEX_PATH_NAME,
|
|
];
|
|
|
|
/// Returns true when a path basename is one of the protected workspace metadata names.
|
|
pub fn is_protected_metadata_name(name: &OsStr) -> bool {
|
|
PROTECTED_METADATA_PATH_NAMES
|
|
.iter()
|
|
.any(|metadata_name| name == OsStr::new(metadata_name))
|
|
}
|
|
|
|
pub fn is_protected_metadata_directory_name(name: &OsStr) -> bool {
|
|
name == OsStr::new(PROTECTED_METADATA_AGENTS_PATH_NAME)
|
|
|| name == OsStr::new(PROTECTED_METADATA_CODEX_PATH_NAME)
|
|
}
|
|
|
|
/// Returns the protected workspace metadata name when an agent write to `path`
|
|
/// should be blocked before execution.
|
|
pub fn forbidden_agent_metadata_write(
|
|
path: &Path,
|
|
cwd: &Path,
|
|
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
|
) -> Option<&'static str> {
|
|
if !matches!(
|
|
file_system_sandbox_policy.kind,
|
|
FileSystemSandboxKind::Restricted
|
|
) {
|
|
return None;
|
|
}
|
|
|
|
let target = resolve_candidate_path(path, cwd)?;
|
|
let (protected_metadata_path, metadata_name) =
|
|
metadata_child_of_writable_root(file_system_sandbox_policy, target.as_path(), cwd)?;
|
|
if has_explicit_write_entry_for_metadata_path(
|
|
file_system_sandbox_policy,
|
|
&protected_metadata_path,
|
|
target.as_path(),
|
|
cwd,
|
|
) {
|
|
return None;
|
|
}
|
|
|
|
if !file_system_sandbox_policy.can_write_path_with_cwd(target.as_path(), cwd) {
|
|
return Some(metadata_name);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[derive(
|
|
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
|
|
)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
#[strum(serialize_all = "kebab-case")]
|
|
pub enum NetworkSandboxPolicy {
|
|
#[default]
|
|
Restricted,
|
|
Enabled,
|
|
}
|
|
|
|
impl NetworkSandboxPolicy {
|
|
pub fn is_enabled(self) -> bool {
|
|
matches!(self, NetworkSandboxPolicy::Enabled)
|
|
}
|
|
}
|
|
|
|
/// Access mode for a filesystem entry.
|
|
///
|
|
/// When two equally specific entries target the same path, we compare these by
|
|
/// conflict precedence rather than by capability breadth: `none` beats
|
|
/// `write`, and `write` beats `read`.
|
|
#[derive(
|
|
Debug,
|
|
Clone,
|
|
Copy,
|
|
Hash,
|
|
PartialEq,
|
|
Eq,
|
|
PartialOrd,
|
|
Ord,
|
|
Serialize,
|
|
Deserialize,
|
|
Display,
|
|
JsonSchema,
|
|
TS,
|
|
)]
|
|
#[serde(rename_all = "lowercase")]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum FileSystemAccessMode {
|
|
Read,
|
|
Write,
|
|
None,
|
|
}
|
|
|
|
impl FileSystemAccessMode {
|
|
pub fn can_read(self) -> bool {
|
|
!matches!(self, FileSystemAccessMode::None)
|
|
}
|
|
|
|
pub fn can_write(self) -> bool {
|
|
matches!(self, FileSystemAccessMode::Write)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
#[ts(tag = "kind")]
|
|
pub enum FileSystemSpecialPath {
|
|
Root,
|
|
Minimal,
|
|
#[serde(alias = "current_working_directory")]
|
|
ProjectRoots {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
subpath: Option<PathBuf>,
|
|
},
|
|
Tmpdir,
|
|
SlashTmp,
|
|
/// WARNING: `:special_path` tokens are part of config compatibility.
|
|
/// Do not make older runtimes reject newly introduced tokens.
|
|
/// New parser support should be additive, while unknown values must stay
|
|
/// representable so config from a newer Codex degrades to warn-and-ignore
|
|
/// instead of failing to load. Codex 0.112.0 rejected unknown values here,
|
|
/// which broke forward compatibility for newer config.
|
|
/// Preserves future special-path tokens so older runtimes can ignore them
|
|
/// without rejecting config authored by a newer release.
|
|
Unknown {
|
|
path: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
subpath: Option<PathBuf>,
|
|
},
|
|
}
|
|
|
|
impl FileSystemSpecialPath {
|
|
pub fn project_roots(subpath: Option<PathBuf>) -> Self {
|
|
Self::ProjectRoots { subpath }
|
|
}
|
|
|
|
pub fn unknown(path: impl Into<String>, subpath: Option<PathBuf>) -> Self {
|
|
Self::Unknown {
|
|
path: path.into(),
|
|
subpath,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
|
|
pub struct FileSystemSandboxEntry {
|
|
pub path: FileSystemPath,
|
|
pub access: FileSystemAccessMode,
|
|
}
|
|
|
|
#[derive(
|
|
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
|
|
)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
#[strum(serialize_all = "kebab-case")]
|
|
pub enum FileSystemSandboxKind {
|
|
#[default]
|
|
Restricted,
|
|
Unrestricted,
|
|
ExternalSandbox,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
|
pub struct FileSystemSandboxPolicy {
|
|
pub kind: FileSystemSandboxKind,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub glob_scan_max_depth: Option<usize>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub entries: Vec<FileSystemSandboxEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ResolvedFileSystemEntry {
|
|
path: AbsolutePathBuf,
|
|
access: FileSystemAccessMode,
|
|
}
|
|
|
|
#[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 {
|
|
denied_candidates: Vec<Vec<PathBuf>>,
|
|
deny_read_matchers: Vec<GlobMatcher>,
|
|
invalid_pattern: bool,
|
|
}
|
|
|
|
impl ReadDenyMatcher {
|
|
/// Builds a matcher from exact deny-read roots and deny-read glob entries.
|
|
///
|
|
/// Returns `None` when the policy has no deny-read restrictions, so callers
|
|
/// can skip read-deny checks without allocating matcher state. The `cwd`
|
|
/// resolves cwd-relative policy paths and special paths before matching.
|
|
pub fn new(file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path) -> Option<Self> {
|
|
if !file_system_sandbox_policy.has_denied_read_restrictions() {
|
|
return None;
|
|
}
|
|
|
|
// Exact roots are stored as all meaningful path spellings we can derive
|
|
// cheaply. This lets direct tool checks catch both a symlink path and
|
|
// its canonical target without changing the policy entries themselves.
|
|
let denied_candidates = file_system_sandbox_policy
|
|
.get_unreadable_roots_with_cwd(cwd)
|
|
.into_iter()
|
|
.map(|path| normalized_and_canonical_candidates(path.as_path()))
|
|
.collect();
|
|
// Pattern entries stay as policy-level globs. They are matched at read
|
|
// time here instead of being snapshotted to startup filesystem state.
|
|
let mut invalid_pattern = false;
|
|
let deny_read_matchers = file_system_sandbox_policy
|
|
.get_unreadable_globs_with_cwd(cwd)
|
|
.into_iter()
|
|
.filter_map(|pattern| match build_glob_matcher(&pattern) {
|
|
Some(matcher) => Some(matcher),
|
|
None => {
|
|
invalid_pattern = true;
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
Some(Self {
|
|
denied_candidates,
|
|
deny_read_matchers,
|
|
invalid_pattern,
|
|
})
|
|
}
|
|
|
|
/// Returns whether `path` is denied by the policy used to build this matcher.
|
|
pub fn is_read_denied(&self, path: &Path) -> bool {
|
|
if self.invalid_pattern {
|
|
// Direct tool reads fail closed on malformed deny patterns. Silent
|
|
// allow would turn a config typo into a policy bypass.
|
|
return true;
|
|
}
|
|
|
|
// Check exact roots against each candidate spelling before evaluating
|
|
// glob matchers. Exact entries are subtree denies; glob entries match
|
|
// according to the pattern compiler's path-separator rules.
|
|
let path_candidates = normalized_and_canonical_candidates(path);
|
|
if self.denied_candidates.iter().any(|denied_candidates| {
|
|
path_candidates.iter().any(|candidate| {
|
|
denied_candidates.iter().any(|denied_candidate| {
|
|
candidate == denied_candidate || candidate.starts_with(denied_candidate)
|
|
})
|
|
})
|
|
}) {
|
|
return true;
|
|
}
|
|
|
|
self.deny_read_matchers.iter().any(|matcher| {
|
|
path_candidates
|
|
.iter()
|
|
.any(|candidate| matcher.is_match(candidate))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
#[ts(tag = "type")]
|
|
pub enum FileSystemPath {
|
|
Path {
|
|
path: AbsolutePathBuf,
|
|
},
|
|
/// A git-style glob pattern. Pattern entries currently support
|
|
/// FileSystemAccessMode::None only.
|
|
GlobPattern {
|
|
pattern: String,
|
|
},
|
|
Special {
|
|
value: FileSystemSpecialPath,
|
|
},
|
|
}
|
|
|
|
impl Default for FileSystemSandboxPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
kind: FileSystemSandboxKind::Restricted,
|
|
glob_scan_max_depth: None,
|
|
entries: vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
}],
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FileSystemSandboxPolicy {
|
|
pub fn unrestricted() -> Self {
|
|
Self {
|
|
kind: FileSystemSandboxKind::Unrestricted,
|
|
glob_scan_max_depth: None,
|
|
entries: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn external_sandbox() -> Self {
|
|
Self {
|
|
kind: FileSystemSandboxKind::ExternalSandbox,
|
|
glob_scan_max_depth: None,
|
|
entries: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn restricted(entries: Vec<FileSystemSandboxEntry>) -> Self {
|
|
Self {
|
|
kind: FileSystemSandboxKind::Restricted,
|
|
glob_scan_max_depth: None,
|
|
entries,
|
|
}
|
|
}
|
|
|
|
fn has_root_access(&self, predicate: impl Fn(FileSystemAccessMode) -> bool) -> bool {
|
|
matches!(self.kind, FileSystemSandboxKind::Restricted)
|
|
&& self.entries.iter().any(|entry| {
|
|
matches!(
|
|
&entry.path,
|
|
FileSystemPath::Special { value }
|
|
if matches!(value, FileSystemSpecialPath::Root) && predicate(entry.access)
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn has_denied_read_restrictions(&self) -> bool {
|
|
matches!(self.kind, FileSystemSandboxKind::Restricted)
|
|
&& self
|
|
.entries
|
|
.iter()
|
|
.any(|entry| entry.access == FileSystemAccessMode::None)
|
|
}
|
|
|
|
pub fn from_legacy_sandbox_policy_preserving_deny_entries(
|
|
sandbox_policy: &SandboxPolicy,
|
|
cwd: &Path,
|
|
existing: &Self,
|
|
) -> Self {
|
|
let mut rebuilt = Self::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd);
|
|
if !matches!(rebuilt.kind, FileSystemSandboxKind::Restricted) {
|
|
return rebuilt;
|
|
}
|
|
rebuilt.glob_scan_max_depth = existing.glob_scan_max_depth;
|
|
|
|
for deny_entry in existing
|
|
.entries
|
|
.iter()
|
|
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
|
{
|
|
if !rebuilt.entries.iter().any(|entry| entry == deny_entry) {
|
|
rebuilt.entries.push(deny_entry.clone());
|
|
}
|
|
}
|
|
|
|
rebuilt
|
|
}
|
|
|
|
/// Preserve explicit read-deny rules from `existing` when a caller
|
|
/// replaces the allow side of a policy.
|
|
pub fn preserve_deny_read_restrictions_from(&mut self, existing: &Self) {
|
|
let has_deny_read_entries = existing
|
|
.entries
|
|
.iter()
|
|
.any(|entry| entry.access == FileSystemAccessMode::None);
|
|
if matches!(self.kind, FileSystemSandboxKind::Unrestricted) && has_deny_read_entries {
|
|
*self = Self::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
}]);
|
|
}
|
|
|
|
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
|
|
return;
|
|
}
|
|
|
|
if self.glob_scan_max_depth.is_none() {
|
|
self.glob_scan_max_depth = existing.glob_scan_max_depth;
|
|
}
|
|
|
|
for deny_entry in existing
|
|
.entries
|
|
.iter()
|
|
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
|
{
|
|
if !self.entries.iter().any(|entry| entry == deny_entry) {
|
|
self.entries.push(deny_entry.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns true when a restricted policy contains any entry that really
|
|
/// reduces a broader `:root = write` grant.
|
|
///
|
|
/// Raw entry presence is not enough here: an equally specific `write`
|
|
/// entry for the same target wins under the normal precedence rules, so a
|
|
/// shadowed `read` entry must not downgrade the policy out of full-disk
|
|
/// write mode.
|
|
fn has_write_narrowing_entries(&self) -> bool {
|
|
matches!(self.kind, FileSystemSandboxKind::Restricted)
|
|
&& self.entries.iter().any(|entry| {
|
|
if entry.access.can_write() {
|
|
return false;
|
|
}
|
|
|
|
match &entry.path {
|
|
FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry),
|
|
FileSystemPath::GlobPattern { .. } => true,
|
|
FileSystemPath::Special { value } => match value {
|
|
FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None,
|
|
FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => {
|
|
false
|
|
}
|
|
_ => !self.has_same_target_write_override(entry),
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Returns true when a higher-priority `write` entry targets the same
|
|
/// location as `entry`, so `entry` cannot narrow effective write access.
|
|
fn has_same_target_write_override(&self, entry: &FileSystemSandboxEntry) -> bool {
|
|
self.entries.iter().any(|candidate| {
|
|
candidate.access.can_write()
|
|
&& candidate.access > entry.access
|
|
&& file_system_paths_share_target(&candidate.path, &entry.path)
|
|
})
|
|
}
|
|
|
|
/// Filesystem policy matching `WorkspaceWrite` semantics without requiring
|
|
/// callers to construct a legacy [`SandboxPolicy`] first.
|
|
pub fn workspace_write(
|
|
writable_roots: &[AbsolutePathBuf],
|
|
exclude_tmpdir_env_var: bool,
|
|
exclude_slash_tmp: bool,
|
|
) -> Self {
|
|
let mut entries = vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
}];
|
|
|
|
entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
});
|
|
if !exclude_slash_tmp {
|
|
entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::SlashTmp,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
});
|
|
}
|
|
if !exclude_tmpdir_env_var {
|
|
entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Tmpdir,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
});
|
|
}
|
|
entries.extend(
|
|
writable_roots
|
|
.iter()
|
|
.cloned()
|
|
.map(|path| FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path },
|
|
access: FileSystemAccessMode::Write,
|
|
}),
|
|
);
|
|
|
|
append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".git");
|
|
append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".agents");
|
|
append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".codex");
|
|
for writable_root in writable_roots {
|
|
for protected_path in default_read_only_subpaths_for_writable_root(
|
|
writable_root,
|
|
/*protect_missing_dot_codex*/ false,
|
|
) {
|
|
append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
|
|
}
|
|
}
|
|
|
|
FileSystemSandboxPolicy::restricted(entries)
|
|
}
|
|
|
|
/// Converts a legacy sandbox policy into an equivalent filesystem policy
|
|
/// after resolving cwd-sensitive legacy defaults for the provided cwd.
|
|
///
|
|
/// Legacy `WorkspaceWrite` policies may list readable roots that live
|
|
/// under an already-writable root. Those paths were redundant in the
|
|
/// legacy model and should not become read-only carveouts when projected
|
|
/// into split filesystem policy.
|
|
pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
|
|
let mut file_system_policy = Self::from(sandbox_policy);
|
|
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy {
|
|
if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
|
|
for protected_path in default_read_only_subpaths_for_writable_root(
|
|
&cwd_root, /*protect_missing_dot_codex*/ true,
|
|
) {
|
|
append_default_read_only_path_if_no_explicit_rule(
|
|
&mut file_system_policy.entries,
|
|
protected_path,
|
|
);
|
|
}
|
|
}
|
|
for writable_root in writable_roots {
|
|
for protected_path in default_read_only_subpaths_for_writable_root(
|
|
writable_root,
|
|
/*protect_missing_dot_codex*/ false,
|
|
) {
|
|
append_default_read_only_path_if_no_explicit_rule(
|
|
&mut file_system_policy.entries,
|
|
protected_path,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
file_system_policy
|
|
}
|
|
|
|
/// Returns true when filesystem reads are unrestricted.
|
|
pub fn has_full_disk_read_access(&self) -> bool {
|
|
match self.kind {
|
|
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
|
|
FileSystemSandboxKind::Restricted => {
|
|
self.has_root_access(FileSystemAccessMode::can_read)
|
|
&& !self.has_denied_read_restrictions()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns true when filesystem writes are unrestricted.
|
|
pub fn has_full_disk_write_access(&self) -> bool {
|
|
match self.kind {
|
|
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
|
|
FileSystemSandboxKind::Restricted => {
|
|
self.has_root_access(FileSystemAccessMode::can_write)
|
|
&& !self.has_write_narrowing_entries()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns true when platform-default readable roots should be included.
|
|
pub fn include_platform_defaults(&self) -> bool {
|
|
!self.has_full_disk_read_access()
|
|
&& matches!(self.kind, FileSystemSandboxKind::Restricted)
|
|
&& self.entries.iter().any(|entry| {
|
|
matches!(
|
|
&entry.path,
|
|
FileSystemPath::Special { value }
|
|
if matches!(value, FileSystemSpecialPath::Minimal)
|
|
&& entry.access.can_read()
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn resolve_access_with_cwd(&self, path: &Path, cwd: &Path) -> FileSystemAccessMode {
|
|
match self.kind {
|
|
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
|
|
return FileSystemAccessMode::Write;
|
|
}
|
|
FileSystemSandboxKind::Restricted => {}
|
|
}
|
|
|
|
let Some(path) = resolve_candidate_path(path, cwd) else {
|
|
return FileSystemAccessMode::None;
|
|
};
|
|
|
|
self.resolved_entries_with_cwd(cwd)
|
|
.into_iter()
|
|
.filter(|entry| path.as_path().starts_with(entry.path.as_path()))
|
|
.max_by_key(resolved_entry_precedence)
|
|
.map(|entry| entry.access)
|
|
.unwrap_or(FileSystemAccessMode::None)
|
|
}
|
|
|
|
pub fn can_read_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
|
|
self.resolve_access_with_cwd(path, cwd).can_read()
|
|
}
|
|
|
|
pub fn can_write_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
|
|
if !self.resolve_access_with_cwd(path, cwd).can_write() {
|
|
return false;
|
|
}
|
|
if self.has_full_disk_write_access() {
|
|
return true;
|
|
}
|
|
!self.is_metadata_write_denied(path, cwd)
|
|
}
|
|
|
|
fn is_metadata_write_denied(&self, path: &Path, cwd: &Path) -> bool {
|
|
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
|
|
return false;
|
|
}
|
|
|
|
let Some(target) = resolve_candidate_path(path, cwd) else {
|
|
return true;
|
|
};
|
|
let Some((protected_metadata_path, _)) =
|
|
metadata_child_of_writable_root(self, target.as_path(), cwd)
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
!has_explicit_write_entry_for_metadata_path(
|
|
self,
|
|
&protected_metadata_path,
|
|
target.as_path(),
|
|
cwd,
|
|
)
|
|
}
|
|
|
|
/// Replaces symbolic `:project_roots` entries with absolute paths resolved
|
|
/// against `cwd`.
|
|
///
|
|
/// Use this when a durable permission profile must survive a cwd-only
|
|
/// update without rebinding its project-root authority to the new cwd.
|
|
pub fn materialize_project_roots_with_cwd(mut self, cwd: &Path) -> Self {
|
|
let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
|
for entry in &mut self.entries {
|
|
let FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::ProjectRoots { .. },
|
|
} = &entry.path
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) {
|
|
entry.path = FileSystemPath::Path { path };
|
|
}
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn with_additional_readable_roots(
|
|
mut self,
|
|
cwd: &Path,
|
|
additional_readable_roots: &[AbsolutePathBuf],
|
|
) -> Self {
|
|
if self.has_full_disk_read_access() {
|
|
return self;
|
|
}
|
|
|
|
for path in additional_readable_roots {
|
|
if self.can_read_path_with_cwd(path.as_path(), cwd) {
|
|
continue;
|
|
}
|
|
|
|
self.entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: path.clone() },
|
|
access: FileSystemAccessMode::Read,
|
|
});
|
|
}
|
|
|
|
self
|
|
}
|
|
|
|
pub fn with_additional_writable_roots(
|
|
mut self,
|
|
cwd: &Path,
|
|
additional_writable_roots: &[AbsolutePathBuf],
|
|
) -> Self {
|
|
for path in additional_writable_roots {
|
|
if self.can_write_path_with_cwd(path.as_path(), cwd) {
|
|
continue;
|
|
}
|
|
|
|
self.entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: path.clone() },
|
|
access: FileSystemAccessMode::Write,
|
|
});
|
|
}
|
|
|
|
self
|
|
}
|
|
|
|
/// Add roots using legacy `WorkspaceWrite` behavior.
|
|
///
|
|
/// Unlike [`Self::with_additional_writable_roots`], this mirrors legacy
|
|
/// writable-roots semantics by adding exact roots even when they are
|
|
/// already writable through `:project_roots`, and by adding the default
|
|
/// read-only protected subpaths for each new root.
|
|
pub fn with_additional_legacy_workspace_writable_roots(
|
|
mut self,
|
|
additional_writable_roots: &[AbsolutePathBuf],
|
|
) -> Self {
|
|
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
|
|
return self;
|
|
}
|
|
|
|
for path in additional_writable_roots {
|
|
if !self.entries.iter().any(|entry| {
|
|
entry.access.can_write()
|
|
&& matches!(&entry.path, FileSystemPath::Path { path: existing } if existing == path)
|
|
}) {
|
|
self.entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: path.clone() },
|
|
access: FileSystemAccessMode::Write,
|
|
});
|
|
}
|
|
|
|
for protected_path in default_read_only_subpaths_for_writable_root(
|
|
path, /*protect_missing_dot_codex*/ false,
|
|
) {
|
|
append_default_read_only_path_if_no_explicit_rule(
|
|
&mut self.entries,
|
|
protected_path,
|
|
);
|
|
}
|
|
}
|
|
|
|
self
|
|
}
|
|
|
|
pub fn needs_direct_runtime_enforcement(
|
|
&self,
|
|
network_policy: NetworkSandboxPolicy,
|
|
cwd: &Path,
|
|
) -> bool {
|
|
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
|
|
return false;
|
|
}
|
|
|
|
let Ok(legacy_policy) = self.to_legacy_sandbox_policy(network_policy, cwd) else {
|
|
return true;
|
|
};
|
|
|
|
if protected_metadata_names_need_direct_runtime_enforcement(self, &legacy_policy, cwd) {
|
|
return true;
|
|
}
|
|
|
|
self.semantic_signature(cwd)
|
|
!= legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd)
|
|
.semantic_signature(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)
|
|
}
|
|
|
|
/// Returns the explicit readable roots resolved against the provided cwd.
|
|
pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
|
|
if self.has_full_disk_read_access() {
|
|
return Vec::new();
|
|
}
|
|
|
|
dedup_absolute_paths(
|
|
self.resolved_entries_with_cwd(cwd)
|
|
.into_iter()
|
|
.filter(|entry| entry.access.can_read())
|
|
.filter(|entry| self.can_read_path_with_cwd(entry.path.as_path(), cwd))
|
|
.map(|entry| entry.path)
|
|
.collect(),
|
|
/*normalize_effective_paths*/ true,
|
|
)
|
|
}
|
|
|
|
/// 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> {
|
|
if self.has_full_disk_write_access() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let resolved_entries = self.resolved_entries_with_cwd(cwd);
|
|
let writable_entries: Vec<AbsolutePathBuf> = resolved_entries
|
|
.iter()
|
|
.filter(|entry| entry.access.can_write())
|
|
.filter(|entry| self.can_write_path_with_cwd(entry.path.as_path(), cwd))
|
|
.map(|entry| entry.path.clone())
|
|
.collect();
|
|
|
|
dedup_absolute_paths(
|
|
writable_entries.clone(),
|
|
/*normalize_effective_paths*/ true,
|
|
)
|
|
.into_iter()
|
|
.map(|root| {
|
|
// Filesystem-root policies stay in their effective canonical form
|
|
// so root-wide aliases do not create duplicate top-level masks.
|
|
// Example: keep `/var/...` normalized under `/` instead of
|
|
// materializing both `/var/...` and `/private/var/...`.
|
|
// Nested symlink paths under a writable root stay logical so
|
|
// downstream sandboxes can still bind the real target while
|
|
// masking the user-visible symlink inode when needed.
|
|
let preserve_raw_carveout_paths = root.as_path().parent().is_some();
|
|
let raw_writable_roots: Vec<&AbsolutePathBuf> = writable_entries
|
|
.iter()
|
|
.filter(|path| normalize_effective_absolute_path((*path).clone()) == root)
|
|
.collect();
|
|
let protected_metadata_names =
|
|
protected_metadata_names_for_writable_root(self, &root, &raw_writable_roots, cwd);
|
|
let protect_missing_dot_codex = AbsolutePathBuf::from_absolute_path(cwd)
|
|
.ok()
|
|
.is_some_and(|cwd| normalize_effective_absolute_path(cwd) == 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))
|
|
.collect();
|
|
// Narrower explicit non-write entries carve out broader writable roots.
|
|
// More specific write entries still remain writable because they appear
|
|
// as separate WritableRoot values and are checked independently.
|
|
// Preserve symlink path components that live under the writable root
|
|
// so downstream sandboxes can still mask the symlink inode itself.
|
|
// Example: if `<root>/.codex -> <root>/decoy`, bwrap must still see
|
|
// `<root>/.codex`, not only the resolved `<root>/decoy`.
|
|
read_only_subpaths.extend(
|
|
resolved_entries
|
|
.iter()
|
|
.filter(|entry| !entry.access.can_write())
|
|
.filter(|entry| !self.can_write_path_with_cwd(entry.path.as_path(), cwd))
|
|
.filter_map(|entry| {
|
|
let effective_path = normalize_effective_absolute_path(entry.path.clone());
|
|
// Preserve the literal in-root path whenever the
|
|
// carveout itself lives under this writable root, even
|
|
// if following symlinks would resolve back to the root
|
|
// or escape outside it. Downstream sandboxes need that
|
|
// raw path so they can mask the symlink inode itself.
|
|
// Examples:
|
|
// - `<root>/linked-private -> <root>/decoy-private`
|
|
// - `<root>/linked-private -> /tmp/outside-private`
|
|
// - `<root>/alias-root -> <root>`
|
|
let raw_carveout_path = if preserve_raw_carveout_paths {
|
|
if entry.path == root {
|
|
None
|
|
} else if entry.path.as_path().starts_with(root.as_path()) {
|
|
Some(entry.path.clone())
|
|
} else {
|
|
raw_writable_roots.iter().find_map(|raw_root| {
|
|
let suffix = entry
|
|
.path
|
|
.as_path()
|
|
.strip_prefix(raw_root.as_path())
|
|
.ok()?;
|
|
if suffix.as_os_str().is_empty() {
|
|
return None;
|
|
}
|
|
Some(root.join(suffix))
|
|
})
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(raw_carveout_path) = raw_carveout_path {
|
|
return Some(raw_carveout_path);
|
|
}
|
|
|
|
if effective_path == root
|
|
|| !effective_path.as_path().starts_with(root.as_path())
|
|
{
|
|
return None;
|
|
}
|
|
|
|
Some(effective_path)
|
|
}),
|
|
);
|
|
WritableRoot {
|
|
protected_metadata_names,
|
|
root,
|
|
// Preserve literal in-root protected paths like `.git` and
|
|
// `.codex` so downstream sandboxes can still detect and mask
|
|
// the symlink itself instead of only its resolved target.
|
|
read_only_subpaths: dedup_absolute_paths(
|
|
read_only_subpaths,
|
|
/*normalize_effective_paths*/ false,
|
|
),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// 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));
|
|
|
|
dedup_absolute_paths(
|
|
self.resolved_entries_with_cwd(cwd)
|
|
.iter()
|
|
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
|
.filter(|entry| !self.can_read_path_with_cwd(entry.path.as_path(), cwd))
|
|
// 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,
|
|
)
|
|
}
|
|
|
|
/// Returns unreadable glob patterns resolved against the provided cwd.
|
|
pub fn get_unreadable_globs_with_cwd(&self, cwd: &Path) -> Vec<String> {
|
|
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut patterns = self
|
|
.entries
|
|
.iter()
|
|
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
|
.filter_map(|entry| match &entry.path {
|
|
FileSystemPath::GlobPattern { pattern } => {
|
|
Some(AbsolutePathBuf::resolve_path_against_base(pattern, cwd))
|
|
}
|
|
FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => None,
|
|
})
|
|
.map(|pattern| pattern.to_string_lossy().into_owned())
|
|
.collect::<Vec<_>>();
|
|
patterns.sort();
|
|
patterns.dedup();
|
|
patterns
|
|
}
|
|
|
|
pub fn to_legacy_sandbox_policy(
|
|
&self,
|
|
network_policy: NetworkSandboxPolicy,
|
|
cwd: &Path,
|
|
) -> io::Result<SandboxPolicy> {
|
|
Ok(match self.kind {
|
|
FileSystemSandboxKind::ExternalSandbox => SandboxPolicy::ExternalSandbox {
|
|
network_access: if network_policy.is_enabled() {
|
|
NetworkAccess::Enabled
|
|
} else {
|
|
NetworkAccess::Restricted
|
|
},
|
|
},
|
|
FileSystemSandboxKind::Unrestricted => {
|
|
if network_policy.is_enabled() {
|
|
SandboxPolicy::DangerFullAccess
|
|
} else {
|
|
SandboxPolicy::ExternalSandbox {
|
|
network_access: NetworkAccess::Restricted,
|
|
}
|
|
}
|
|
}
|
|
FileSystemSandboxKind::Restricted => {
|
|
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
|
let has_full_disk_write_access = self.has_full_disk_write_access();
|
|
let mut workspace_root_writable = false;
|
|
let mut writable_roots = Vec::new();
|
|
let mut tmpdir_writable = false;
|
|
let mut slash_tmp_writable = false;
|
|
let mut unbridgeable_root_write = false;
|
|
|
|
for entry in &self.entries {
|
|
match &entry.path {
|
|
FileSystemPath::GlobPattern { .. } => {}
|
|
FileSystemPath::Path { path } => {
|
|
if entry.access.can_write() {
|
|
if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) {
|
|
workspace_root_writable = true;
|
|
} else {
|
|
writable_roots.push(path.clone());
|
|
}
|
|
}
|
|
}
|
|
FileSystemPath::Special { value } => match value {
|
|
FileSystemSpecialPath::Root => match entry.access {
|
|
FileSystemAccessMode::None => {}
|
|
FileSystemAccessMode::Read => {}
|
|
FileSystemAccessMode::Write => {
|
|
unbridgeable_root_write = true;
|
|
}
|
|
},
|
|
FileSystemSpecialPath::Minimal => {}
|
|
FileSystemSpecialPath::ProjectRoots { subpath } => {
|
|
if subpath.is_none() && entry.access.can_write() {
|
|
workspace_root_writable = true;
|
|
} else if let Some(path) =
|
|
resolve_file_system_special_path(value, cwd_absolute.as_ref())
|
|
&& entry.access.can_write()
|
|
{
|
|
writable_roots.push(path);
|
|
}
|
|
}
|
|
FileSystemSpecialPath::Tmpdir => {
|
|
if entry.access.can_write() {
|
|
tmpdir_writable = true;
|
|
}
|
|
}
|
|
FileSystemSpecialPath::SlashTmp => {
|
|
if entry.access.can_write() {
|
|
slash_tmp_writable = true;
|
|
}
|
|
}
|
|
FileSystemSpecialPath::Unknown { .. } => {}
|
|
},
|
|
}
|
|
}
|
|
|
|
if has_full_disk_write_access {
|
|
return Ok(if network_policy.is_enabled() {
|
|
SandboxPolicy::DangerFullAccess
|
|
} else {
|
|
SandboxPolicy::ExternalSandbox {
|
|
network_access: NetworkAccess::Restricted,
|
|
}
|
|
});
|
|
}
|
|
|
|
if workspace_root_writable {
|
|
SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: dedup_absolute_paths(
|
|
writable_roots,
|
|
/*normalize_effective_paths*/ false,
|
|
),
|
|
network_access: network_policy.is_enabled(),
|
|
exclude_tmpdir_env_var: !tmpdir_writable,
|
|
exclude_slash_tmp: !slash_tmp_writable,
|
|
}
|
|
} else if unbridgeable_root_write
|
|
|| !writable_roots.is_empty()
|
|
|| tmpdir_writable
|
|
|| slash_tmp_writable
|
|
{
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly",
|
|
));
|
|
} else {
|
|
SandboxPolicy::ReadOnly {
|
|
network_access: network_policy.is_enabled(),
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn resolved_entries_with_cwd(&self, cwd: &Path) -> Vec<ResolvedFileSystemEntry> {
|
|
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
|
self.entries
|
|
.iter()
|
|
.filter_map(|entry| {
|
|
resolve_entry_path(&entry.path, cwd_absolute.as_ref()).map(|path| {
|
|
ResolvedFileSystemEntry {
|
|
path,
|
|
access: entry.access,
|
|
}
|
|
})
|
|
})
|
|
.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),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&SandboxPolicy> for NetworkSandboxPolicy {
|
|
fn from(value: &SandboxPolicy) -> Self {
|
|
if value.has_full_network_access() {
|
|
NetworkSandboxPolicy::Enabled
|
|
} else {
|
|
NetworkSandboxPolicy::Restricted
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
|
|
fn from(value: &SandboxPolicy) -> Self {
|
|
match value {
|
|
SandboxPolicy::DangerFullAccess => FileSystemSandboxPolicy::unrestricted(),
|
|
SandboxPolicy::ExternalSandbox { .. } => FileSystemSandboxPolicy::external_sandbox(),
|
|
SandboxPolicy::ReadOnly { .. } => {
|
|
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
}])
|
|
}
|
|
SandboxPolicy::WorkspaceWrite {
|
|
writable_roots,
|
|
exclude_tmpdir_env_var,
|
|
exclude_slash_tmp,
|
|
..
|
|
} => FileSystemSandboxPolicy::workspace_write(
|
|
writable_roots,
|
|
*exclude_tmpdir_env_var,
|
|
*exclude_slash_tmp,
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_file_system_path(
|
|
path: &FileSystemPath,
|
|
cwd: Option<&AbsolutePathBuf>,
|
|
) -> Option<AbsolutePathBuf> {
|
|
match path {
|
|
FileSystemPath::Path { path } => Some(path.clone()),
|
|
FileSystemPath::GlobPattern { .. } => None,
|
|
FileSystemPath::Special { value } => resolve_file_system_special_path(value, cwd),
|
|
}
|
|
}
|
|
|
|
fn resolve_entry_path(
|
|
path: &FileSystemPath,
|
|
cwd: Option<&AbsolutePathBuf>,
|
|
) -> Option<AbsolutePathBuf> {
|
|
match path {
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
} => cwd.map(absolute_root_path_for_cwd),
|
|
_ => resolve_file_system_path(path, cwd),
|
|
}
|
|
}
|
|
|
|
fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option<AbsolutePathBuf> {
|
|
if path.is_absolute() {
|
|
AbsolutePathBuf::from_absolute_path(path).ok()
|
|
} else {
|
|
Some(AbsolutePathBuf::from_absolute_path(cwd).ok()?.join(path))
|
|
}
|
|
}
|
|
|
|
/// Returns true when two config paths refer to the same exact target before
|
|
/// any prefix matching is applied.
|
|
///
|
|
/// This is intentionally narrower than full path resolution: it only answers
|
|
/// the "can one entry shadow another at the same specificity?" question used
|
|
/// by `has_write_narrowing_entries`.
|
|
fn file_system_paths_share_target(left: &FileSystemPath, right: &FileSystemPath) -> bool {
|
|
match (left, right) {
|
|
(FileSystemPath::Path { path: left }, FileSystemPath::Path { path: right }) => {
|
|
left == right
|
|
}
|
|
(FileSystemPath::Special { value: left }, FileSystemPath::Special { value: right }) => {
|
|
special_paths_share_target(left, right)
|
|
}
|
|
(FileSystemPath::Path { path }, FileSystemPath::Special { value })
|
|
| (FileSystemPath::Special { value }, FileSystemPath::Path { path }) => {
|
|
special_path_matches_absolute_path(value, path)
|
|
}
|
|
(
|
|
FileSystemPath::GlobPattern { pattern: left },
|
|
FileSystemPath::GlobPattern { pattern: right },
|
|
) => left == right,
|
|
(FileSystemPath::GlobPattern { .. }, _) | (_, FileSystemPath::GlobPattern { .. }) => false,
|
|
}
|
|
}
|
|
|
|
/// Compares special-path tokens that resolve to the same concrete target
|
|
/// without needing a cwd.
|
|
fn special_paths_share_target(left: &FileSystemSpecialPath, right: &FileSystemSpecialPath) -> bool {
|
|
match (left, right) {
|
|
(FileSystemSpecialPath::Root, FileSystemSpecialPath::Root)
|
|
| (FileSystemSpecialPath::Minimal, FileSystemSpecialPath::Minimal)
|
|
| (FileSystemSpecialPath::Tmpdir, FileSystemSpecialPath::Tmpdir)
|
|
| (FileSystemSpecialPath::SlashTmp, FileSystemSpecialPath::SlashTmp) => true,
|
|
(
|
|
FileSystemSpecialPath::ProjectRoots { subpath: left },
|
|
FileSystemSpecialPath::ProjectRoots { subpath: right },
|
|
) => left == right,
|
|
(
|
|
FileSystemSpecialPath::Unknown {
|
|
path: left,
|
|
subpath: left_subpath,
|
|
},
|
|
FileSystemSpecialPath::Unknown {
|
|
path: right,
|
|
subpath: right_subpath,
|
|
},
|
|
) => left == right && left_subpath == right_subpath,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Matches cwd-independent special paths against absolute `Path` entries when
|
|
/// they name the same location.
|
|
///
|
|
/// We intentionally only fold the special paths whose concrete meaning is
|
|
/// stable without a cwd, such as `/` and `/tmp`.
|
|
fn special_path_matches_absolute_path(
|
|
value: &FileSystemSpecialPath,
|
|
path: &AbsolutePathBuf,
|
|
) -> bool {
|
|
match value {
|
|
FileSystemSpecialPath::Root => path.as_path().parent().is_none(),
|
|
FileSystemSpecialPath::SlashTmp => path.as_path() == Path::new("/tmp"),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Orders resolved entries so the most specific path wins first, then applies
|
|
/// the access tie-breaker from [`FileSystemAccessMode`].
|
|
fn resolved_entry_precedence(entry: &ResolvedFileSystemEntry) -> (usize, FileSystemAccessMode) {
|
|
let specificity = entry.path.as_path().components().count();
|
|
(specificity, entry.access)
|
|
}
|
|
|
|
fn absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
|
|
let root = cwd
|
|
.as_path()
|
|
.ancestors()
|
|
.last()
|
|
.unwrap_or_else(|| panic!("cwd must have a filesystem root"));
|
|
AbsolutePathBuf::from_absolute_path(root)
|
|
.unwrap_or_else(|err| panic!("cwd root must be an absolute path: {err}"))
|
|
}
|
|
|
|
fn normalized_and_canonical_candidates(path: &Path) -> Vec<PathBuf> {
|
|
// Compare the lexical absolute form plus the canonical target when it
|
|
// exists. Missing paths still need the lexical candidate so future-created
|
|
// denied paths remain blocked by direct tool checks.
|
|
let mut candidates = Vec::new();
|
|
|
|
if let Ok(normalized) = AbsolutePathBuf::from_absolute_path(path) {
|
|
push_unique(&mut candidates, normalized.to_path_buf());
|
|
} else {
|
|
push_unique(&mut candidates, path.to_path_buf());
|
|
}
|
|
|
|
if let Ok(canonical) = path.canonicalize()
|
|
&& let Ok(canonical_absolute) = AbsolutePathBuf::from_absolute_path(canonical)
|
|
{
|
|
push_unique(&mut candidates, canonical_absolute.to_path_buf());
|
|
}
|
|
|
|
candidates
|
|
}
|
|
|
|
fn push_unique(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
|
|
if !candidates.iter().any(|existing| existing == &candidate) {
|
|
candidates.push(candidate);
|
|
}
|
|
}
|
|
|
|
fn build_glob_matcher(pattern: &str) -> Option<GlobMatcher> {
|
|
// Keep `*` and `?` within a single path component and preserve an unclosed
|
|
// `[` as a literal so matcher behavior stays aligned with config parsing.
|
|
GlobBuilder::new(pattern)
|
|
.literal_separator(true)
|
|
.allow_unclosed_class(true)
|
|
.build()
|
|
.ok()
|
|
.map(|glob| glob.compile_matcher())
|
|
}
|
|
|
|
fn resolve_file_system_special_path(
|
|
value: &FileSystemSpecialPath,
|
|
cwd: Option<&AbsolutePathBuf>,
|
|
) -> Option<AbsolutePathBuf> {
|
|
match value {
|
|
FileSystemSpecialPath::Root
|
|
| FileSystemSpecialPath::Minimal
|
|
| FileSystemSpecialPath::Unknown { .. } => None,
|
|
FileSystemSpecialPath::ProjectRoots { subpath } => {
|
|
let cwd = cwd?;
|
|
match subpath.as_ref() {
|
|
Some(subpath) => Some(AbsolutePathBuf::resolve_path_against_base(
|
|
subpath,
|
|
cwd.as_path(),
|
|
)),
|
|
None => Some(cwd.clone()),
|
|
}
|
|
}
|
|
FileSystemSpecialPath::Tmpdir => {
|
|
let tmpdir = std::env::var_os("TMPDIR")?;
|
|
if tmpdir.is_empty() {
|
|
None
|
|
} else {
|
|
let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?;
|
|
Some(tmpdir)
|
|
}
|
|
}
|
|
FileSystemSpecialPath::SlashTmp => {
|
|
#[allow(clippy::expect_used)]
|
|
let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
|
|
if !slash_tmp.as_path().is_dir() {
|
|
return None;
|
|
}
|
|
Some(slash_tmp)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn dedup_absolute_paths(
|
|
paths: Vec<AbsolutePathBuf>,
|
|
normalize_effective_paths: bool,
|
|
) -> Vec<AbsolutePathBuf> {
|
|
let mut deduped = Vec::with_capacity(paths.len());
|
|
let mut seen = HashSet::new();
|
|
for path in paths {
|
|
let dedup_path = if normalize_effective_paths {
|
|
normalize_effective_absolute_path(path)
|
|
} else {
|
|
path
|
|
};
|
|
if seen.insert(dedup_path.to_path_buf()) {
|
|
deduped.push(dedup_path);
|
|
}
|
|
}
|
|
deduped
|
|
}
|
|
|
|
fn sorted_absolute_paths(mut paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
|
|
paths.sort_by(|left, right| left.as_path().cmp(right.as_path()));
|
|
paths
|
|
}
|
|
|
|
fn sorted_writable_roots(mut roots: Vec<WritableRoot>) -> Vec<WritableRoot> {
|
|
for root in &mut roots {
|
|
root.read_only_subpaths =
|
|
sorted_absolute_paths(std::mem::take(&mut root.read_only_subpaths));
|
|
root.protected_metadata_names.sort();
|
|
root.protected_metadata_names.dedup();
|
|
}
|
|
roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path()));
|
|
roots
|
|
}
|
|
|
|
fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
|
|
let raw_path = path.to_path_buf();
|
|
for ancestor in raw_path.ancestors() {
|
|
if std::fs::symlink_metadata(ancestor).is_err() {
|
|
continue;
|
|
}
|
|
let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else {
|
|
continue;
|
|
};
|
|
let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
|
|
continue;
|
|
};
|
|
if let Ok(normalized_path) =
|
|
AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix))
|
|
{
|
|
return normalized_path;
|
|
}
|
|
}
|
|
path
|
|
}
|
|
|
|
pub(crate) fn default_read_only_subpaths_for_writable_root(
|
|
writable_root: &AbsolutePathBuf,
|
|
protect_missing_dot_codex: bool,
|
|
) -> Vec<AbsolutePathBuf> {
|
|
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
|
|
let top_level_git = writable_root.join(PROTECTED_METADATA_GIT_PATH_NAME);
|
|
// This applies to typical repos (directory .git), worktrees/submodules
|
|
// (file .git with gitdir pointer), and bare repos when the gitdir is the
|
|
// writable root itself.
|
|
let top_level_git_is_file = top_level_git.as_path().is_file();
|
|
let top_level_git_is_dir = top_level_git.as_path().is_dir();
|
|
let should_protect_top_level = top_level_git_is_dir || top_level_git_is_file;
|
|
if should_protect_top_level {
|
|
if top_level_git_is_file
|
|
&& is_git_pointer_file(&top_level_git)
|
|
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
|
|
{
|
|
subpaths.push(gitdir);
|
|
}
|
|
subpaths.push(top_level_git);
|
|
}
|
|
|
|
let top_level_agents = writable_root.join(PROTECTED_METADATA_AGENTS_PATH_NAME);
|
|
if top_level_agents.as_path().is_dir() {
|
|
subpaths.push(top_level_agents);
|
|
}
|
|
|
|
// Keep top-level project metadata under .codex read-only to the agent by
|
|
// default. For the workspace root itself, protect it even before the
|
|
// directory exists so first-time creation still goes through the
|
|
// protected-path approval flow.
|
|
let top_level_codex = writable_root.join(PROTECTED_METADATA_CODEX_PATH_NAME);
|
|
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
|
|
subpaths.push(top_level_codex);
|
|
}
|
|
|
|
dedup_absolute_paths(subpaths, /*normalize_effective_paths*/ false)
|
|
}
|
|
|
|
/// Rebuilds the filesystem policy that legacy sandbox runtimes enforce for a
|
|
/// concrete cwd.
|
|
///
|
|
/// Unlike [`FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd`], this
|
|
/// intentionally does not add symbolic project-root metadata carveouts. Legacy
|
|
/// runtime expansion only protected `.git`/`.agents` when those paths already
|
|
/// existed, so missing-path carveouts still require direct profile enforcement.
|
|
fn legacy_runtime_file_system_policy_for_cwd(
|
|
sandbox_policy: &SandboxPolicy,
|
|
cwd: &Path,
|
|
) -> FileSystemSandboxPolicy {
|
|
let SandboxPolicy::WorkspaceWrite {
|
|
writable_roots,
|
|
exclude_tmpdir_env_var,
|
|
exclude_slash_tmp,
|
|
..
|
|
} = sandbox_policy
|
|
else {
|
|
return FileSystemSandboxPolicy::from(sandbox_policy);
|
|
};
|
|
|
|
let mut entries = vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
];
|
|
|
|
if !*exclude_slash_tmp {
|
|
entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::SlashTmp,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
});
|
|
}
|
|
if !*exclude_tmpdir_env_var {
|
|
entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Tmpdir,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
});
|
|
}
|
|
entries.extend(
|
|
writable_roots
|
|
.iter()
|
|
.cloned()
|
|
.map(|path| FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path },
|
|
access: FileSystemAccessMode::Write,
|
|
}),
|
|
);
|
|
|
|
if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
|
|
for protected_path in default_read_only_subpaths_for_writable_root(
|
|
&cwd_root, /*protect_missing_dot_codex*/ true,
|
|
) {
|
|
append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
|
|
}
|
|
}
|
|
for writable_root in writable_roots {
|
|
for protected_path in default_read_only_subpaths_for_writable_root(
|
|
writable_root,
|
|
/*protect_missing_dot_codex*/ false,
|
|
) {
|
|
append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
|
|
}
|
|
}
|
|
|
|
FileSystemSandboxPolicy::restricted(entries)
|
|
}
|
|
|
|
fn append_default_read_only_project_root_subpath_if_no_explicit_rule(
|
|
entries: &mut Vec<FileSystemSandboxEntry>,
|
|
subpath: impl Into<PathBuf>,
|
|
) {
|
|
append_default_read_only_entry_if_no_explicit_rule(
|
|
entries,
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(Some(subpath.into())),
|
|
},
|
|
);
|
|
}
|
|
|
|
fn append_default_read_only_path_if_no_explicit_rule(
|
|
entries: &mut Vec<FileSystemSandboxEntry>,
|
|
path: AbsolutePathBuf,
|
|
) {
|
|
append_default_read_only_entry_if_no_explicit_rule(entries, FileSystemPath::Path { path });
|
|
}
|
|
|
|
fn append_default_read_only_entry_if_no_explicit_rule(
|
|
entries: &mut Vec<FileSystemSandboxEntry>,
|
|
path: FileSystemPath,
|
|
) {
|
|
if entries
|
|
.iter()
|
|
.any(|entry| file_system_paths_share_target(&entry.path, &path))
|
|
{
|
|
return;
|
|
}
|
|
|
|
entries.push(FileSystemSandboxEntry {
|
|
path,
|
|
access: FileSystemAccessMode::Read,
|
|
});
|
|
}
|
|
|
|
fn has_explicit_resolved_path_entry(
|
|
entries: &[ResolvedFileSystemEntry],
|
|
path: &AbsolutePathBuf,
|
|
) -> bool {
|
|
entries.iter().any(|entry| &entry.path == path)
|
|
}
|
|
|
|
fn metadata_path_name(name: &OsStr) -> Option<&'static str> {
|
|
PROTECTED_METADATA_PATH_NAMES
|
|
.iter()
|
|
.copied()
|
|
.find(|metadata_name| name == OsStr::new(metadata_name))
|
|
}
|
|
|
|
fn metadata_child_of_writable_root(
|
|
policy: &FileSystemSandboxPolicy,
|
|
target: &Path,
|
|
cwd: &Path,
|
|
) -> Option<(AbsolutePathBuf, &'static str)> {
|
|
policy
|
|
.resolved_entries_with_cwd(cwd)
|
|
.iter()
|
|
.filter(|entry| entry.access.can_write())
|
|
.filter_map(|entry| {
|
|
let relative_path = target.strip_prefix(entry.path.as_path()).ok()?;
|
|
let first_component = relative_path.components().next()?;
|
|
let metadata_name = metadata_path_name(first_component.as_os_str())?;
|
|
Some((entry.path.join(metadata_name), metadata_name))
|
|
})
|
|
.next()
|
|
}
|
|
|
|
fn protected_metadata_names_for_writable_root(
|
|
policy: &FileSystemSandboxPolicy,
|
|
root: &AbsolutePathBuf,
|
|
raw_writable_roots: &[&AbsolutePathBuf],
|
|
cwd: &Path,
|
|
) -> Vec<String> {
|
|
let mut protected_names = Vec::new();
|
|
for metadata_name in PROTECTED_METADATA_PATH_NAMES {
|
|
let mut metadata_paths = vec![root.join(*metadata_name)];
|
|
metadata_paths.extend(
|
|
raw_writable_roots
|
|
.iter()
|
|
.map(|raw_root| raw_root.join(*metadata_name)),
|
|
);
|
|
|
|
if metadata_paths
|
|
.iter()
|
|
.all(|metadata_path| !policy.can_write_path_with_cwd(metadata_path.as_path(), cwd))
|
|
{
|
|
protected_names.push((*metadata_name).to_string());
|
|
}
|
|
}
|
|
protected_names
|
|
}
|
|
|
|
fn protected_metadata_names_need_direct_runtime_enforcement(
|
|
policy: &FileSystemSandboxPolicy,
|
|
legacy_policy: &SandboxPolicy,
|
|
cwd: &Path,
|
|
) -> bool {
|
|
let legacy_roots = legacy_policy.get_writable_roots_with_cwd(cwd);
|
|
policy
|
|
.get_writable_roots_with_cwd(cwd)
|
|
.into_iter()
|
|
.any(|writable_root| {
|
|
let Some(legacy_root) = legacy_roots
|
|
.iter()
|
|
.find(|candidate| candidate.root == writable_root.root)
|
|
else {
|
|
return !writable_root.protected_metadata_names.is_empty();
|
|
};
|
|
|
|
writable_root
|
|
.protected_metadata_names
|
|
.iter()
|
|
.any(|metadata_name| {
|
|
let metadata_path = writable_root.root.join(metadata_name);
|
|
!legacy_root
|
|
.read_only_subpaths
|
|
.iter()
|
|
.any(|subpath| subpath == &metadata_path)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn has_explicit_write_entry_for_metadata_path(
|
|
policy: &FileSystemSandboxPolicy,
|
|
protected_metadata_path: &AbsolutePathBuf,
|
|
target: &Path,
|
|
cwd: &Path,
|
|
) -> bool {
|
|
policy.resolved_entries_with_cwd(cwd).iter().any(|entry| {
|
|
entry.access.can_write()
|
|
&& target.starts_with(entry.path.as_path())
|
|
&& entry
|
|
.path
|
|
.as_path()
|
|
.starts_with(protected_metadata_path.as_path())
|
|
})
|
|
}
|
|
|
|
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
|
|
path.as_path().is_file()
|
|
&& path.as_path().file_name() == Some(OsStr::new(PROTECTED_METADATA_GIT_PATH_NAME))
|
|
}
|
|
|
|
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
|
|
let contents = match std::fs::read_to_string(dot_git.as_path()) {
|
|
Ok(contents) => contents,
|
|
Err(err) => {
|
|
error!(
|
|
"Failed to read {path} for gitdir pointer: {err}",
|
|
path = dot_git.as_path().display()
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let trimmed = contents.trim();
|
|
let (_, gitdir_raw) = match trimmed.split_once(':') {
|
|
Some((prefix, gitdir_raw)) if prefix.trim() == "gitdir" => (prefix, gitdir_raw),
|
|
Some(_) => {
|
|
error!(
|
|
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
|
|
path = dot_git.as_path().display()
|
|
);
|
|
return None;
|
|
}
|
|
None => {
|
|
error!(
|
|
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
|
|
path = dot_git.as_path().display()
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
let gitdir_raw = gitdir_raw.trim();
|
|
if gitdir_raw.is_empty() {
|
|
error!(
|
|
"Expected {path} to contain a gitdir pointer, but it was empty.",
|
|
path = dot_git.as_path().display()
|
|
);
|
|
return None;
|
|
}
|
|
let base = match dot_git.as_path().parent() {
|
|
Some(base) => base,
|
|
None => {
|
|
error!(
|
|
"Unable to resolve parent directory for {path}.",
|
|
path = dot_git.as_path().display()
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base);
|
|
if !gitdir_path.as_path().exists() {
|
|
error!(
|
|
"Resolved gitdir path {path} does not exist.",
|
|
path = gitdir_path.as_path().display()
|
|
);
|
|
return None;
|
|
}
|
|
Some(gitdir_path)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
#[cfg(unix)]
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use tempfile::TempDir;
|
|
|
|
#[cfg(unix)]
|
|
const SYMLINKED_TMPDIR_TEST_ENV: &str = "CODEX_PROTOCOL_TEST_SYMLINKED_TMPDIR";
|
|
|
|
#[cfg(unix)]
|
|
fn symlink_dir(original: &Path, link: &Path) -> std::io::Result<()> {
|
|
std::os::unix::fs::symlink(original, link)
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_special_paths_are_ignored_by_legacy_bridge() -> std::io::Result<()> {
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::unknown(
|
|
":future_special_path",
|
|
/*subpath*/ None,
|
|
),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
]);
|
|
|
|
let sandbox_policy = policy.to_legacy_sandbox_policy(
|
|
NetworkSandboxPolicy::Restricted,
|
|
Path::new("/tmp/workspace"),
|
|
)?;
|
|
|
|
assert_eq!(
|
|
sandbox_policy,
|
|
SandboxPolicy::ReadOnly {
|
|
network_access: false,
|
|
}
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn writable_roots_proactively_protect_missing_dot_codex() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let expected_root = AbsolutePathBuf::from_absolute_path(
|
|
cwd.path().canonicalize().expect("canonicalize cwd"),
|
|
)
|
|
.expect("absolute canonical root");
|
|
let expected_dot_codex = expected_root.join(".codex");
|
|
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
}]);
|
|
|
|
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
|
assert_eq!(writable_roots.len(), 1);
|
|
assert_eq!(writable_roots[0].root, expected_root);
|
|
assert!(
|
|
writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&expected_dot_codex)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_workspace_write_projection_preserves_symbolic_project_root() {
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: Vec::new(),
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
|
|
assert_eq!(
|
|
FileSystemSandboxPolicy::from(&policy),
|
|
FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(Some(".git".into())),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(Some(".agents".into())),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(Some(".codex".into())),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_current_working_directory_special_path_deserializes_as_project_roots()
|
|
-> serde_json::Result<()> {
|
|
let value = serde_json::json!({
|
|
"kind": "current_working_directory",
|
|
});
|
|
|
|
let special_path = serde_json::from_value::<FileSystemSpecialPath>(value)?;
|
|
assert_eq!(
|
|
special_path,
|
|
FileSystemSpecialPath::project_roots(/*subpath*/ None)
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(&special_path)?,
|
|
serde_json::json!({
|
|
"kind": "project_roots",
|
|
})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn writable_roots_skip_default_dot_codex_when_explicit_user_rule_exists() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let expected_root = AbsolutePathBuf::from_absolute_path(
|
|
cwd.path().canonicalize().expect("canonicalize cwd"),
|
|
)
|
|
.expect("absolute canonical root");
|
|
let explicit_dot_codex = expected_root.join(".codex");
|
|
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: explicit_dot_codex.clone(),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
]);
|
|
|
|
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
|
let workspace_root = writable_roots
|
|
.iter()
|
|
.find(|root| root.root == expected_root)
|
|
.expect("workspace writable root");
|
|
assert!(
|
|
!workspace_root
|
|
.protected_metadata_names
|
|
.contains(&".codex".to_string()),
|
|
"explicit .codex rule should remove the metadata-name protection"
|
|
);
|
|
assert!(
|
|
!workspace_root
|
|
.read_only_subpaths
|
|
.contains(&explicit_dot_codex),
|
|
"explicit .codex rule should win over the default protected carveout"
|
|
);
|
|
assert!(
|
|
policy.can_write_path_with_cwd(
|
|
explicit_dot_codex.join("config.toml").as_path(),
|
|
cwd.path()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn filesystem_policy_blocks_protected_metadata_path_writes_by_default() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let dot_git_config = cwd.path().join(".git").join("config");
|
|
let dot_agents_config = cwd.path().join(".agents").join("config");
|
|
let dot_codex_config = cwd.path().join(".codex").join("config.toml");
|
|
let root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
|
|
let file_system_policy =
|
|
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: root },
|
|
access: FileSystemAccessMode::Write,
|
|
}]);
|
|
|
|
assert!(!file_system_policy.can_write_path_with_cwd(&dot_git_config, cwd.path()));
|
|
assert!(!file_system_policy.can_write_path_with_cwd(&dot_agents_config, cwd.path()));
|
|
assert!(!file_system_policy.can_write_path_with_cwd(&dot_codex_config, cwd.path()));
|
|
|
|
let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd.path());
|
|
assert_eq!(writable_roots.len(), 1);
|
|
assert_eq!(
|
|
writable_roots[0].protected_metadata_names,
|
|
vec![
|
|
".git".to_string(),
|
|
".agents".to_string(),
|
|
".codex".to_string(),
|
|
]
|
|
);
|
|
assert!(!writable_roots[0].is_path_writable(&dot_git_config));
|
|
assert!(!writable_roots[0].is_path_writable(&dot_agents_config));
|
|
assert!(!writable_roots[0].is_path_writable(&dot_codex_config));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_workspace_write_projection_accepts_relative_cwd() {
|
|
let relative_cwd = Path::new("workspace");
|
|
let expected_root = AbsolutePathBuf::from_absolute_path(
|
|
std::env::current_dir()
|
|
.expect("current dir")
|
|
.join(relative_cwd),
|
|
)
|
|
.expect("absolute root");
|
|
let policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
|
|
let file_system_policy =
|
|
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, relative_cwd);
|
|
|
|
let mut expected_entries = vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
];
|
|
expected_entries.extend(PROTECTED_METADATA_PATH_NAMES.iter().map(|name| {
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(Some((*name).into())),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
}
|
|
}));
|
|
expected_entries.extend(
|
|
default_read_only_subpaths_for_writable_root(
|
|
&expected_root,
|
|
/*protect_missing_dot_codex*/ true,
|
|
)
|
|
.into_iter()
|
|
.map(|path| FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path },
|
|
access: FileSystemAccessMode::Read,
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
file_system_policy,
|
|
FileSystemSandboxPolicy::restricted(expected_entries)
|
|
);
|
|
assert_eq!(
|
|
forbidden_agent_metadata_write(
|
|
Path::new(".git/config"),
|
|
relative_cwd,
|
|
&file_system_policy,
|
|
),
|
|
Some(".git")
|
|
);
|
|
assert!(
|
|
!file_system_policy
|
|
.can_write_path_with_cwd(Path::new(".codex/config.toml"), relative_cwd,)
|
|
);
|
|
assert!(
|
|
!file_system_policy.can_write_path_with_cwd(
|
|
Path::new(".agents/skills/example/SKILL.md"),
|
|
relative_cwd,
|
|
)
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn effective_runtime_roots_preserve_symlinked_paths() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let real_root = cwd.path().join("real");
|
|
let link_root = cwd.path().join("link");
|
|
let blocked = real_root.join("blocked");
|
|
let codex_dir = real_root.join(".codex");
|
|
|
|
fs::create_dir_all(&blocked).expect("create blocked");
|
|
fs::create_dir_all(&codex_dir).expect("create .codex");
|
|
symlink_dir(&real_root, &link_root).expect("create symlinked root");
|
|
|
|
let link_root =
|
|
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
|
|
let link_blocked = link_root.join("blocked");
|
|
let expected_root = link_root.clone();
|
|
let expected_blocked = link_blocked.clone();
|
|
let expected_codex = link_root.join(".codex");
|
|
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: link_root },
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: link_blocked },
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
policy.get_unreadable_roots_with_cwd(cwd.path()),
|
|
vec![expected_blocked.clone()]
|
|
);
|
|
|
|
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
|
assert_eq!(writable_roots.len(), 1);
|
|
assert_eq!(writable_roots[0].root, expected_root);
|
|
assert!(
|
|
writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&expected_blocked)
|
|
);
|
|
assert!(
|
|
writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&expected_codex)
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn project_roots_special_path_preserves_symlinked_root() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let real_root = cwd.path().join("real");
|
|
let link_root = cwd.path().join("link");
|
|
let blocked = real_root.join("blocked");
|
|
let agents_dir = real_root.join(".agents");
|
|
let codex_dir = real_root.join(".codex");
|
|
|
|
fs::create_dir_all(&blocked).expect("create blocked");
|
|
fs::create_dir_all(&agents_dir).expect("create .agents");
|
|
fs::create_dir_all(&codex_dir).expect("create .codex");
|
|
symlink_dir(&real_root, &link_root).expect("create symlinked cwd");
|
|
|
|
let link_blocked =
|
|
AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked");
|
|
let expected_root =
|
|
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
|
|
let expected_blocked = link_blocked.clone();
|
|
let expected_agents = expected_root.join(".agents");
|
|
let expected_codex = expected_root.join(".codex");
|
|
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Minimal,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: link_blocked },
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
policy.get_readable_roots_with_cwd(&link_root),
|
|
vec![expected_root.clone()]
|
|
);
|
|
assert_eq!(
|
|
policy.get_unreadable_roots_with_cwd(&link_root),
|
|
vec![expected_blocked.clone()]
|
|
);
|
|
|
|
let writable_roots = policy.get_writable_roots_with_cwd(&link_root);
|
|
assert_eq!(writable_roots.len(), 1);
|
|
assert_eq!(writable_roots[0].root, expected_root);
|
|
assert!(
|
|
writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&expected_blocked)
|
|
);
|
|
assert!(
|
|
writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&expected_agents)
|
|
);
|
|
assert!(
|
|
writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&expected_codex)
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn writable_roots_preserve_symlinked_protected_subpaths() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let root = cwd.path().join("root");
|
|
let decoy = root.join("decoy-codex");
|
|
let dot_codex = root.join(".codex");
|
|
fs::create_dir_all(&decoy).expect("create decoy");
|
|
symlink_dir(&decoy, &dot_codex).expect("create .codex symlink");
|
|
|
|
let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
|
|
let expected_dot_codex = AbsolutePathBuf::from_absolute_path(
|
|
root.as_path()
|
|
.canonicalize()
|
|
.expect("canonicalize root")
|
|
.join(".codex"),
|
|
)
|
|
.expect("absolute .codex symlink");
|
|
let unexpected_decoy =
|
|
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
|
|
.expect("absolute canonical decoy");
|
|
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: root },
|
|
access: FileSystemAccessMode::Write,
|
|
}]);
|
|
|
|
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
|
assert_eq!(writable_roots.len(), 1);
|
|
assert_eq!(
|
|
writable_roots[0].read_only_subpaths,
|
|
vec![expected_dot_codex]
|
|
);
|
|
assert!(
|
|
!writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&unexpected_decoy)
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn writable_roots_preserve_explicit_symlinked_carveouts_under_symlinked_roots() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let real_root = cwd.path().join("real");
|
|
let link_root = cwd.path().join("link");
|
|
let decoy = real_root.join("decoy-private");
|
|
let linked_private = real_root.join("linked-private");
|
|
fs::create_dir_all(&decoy).expect("create decoy");
|
|
symlink_dir(&real_root, &link_root).expect("create symlinked root");
|
|
symlink_dir(&decoy, &linked_private).expect("create linked-private symlink");
|
|
|
|
let link_root =
|
|
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
|
|
let link_private = link_root.join("linked-private");
|
|
let expected_root = link_root.clone();
|
|
let expected_linked_private = link_private.clone();
|
|
let unexpected_decoy =
|
|
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
|
|
.expect("absolute canonical decoy");
|
|
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: link_root },
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: link_private },
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
]);
|
|
|
|
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
|
assert_eq!(writable_roots.len(), 1);
|
|
assert_eq!(writable_roots[0].root, expected_root);
|
|
assert_eq!(
|
|
writable_roots[0].read_only_subpaths,
|
|
vec![expected_linked_private]
|
|
);
|
|
assert!(
|
|
!writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&unexpected_decoy)
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn writable_roots_preserve_explicit_symlinked_carveouts_that_escape_root() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let real_root = cwd.path().join("real");
|
|
let link_root = cwd.path().join("link");
|
|
let decoy = cwd.path().join("outside-private");
|
|
let linked_private = real_root.join("linked-private");
|
|
fs::create_dir_all(&decoy).expect("create decoy");
|
|
fs::create_dir_all(&real_root).expect("create real root");
|
|
symlink_dir(&real_root, &link_root).expect("create symlinked root");
|
|
symlink_dir(&decoy, &linked_private).expect("create linked-private symlink");
|
|
|
|
let link_root =
|
|
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
|
|
let link_private = link_root.join("linked-private");
|
|
let expected_root = link_root.clone();
|
|
let expected_linked_private = link_private.clone();
|
|
let unexpected_decoy =
|
|
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
|
|
.expect("absolute canonical decoy");
|
|
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: link_root },
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: link_private },
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
]);
|
|
|
|
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
|
assert_eq!(writable_roots.len(), 1);
|
|
assert_eq!(writable_roots[0].root, expected_root);
|
|
assert_eq!(
|
|
writable_roots[0].read_only_subpaths,
|
|
vec![expected_linked_private]
|
|
);
|
|
assert!(
|
|
!writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&unexpected_decoy)
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn writable_roots_preserve_explicit_symlinked_carveouts_that_alias_root() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let root = cwd.path().join("root");
|
|
let alias = root.join("alias-root");
|
|
fs::create_dir_all(&root).expect("create root");
|
|
symlink_dir(&root, &alias).expect("create alias symlink");
|
|
|
|
let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
|
|
let alias = root.join("alias-root");
|
|
let expected_root = AbsolutePathBuf::from_absolute_path(
|
|
root.as_path().canonicalize().expect("canonicalize root"),
|
|
)
|
|
.expect("absolute canonical root");
|
|
let expected_alias = expected_root.join("alias-root");
|
|
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: root },
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: alias },
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
]);
|
|
|
|
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
|
assert_eq!(writable_roots.len(), 1);
|
|
assert_eq!(writable_roots[0].root, expected_root);
|
|
assert_eq!(writable_roots[0].read_only_subpaths, vec![expected_alias]);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn tmpdir_special_path_preserves_symlinked_tmpdir() {
|
|
if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() {
|
|
let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
|
|
.env(SYMLINKED_TMPDIR_TEST_ENV, "1")
|
|
.arg("--exact")
|
|
.arg("permissions::tests::tmpdir_special_path_preserves_symlinked_tmpdir")
|
|
.output()
|
|
.expect("run tmpdir subprocess test");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"tmpdir subprocess test failed\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
return;
|
|
}
|
|
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let real_tmpdir = cwd.path().join("real-tmpdir");
|
|
let link_tmpdir = cwd.path().join("link-tmpdir");
|
|
let blocked = real_tmpdir.join("blocked");
|
|
let codex_dir = real_tmpdir.join(".codex");
|
|
|
|
fs::create_dir_all(&blocked).expect("create blocked");
|
|
fs::create_dir_all(&codex_dir).expect("create .codex");
|
|
symlink_dir(&real_tmpdir, &link_tmpdir).expect("create symlinked tmpdir");
|
|
|
|
let link_blocked =
|
|
AbsolutePathBuf::from_absolute_path(link_tmpdir.join("blocked")).expect("link blocked");
|
|
let expected_root =
|
|
AbsolutePathBuf::from_absolute_path(&link_tmpdir).expect("absolute symlinked tmpdir");
|
|
let expected_blocked = link_blocked.clone();
|
|
let expected_codex = expected_root.join(".codex");
|
|
|
|
unsafe {
|
|
std::env::set_var("TMPDIR", &link_tmpdir);
|
|
}
|
|
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Tmpdir,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: link_blocked },
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
policy.get_unreadable_roots_with_cwd(cwd.path()),
|
|
vec![expected_blocked.clone()]
|
|
);
|
|
|
|
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
|
|
assert_eq!(writable_roots.len(), 1);
|
|
assert_eq!(writable_roots[0].root, expected_root);
|
|
assert!(
|
|
writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&expected_blocked)
|
|
);
|
|
assert!(
|
|
writable_roots[0]
|
|
.read_only_subpaths
|
|
.contains(&expected_codex)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_access_with_cwd_uses_most_specific_entry() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
|
|
let docs_private = AbsolutePathBuf::resolve_path_against_base("docs/private", cwd.path());
|
|
let docs_private_public =
|
|
AbsolutePathBuf::resolve_path_against_base("docs/private/public", cwd.path());
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: docs.clone() },
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: docs_private.clone(),
|
|
},
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: docs_private_public.clone(),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
policy.resolve_access_with_cwd(cwd.path(), cwd.path()),
|
|
FileSystemAccessMode::Write
|
|
);
|
|
assert_eq!(
|
|
policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
|
|
FileSystemAccessMode::Read
|
|
);
|
|
assert_eq!(
|
|
policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()),
|
|
FileSystemAccessMode::None
|
|
);
|
|
assert_eq!(
|
|
policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()),
|
|
FileSystemAccessMode::Write
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn split_only_nested_carveouts_need_direct_runtime_enforcement() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: docs },
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
]);
|
|
|
|
assert!(
|
|
policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
|
|
);
|
|
|
|
let legacy_workspace_write = legacy_runtime_file_system_policy_for_cwd(
|
|
&SandboxPolicy::new_workspace_write_policy(),
|
|
cwd.path(),
|
|
);
|
|
assert!(
|
|
legacy_workspace_write
|
|
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),),
|
|
"metadata-name protections must stay in the direct enforcement path even when legacy concrete read-only paths match"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_projection_runtime_enforcement_ignores_entry_order() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let legacy_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: Vec::new(),
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let legacy_order = legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path());
|
|
let mut reordered_entries = legacy_order.entries.clone();
|
|
reordered_entries.reverse();
|
|
let reordered = FileSystemSandboxPolicy::restricted(reordered_entries);
|
|
|
|
assert!(
|
|
legacy_order.is_semantically_equivalent_to(&reordered, cwd.path()),
|
|
"entry order should not affect filesystem semantics"
|
|
);
|
|
assert_eq!(
|
|
legacy_order
|
|
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
|
|
reordered
|
|
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
|
|
"entry order should not affect direct-enforcement classification"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_symbolic_metadata_carveouts_need_direct_runtime_enforcement() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let legacy_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: Vec::new(),
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
|
|
let profile_projection =
|
|
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&legacy_policy, cwd.path());
|
|
assert!(
|
|
profile_projection
|
|
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
|
|
"symbolic .git/.agents carveouts protect missing paths that legacy sandboxes cannot represent"
|
|
);
|
|
|
|
let legacy_runtime_projection =
|
|
legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path());
|
|
assert!(
|
|
legacy_runtime_projection
|
|
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
|
|
"metadata-name protections are outside the legacy SandboxPolicy writable-root contract"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn root_write_with_read_only_child_is_not_full_disk_write() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: docs.clone() },
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
]);
|
|
|
|
assert!(!policy.has_full_disk_write_access());
|
|
assert_eq!(
|
|
policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
|
|
FileSystemAccessMode::Read
|
|
);
|
|
assert!(
|
|
policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
|
|
);
|
|
assert!(
|
|
policy
|
|
.to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd.path())
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn root_deny_does_not_materialize_as_unreadable_root() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
|
|
let expected_docs = AbsolutePathBuf::from_absolute_path(
|
|
canonicalize_preserving_symlinks(cwd.path())
|
|
.expect("canonicalize cwd")
|
|
.join("docs"),
|
|
)
|
|
.expect("canonical docs");
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: docs.clone() },
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
|
|
FileSystemAccessMode::Read
|
|
);
|
|
assert_eq!(
|
|
policy.get_readable_roots_with_cwd(cwd.path()),
|
|
vec![expected_docs]
|
|
);
|
|
assert!(policy.get_unreadable_roots_with_cwd(cwd.path()).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_root_deny_prevents_full_disk_write_access() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let root = AbsolutePathBuf::from_absolute_path(cwd.path())
|
|
.map(|cwd| absolute_root_path_for_cwd(&cwd))
|
|
.expect("resolve filesystem root");
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
]);
|
|
|
|
assert!(!policy.has_full_disk_write_access());
|
|
assert_eq!(
|
|
policy.resolve_access_with_cwd(root.as_path(), cwd.path()),
|
|
FileSystemAccessMode::None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn same_specificity_write_override_keeps_full_disk_write_access() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: docs.clone() },
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: docs.clone() },
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
]);
|
|
|
|
assert!(policy.has_full_disk_write_access());
|
|
assert_eq!(
|
|
policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
|
|
FileSystemAccessMode::Write
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn with_additional_readable_roots_skips_existing_effective_access() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
}]);
|
|
|
|
let actual = policy
|
|
.clone()
|
|
.with_additional_readable_roots(cwd.path(), std::slice::from_ref(&cwd_root));
|
|
|
|
assert_eq!(actual, policy);
|
|
}
|
|
|
|
#[test]
|
|
fn with_additional_writable_roots_skips_existing_effective_access() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
}]);
|
|
|
|
let actual = policy
|
|
.clone()
|
|
.with_additional_writable_roots(cwd.path(), std::slice::from_ref(&cwd_root));
|
|
|
|
assert_eq!(actual, policy);
|
|
}
|
|
|
|
#[test]
|
|
fn with_additional_writable_roots_adds_new_root() {
|
|
let temp_dir = TempDir::new().expect("tempdir");
|
|
let cwd = temp_dir.path().join("workspace");
|
|
let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra"))
|
|
.expect("resolve extra root");
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
}]);
|
|
|
|
let actual = policy.with_additional_writable_roots(&cwd, std::slice::from_ref(&extra));
|
|
|
|
assert_eq!(
|
|
actual,
|
|
FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: extra },
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn with_additional_legacy_workspace_writable_roots_protects_metadata() {
|
|
let temp_dir = TempDir::new().expect("tempdir");
|
|
let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra"))
|
|
.expect("resolve extra root");
|
|
std::fs::create_dir_all(extra.join(".git")).expect("create .git dir");
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
}]);
|
|
|
|
let actual =
|
|
policy.with_additional_legacy_workspace_writable_roots(std::slice::from_ref(&extra));
|
|
|
|
assert_eq!(
|
|
actual,
|
|
FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: extra.clone()
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: extra.join(".git")
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_system_access_mode_orders_by_conflict_precedence() {
|
|
assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
|
|
assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_bridge_preserves_explicit_deny_entries() {
|
|
let denied = AbsolutePathBuf::try_from("/tmp/private").expect("absolute path");
|
|
let existing = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: denied.clone(),
|
|
},
|
|
access: FileSystemAccessMode::None,
|
|
}]);
|
|
|
|
let rebuilt = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
|
|
&SandboxPolicy::new_workspace_write_policy(),
|
|
Path::new("/tmp/workspace"),
|
|
&existing,
|
|
);
|
|
|
|
assert!(
|
|
rebuilt.entries.iter().any(|entry| {
|
|
entry.path
|
|
== FileSystemPath::Path {
|
|
path: denied.clone(),
|
|
}
|
|
&& entry.access == FileSystemAccessMode::None
|
|
}),
|
|
"expected explicit deny entry to be preserved"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn preserving_deny_entries_keeps_unrestricted_policy_enforceable() {
|
|
let deny_entry = unreadable_glob_entry("/tmp/project/**/*.env".to_string());
|
|
let mut existing = FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]);
|
|
existing.glob_scan_max_depth = Some(2);
|
|
let mut replacement = FileSystemSandboxPolicy::unrestricted();
|
|
|
|
replacement.preserve_deny_read_restrictions_from(&existing);
|
|
|
|
let mut expected = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
deny_entry,
|
|
]);
|
|
expected.glob_scan_max_depth = Some(2);
|
|
assert_eq!(replacement, expected);
|
|
}
|
|
|
|
fn deny_policy(path: &Path) -> FileSystemSandboxPolicy {
|
|
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: AbsolutePathBuf::try_from(path).expect("absolute deny path"),
|
|
},
|
|
access: FileSystemAccessMode::None,
|
|
}])
|
|
}
|
|
|
|
fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry {
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern { pattern },
|
|
access: FileSystemAccessMode::None,
|
|
}
|
|
}
|
|
|
|
fn default_policy_with_unreadable_glob(pattern: String) -> FileSystemSandboxPolicy {
|
|
let mut policy = FileSystemSandboxPolicy::default();
|
|
policy.entries.push(unreadable_glob_entry(pattern));
|
|
policy
|
|
}
|
|
|
|
fn is_read_denied(
|
|
path: &Path,
|
|
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
|
cwd: &Path,
|
|
) -> bool {
|
|
ReadDenyMatcher::new(file_system_sandbox_policy, cwd)
|
|
.is_some_and(|matcher| matcher.is_read_denied(path))
|
|
}
|
|
|
|
#[test]
|
|
fn exact_path_and_descendants_are_denied() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let denied_dir = temp.path().join("denied");
|
|
let nested = denied_dir.join("nested.txt");
|
|
std::fs::create_dir_all(&denied_dir).expect("create denied dir");
|
|
std::fs::write(&nested, "secret").expect("write secret");
|
|
|
|
let policy = deny_policy(&denied_dir);
|
|
assert!(is_read_denied(&denied_dir, &policy, temp.path()));
|
|
assert!(is_read_denied(&nested, &policy, temp.path()));
|
|
assert!(!is_read_denied(
|
|
&temp.path().join("other.txt"),
|
|
&policy,
|
|
temp.path()
|
|
));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn canonical_target_matches_denied_symlink_alias() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let real_dir = temp.path().join("real");
|
|
let alias_dir = temp.path().join("alias");
|
|
std::fs::create_dir_all(&real_dir).expect("create real dir");
|
|
symlink_dir(&real_dir, &alias_dir).expect("symlink alias");
|
|
|
|
let secret = real_dir.join("secret.txt");
|
|
std::fs::write(&secret, "secret").expect("write secret");
|
|
let alias_secret = alias_dir.join("secret.txt");
|
|
|
|
let policy = deny_policy(&real_dir);
|
|
assert!(is_read_denied(&alias_secret, &policy, temp.path()));
|
|
}
|
|
|
|
#[test]
|
|
fn literal_patterns_and_globs_are_denied() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let literal = temp.path().join("private");
|
|
let other = temp.path().join("notes.txt");
|
|
std::fs::create_dir_all(&literal).expect("create literal dir");
|
|
std::fs::write(&other, "notes").expect("write notes");
|
|
|
|
let mut policy = deny_policy(&literal);
|
|
policy.entries.push(unreadable_glob_entry(format!(
|
|
"{}/**/*.txt",
|
|
temp.path().display()
|
|
)));
|
|
|
|
assert!(is_read_denied(&literal, &policy, temp.path()));
|
|
assert!(is_read_denied(&other, &policy, temp.path()));
|
|
}
|
|
|
|
#[test]
|
|
fn glob_patterns_deny_matching_paths() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let denied = temp.path().join("private").join("secret1.txt");
|
|
std::fs::create_dir_all(denied.parent().expect("parent")).expect("create parent");
|
|
std::fs::write(&denied, "secret").expect("write secret");
|
|
|
|
let policy = default_policy_with_unreadable_glob(format!(
|
|
"{}/private/secret?.txt",
|
|
temp.path().display()
|
|
));
|
|
|
|
assert!(is_read_denied(&denied, &policy, temp.path()));
|
|
}
|
|
|
|
#[test]
|
|
fn glob_patterns_do_not_cross_path_separators() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let matching = temp.path().join("app").join("file42.txt");
|
|
let nested = temp.path().join("app").join("nested").join("file42.txt");
|
|
let short = temp.path().join("app").join("file4.txt");
|
|
let letters = temp.path().join("app").join("fileab.txt");
|
|
std::fs::create_dir_all(nested.parent().expect("parent")).expect("create parent");
|
|
std::fs::write(&matching, "secret").expect("write matching");
|
|
std::fs::write(&nested, "secret").expect("write nested");
|
|
std::fs::write(&short, "secret").expect("write short");
|
|
std::fs::write(&letters, "secret").expect("write letters");
|
|
|
|
let policy = default_policy_with_unreadable_glob(format!(
|
|
"{}/*/file[0-9]?.txt",
|
|
temp.path().display()
|
|
));
|
|
|
|
assert!(is_read_denied(&matching, &policy, temp.path()));
|
|
assert!(!is_read_denied(&nested, &policy, temp.path()));
|
|
assert!(!is_read_denied(&short, &policy, temp.path()));
|
|
assert!(!is_read_denied(&letters, &policy, temp.path()));
|
|
}
|
|
|
|
#[test]
|
|
fn globstar_patterns_deny_root_and_nested_matches() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let root_env = temp.path().join(".env");
|
|
let nested_env = temp.path().join("app").join(".env");
|
|
let other = temp.path().join("app").join("notes.txt");
|
|
std::fs::create_dir_all(nested_env.parent().expect("parent")).expect("create parent");
|
|
std::fs::write(&root_env, "secret").expect("write root env");
|
|
std::fs::write(&nested_env, "secret").expect("write nested env");
|
|
std::fs::write(&other, "notes").expect("write notes");
|
|
|
|
let policy =
|
|
default_policy_with_unreadable_glob(format!("{}/**/*.env", temp.path().display()));
|
|
|
|
assert!(is_read_denied(&root_env, &policy, temp.path()));
|
|
assert!(is_read_denied(&nested_env, &policy, temp.path()));
|
|
assert!(!is_read_denied(&other, &policy, temp.path()));
|
|
}
|
|
|
|
#[test]
|
|
fn unclosed_character_classes_match_literal_brackets() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let bracket_file = temp.path().join("[");
|
|
let other = temp.path().join("notes.txt");
|
|
std::fs::write(&bracket_file, "secret").expect("write bracket file");
|
|
std::fs::write(&other, "notes").expect("write notes");
|
|
let policy = default_policy_with_unreadable_glob(format!("{}/[", temp.path().display()));
|
|
|
|
assert!(is_read_denied(&bracket_file, &policy, temp.path()));
|
|
assert!(!is_read_denied(&other, &policy, temp.path()));
|
|
}
|
|
}
|