mirror of
https://github.com/openai/codex.git
synced 2026-04-30 03:12:20 +03:00
## Why `argument-comment-lint` was green in CI even though the repo still had many uncommented literal arguments. The main gap was target coverage: the repo wrapper did not force Cargo to inspect test-only call sites, so examples like the `latest_session_lookup_params(true, ...)` tests in `codex-rs/tui_app_server/src/lib.rs` never entered the blocking CI path. This change cleans up the existing backlog, makes the default repo lint path cover all Cargo targets, and starts rolling that stricter CI enforcement out on the platform where it is currently validated. ## What changed - mechanically fixed existing `argument-comment-lint` violations across the `codex-rs` workspace, including tests, examples, and benches - updated `tools/argument-comment-lint/run-prebuilt-linter.sh` and `tools/argument-comment-lint/run.sh` so non-`--fix` runs default to `--all-targets` unless the caller explicitly narrows the target set - fixed both wrappers so forwarded cargo arguments after `--` are preserved with a single separator - documented the new default behavior in `tools/argument-comment-lint/README.md` - updated `rust-ci` so the macOS lint lane keeps the plain wrapper invocation and therefore enforces `--all-targets`, while Linux and Windows temporarily pass `-- --lib --bins` That temporary CI split keeps the stricter all-targets check where it is already cleaned up, while leaving room to finish the remaining Linux- and Windows-specific target-gated cleanup before enabling `--all-targets` on those runners. The Linux and Windows failures on the intermediate revision were caused by the wrapper forwarding bug, not by additional lint findings in those lanes. ## Validation - `bash -n tools/argument-comment-lint/run.sh` - `bash -n tools/argument-comment-lint/run-prebuilt-linter.sh` - shell-level wrapper forwarding check for `-- --lib --bins` - shell-level wrapper forwarding check for `-- --tests` - `just argument-comment-lint` - `cargo test` in `tools/argument-comment-lint` - `cargo test -p codex-terminal-detection` ## Follow-up - Clean up remaining Linux-only target-gated callsites, then switch the Linux lint lane back to the plain wrapper invocation. - Clean up remaining Windows-only target-gated callsites, then switch the Windows lint lane back to the plain wrapper invocation.
2128 lines
80 KiB
Rust
2128 lines
80 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 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/...`.
|
|
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;
|
|
}
|
|
root.join(suffix).ok()
|
|
})
|
|
}
|
|
} 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 {
|
|
AbsolutePathBuf::resolve_path_against_base(path, cwd).ok()
|
|
}
|
|
}
|
|
|
|
/// 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) => {
|
|
AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path()).ok()
|
|
}
|
|
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() {
|
|
let Ok(canonical_ancestor) = ancestor.canonicalize() else {
|
|
continue;
|
|
};
|
|
let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
|
|
continue;
|
|
};
|
|
if let Ok(normalized_path) =
|
|
AbsolutePathBuf::from_absolute_path(canonical_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();
|
|
#[allow(clippy::expect_used)]
|
|
let top_level_git = writable_root
|
|
.join(".git")
|
|
.expect(".git is a valid relative path");
|
|
// 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);
|
|
}
|
|
|
|
#[allow(clippy::expect_used)]
|
|
let top_level_agents = writable_root.join(".agents").expect("valid relative path");
|
|
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.
|
|
#[allow(clippy::expect_used)]
|
|
let top_level_codex = writable_root.join(".codex").expect("valid relative path");
|
|
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 = match AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base) {
|
|
Ok(path) => path,
|
|
Err(err) => {
|
|
error!(
|
|
"Failed to resolve gitdir path {gitdir_raw} from {path}: {err}",
|
|
path = dot_git.as_path().display()
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
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").expect("expected .codex path");
|
|
|
|
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").expect("expected .codex path");
|
|
|
|
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")
|
|
.expect("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_canonicalize_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").expect("symlinked blocked path");
|
|
let expected_root = AbsolutePathBuf::from_absolute_path(
|
|
real_root.canonicalize().expect("canonicalize real root"),
|
|
)
|
|
.expect("absolute canonical root");
|
|
let expected_blocked = AbsolutePathBuf::from_absolute_path(
|
|
blocked.canonicalize().expect("canonicalize blocked"),
|
|
)
|
|
.expect("absolute canonical blocked");
|
|
let expected_codex = AbsolutePathBuf::from_absolute_path(
|
|
codex_dir.canonicalize().expect("canonicalize .codex"),
|
|
)
|
|
.expect("absolute canonical .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_canonicalizes_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(
|
|
real_root.canonicalize().expect("canonicalize real root"),
|
|
)
|
|
.expect("absolute canonical root");
|
|
let expected_blocked = AbsolutePathBuf::from_absolute_path(
|
|
blocked.canonicalize().expect("canonicalize blocked"),
|
|
)
|
|
.expect("absolute canonical blocked");
|
|
let expected_agents = AbsolutePathBuf::from_absolute_path(
|
|
agents_dir.canonicalize().expect("canonicalize .agents"),
|
|
)
|
|
.expect("absolute canonical .agents");
|
|
let expected_codex = AbsolutePathBuf::from_absolute_path(
|
|
codex_dir.canonicalize().expect("canonicalize .codex"),
|
|
)
|
|
.expect("absolute canonical .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")
|
|
.expect("symlinked linked-private path");
|
|
let expected_root = AbsolutePathBuf::from_absolute_path(
|
|
real_root.canonicalize().expect("canonicalize real root"),
|
|
)
|
|
.expect("absolute canonical root");
|
|
let expected_linked_private = expected_root
|
|
.join("linked-private")
|
|
.expect("expected linked-private path");
|
|
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")
|
|
.expect("symlinked linked-private path");
|
|
let expected_root = AbsolutePathBuf::from_absolute_path(
|
|
real_root.canonicalize().expect("canonicalize real root"),
|
|
)
|
|
.expect("absolute canonical root");
|
|
let expected_linked_private = expected_root
|
|
.join("linked-private")
|
|
.expect("expected linked-private path");
|
|
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").expect("alias root path");
|
|
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")
|
|
.expect("expected alias path");
|
|
|
|
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_canonicalizes_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_canonicalizes_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(
|
|
real_tmpdir
|
|
.canonicalize()
|
|
.expect("canonicalize real tmpdir"),
|
|
)
|
|
.expect("absolute canonical tmpdir");
|
|
let expected_blocked = AbsolutePathBuf::from_absolute_path(
|
|
blocked.canonicalize().expect("canonicalize blocked"),
|
|
)
|
|
.expect("absolute canonical blocked");
|
|
let expected_codex = AbsolutePathBuf::from_absolute_path(
|
|
codex_dir.canonicalize().expect("canonicalize .codex"),
|
|
)
|
|
.expect("absolute canonical .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()).expect("resolve docs");
|
|
let docs_private = AbsolutePathBuf::resolve_path_against_base("docs/private", cwd.path())
|
|
.expect("resolve docs/private");
|
|
let docs_private_public =
|
|
AbsolutePathBuf::resolve_path_against_base("docs/private/public", cwd.path())
|
|
.expect("resolve docs/private/public");
|
|
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()).expect("resolve docs");
|
|
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()).expect("resolve docs");
|
|
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()).expect("resolve docs");
|
|
let expected_docs = AbsolutePathBuf::from_absolute_path(
|
|
cwd.path()
|
|
.canonicalize()
|
|
.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()).expect("resolve docs");
|
|
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);
|
|
}
|
|
}
|