Files
codex/codex-rs/protocol/src/permissions.rs
viyatb-oai b114781495 fix(permissions): fix symlinked writable roots in sandbox permissions (#15981)
## Summary
- preserve logical symlink paths during permission normalization and
config cwd handling
- bind real targets for symlinked readable/writable roots in bwrap and
remap carveouts and unreadable roots there
- add regressions for symlinked carveouts and nested symlink escape
masking

## Root cause
Permission normalization canonicalized symlinked writable roots and cwd
to their real targets too early. That drifted policy checks away from
the logical paths the sandboxed process can actually address, while
bwrap still needed the real targets for mounts. The mismatch caused
shell and apply_patch failures on symlinked writable roots.

## Impact
Fixes #15781.

Also fixes #17079:
- #17079 is the protected symlinked carveout side: bwrap now binds the
real symlinked writable-root target and remaps carveouts before masking.

Related to #15157:
- #15157 is the broader permission-check side of this path-identity
problem. This PR addresses the shared logical-vs-canonical normalization
issue, but the reported Darwin prompt behavior should be validated
separately before auto-closing it.

This should also fix #14672, #14694, #14715, and #15725:
- #14672, #14694, and #14715 are the same Linux
symlinked-writable-root/bwrap family as #15781.
- #15725 is the protected symlinked workspace path variant; the PR
preserves the protected logical path in policy space while bwrap applies
read-only or unreadable treatment to the resolved target so
file-vs-directory bind mismatches do not abort sandbox setup.

## Notes
- Added Linux-only regressions for symlinked writable ancestors and
protected symlinked directory targets, including nested symlink escape
masking without rebinding the escape target writable.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-04-10 17:00:58 -07:00

2065 lines
77 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 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::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::protocol::WritableRoot;
#[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,
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, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[ts(tag = "kind")]
pub enum FileSystemSpecialPath {
Root,
Minimal,
CurrentWorkingDirectory,
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, 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 = "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>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum FileSystemPath {
Path { path: AbsolutePathBuf },
Special { value: FileSystemSpecialPath },
}
impl Default for FileSystemSandboxPolicy {
fn default() -> Self {
Self {
kind: FileSystemSandboxKind::Restricted,
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}],
}
}
}
impl FileSystemSandboxPolicy {
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)
)
})
}
fn has_explicit_deny_entries(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::None)
}
/// 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::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)
})
}
pub fn unrestricted() -> Self {
Self {
kind: FileSystemSandboxKind::Unrestricted,
entries: Vec::new(),
}
}
pub fn external_sandbox() -> Self {
Self {
kind: FileSystemSandboxKind::ExternalSandbox,
entries: Vec::new(),
}
}
pub fn restricted(entries: Vec<FileSystemSandboxEntry>) -> Self {
Self {
kind: FileSystemSandboxKind::Restricted,
entries,
}
}
/// Converts a legacy sandbox policy into an equivalent filesystem policy
/// 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(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
let mut file_system_policy = Self::from(sandbox_policy);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy {
let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
file_system_policy.entries.retain(|entry| {
if entry.access != FileSystemAccessMode::Read {
return true;
}
match &entry.path {
FileSystemPath::Path { path } => !legacy_writable_roots
.iter()
.any(|root| root.is_path_writable(path.as_path())),
FileSystemPath::Special { .. } => true,
}
});
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_explicit_deny_entries()
}
}
}
/// 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 {
self.resolve_access_with_cwd(path, cwd).can_write()
}
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
}
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;
};
self.semantic_signature(cwd)
!= FileSystemSandboxPolicy::from_legacy_sandbox_policy(&legacy_policy, cwd)
.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 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 {
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,
)
}
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 mut include_platform_defaults = false;
let mut has_full_disk_read_access = false;
let mut has_full_disk_write_access = false;
let mut workspace_root_writable = false;
let mut writable_roots = Vec::new();
let mut readable_roots = Vec::new();
let mut tmpdir_writable = false;
let mut slash_tmp_writable = false;
for entry in &self.entries {
match &entry.path {
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());
}
} else if entry.access.can_read() {
readable_roots.push(path.clone());
}
}
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => match entry.access {
FileSystemAccessMode::None => {}
FileSystemAccessMode::Read => has_full_disk_read_access = true,
FileSystemAccessMode::Write => {
has_full_disk_read_access = true;
has_full_disk_write_access = true;
}
},
FileSystemSpecialPath::Minimal => {
if entry.access.can_read() {
include_platform_defaults = true;
}
}
FileSystemSpecialPath::CurrentWorkingDirectory => {
if entry.access.can_write() {
workspace_root_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
}
}
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())
{
if entry.access.can_write() {
writable_roots.push(path);
} else if entry.access.can_read() {
readable_roots.push(path);
}
}
}
FileSystemSpecialPath::Tmpdir => {
if entry.access.can_write() {
tmpdir_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
}
}
FileSystemSpecialPath::SlashTmp => {
if entry.access.can_write() {
slash_tmp_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
}
}
FileSystemSpecialPath::Unknown { .. } => {}
},
}
}
if has_full_disk_write_access {
return Ok(if network_policy.is_enabled() {
SandboxPolicy::DangerFullAccess
} else {
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}
});
}
let read_only_access = if has_full_disk_read_access {
ReadOnlyAccess::FullAccess
} else {
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots: dedup_absolute_paths(
readable_roots,
/*normalize_effective_paths*/ false,
),
}
};
if workspace_root_writable {
SandboxPolicy::WorkspaceWrite {
writable_roots: dedup_absolute_paths(
writable_roots,
/*normalize_effective_paths*/ false,
),
read_only_access,
network_access: network_policy.is_enabled(),
exclude_tmpdir_env_var: !tmpdir_writable,
exclude_slash_tmp: !slash_tmp_writable,
}
} else if !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 {
access: read_only_access,
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: self.get_readable_roots_with_cwd(cwd),
writable_roots: self.get_writable_roots_with_cwd(cwd),
unreadable_roots: self.get_unreadable_roots_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 { access, .. } => {
let mut entries = Vec::new();
match access {
ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}),
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots,
} => {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Read,
});
if *include_platform_defaults {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
});
}
entries.extend(readable_roots.iter().cloned().map(|path| {
FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
}
}));
}
}
FileSystemSandboxPolicy::restricted(entries)
}
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
} => {
let mut entries = Vec::new();
match read_only_access {
ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}),
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots,
} => {
if *include_platform_defaults {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
});
}
entries.extend(readable_roots.iter().cloned().map(|path| {
FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
}
}));
}
}
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
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,
}),
);
FileSystemSandboxPolicy::restricted(entries)
}
}
}
}
fn resolve_file_system_path(
path: &FileSystemPath,
cwd: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
match path {
FileSystemPath::Path { path } => Some(path.clone()),
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::resolve_path_against_base(path, cwd))
}
}
/// 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)
}
}
}
/// 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::CurrentWorkingDirectory,
FileSystemSpecialPath::CurrentWorkingDirectory,
)
| (FileSystemSpecialPath::Tmpdir, FileSystemSpecialPath::Tmpdir)
| (FileSystemSpecialPath::SlashTmp, FileSystemSpecialPath::SlashTmp) => true,
(
FileSystemSpecialPath::CurrentWorkingDirectory,
FileSystemSpecialPath::ProjectRoots { subpath: None },
)
| (
FileSystemSpecialPath::ProjectRoots { subpath: None },
FileSystemSpecialPath::CurrentWorkingDirectory,
) => 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 resolve_file_system_special_path(
value: &FileSystemSpecialPath,
cwd: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
match value {
FileSystemSpecialPath::Root
| FileSystemSpecialPath::Minimal
| FileSystemSpecialPath::Unknown { .. } => None,
FileSystemSpecialPath::CurrentWorkingDirectory => {
let cwd = cwd?;
Some(cwd.clone())
}
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 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
}
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(".git");
// 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();
if top_level_git_is_dir || top_level_git_is_file {
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(".agents");
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(".codex");
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)
}
fn append_path_entry_if_missing(
entries: &mut Vec<FileSystemSandboxEntry>,
path: AbsolutePathBuf,
access: FileSystemAccessMode,
) {
if entries.iter().any(|entry| {
entry.access == access
&& matches!(
&entry.path,
FileSystemPath::Path { path: existing } if existing == &path
)
}) {
return;
}
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access,
});
}
fn append_default_read_only_path_if_no_explicit_rule(
entries: &mut Vec<FileSystemSandboxEntry>,
path: AbsolutePathBuf,
) {
if entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Path { path: existing } if existing == &path
)
}) {
return;
}
append_path_entry_if_missing(entries, path, FileSystemAccessMode::Read);
}
fn has_explicit_resolved_path_entry(
entries: &[ResolvedFileSystemEntry],
path: &AbsolutePathBuf,
) -> bool {
entries.iter().any(|entry| &entry.path == path)
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
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(parts) => parts,
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::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 {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: Vec::new(),
},
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::CurrentWorkingDirectory,
},
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)
);
}
#[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::CurrentWorkingDirectory,
},
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
.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 legacy_workspace_write_projection_blocks_missing_dot_codex_writes() {
let cwd = TempDir::new().expect("tempdir");
let dot_codex_config = cwd.path().join(".codex").join("config.toml");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: vec![],
},
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, cwd.path());
assert!(!file_system_policy.can_write_path_with_cwd(&dot_codex_config, cwd.path()));
}
#[test]
fn legacy_workspace_write_projection_accepts_relative_cwd() {
let relative_cwd = Path::new("workspace");
let expected_dot_codex = AbsolutePathBuf::from_absolute_path(
std::env::current_dir()
.expect("current dir")
.join(relative_cwd)
.join(".codex"),
)
.expect("absolute dot codex");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: vec![],
},
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, relative_cwd);
assert_eq!(
file_system_policy,
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: expected_dot_codex,
},
access: FileSystemAccessMode::Read,
},
])
);
assert!(
!file_system_policy
.can_write_path_with_cwd(Path::new(".codex/config.toml"), 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 current_working_directory_special_path_preserves_symlinked_cwd() {
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::CurrentWorkingDirectory,
},
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::CurrentWorkingDirectory,
},
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::CurrentWorkingDirectory,
},
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 = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
&SandboxPolicy::new_workspace_write_policy(),
cwd.path(),
);
assert!(
!legacy_workspace_write
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
);
}
#[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(),)
);
}
#[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::CurrentWorkingDirectory,
},
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::CurrentWorkingDirectory,
},
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::CurrentWorkingDirectory,
},
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::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: extra },
access: FileSystemAccessMode::Write,
},
])
);
}
#[test]
fn file_system_access_mode_orders_by_conflict_precedence() {
assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write);
}
}