mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
4b55979755
## Why
The experimental `PermissionProfile` API had both `:cwd` and
`:project_roots` special filesystem paths, which made the permission
root ambiguous. This PR removes the unstable `current_working_directory`
special path before the permissions API is stabilized, so callers use
`:project_roots` for symbolic project-root access.
## What changed
- Removes `FileSystemSpecialPath::CurrentWorkingDirectory` from protocol
and app-server protocol models, plus regenerated app-server
JSON/TypeScript schemas.
- Replaces internal `:cwd` permission entries with `:project_roots`
entries.
- Keeps the existing cwd-update behavior for legacy-shaped
workspace-write profiles, while removing the deleted
`CurrentWorkingDirectory` case from that compatibility path.
- Keeps `PermissionProfile::workspace_write()` as the reusable symbolic
workspace-write helper, with docs noting that `:project_roots` entries
resolve at enforcement time.
- Updates app-server docs/examples and approval UI labeling to stop
advertising `:cwd` as a permission token.
## Compatibility
Persisted rollout items may contain the old
`{"kind":"current_working_directory"}` tag from earlier experimental
`permissionProfile` snapshots. This PR keeps that tag as a
deserialize-only alias for `ProjectRoots { subpath: None }`, while
continuing to serialize only the new `project_roots` tag.
## Follow-up
This PR intentionally does not introduce an explicit project-root set on
`SessionConfiguration` or runtime sandbox resolution. Today, the
resolver still uses the active cwd as the single implicit project root.
A follow-up should model project roots separately from tool cwd so
`:project_roots` entries can resolve against the configured project
roots, and resolve to no entries when there are no project roots.
## Verification
- `cargo test -p codex-protocol permissions:: --lib`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-sandboxing -p codex-exec-server --lib`
- `cargo test -p codex-core session_configuration_apply_ --lib`
- `cargo test -p codex-app-server
command_exec_permission_profile_project_roots_use_command_cwd --test
all`
- `cargo test -p codex-tui
thread_read_session_state_does_not_reuse_primary_permission_profile
--lib`
- `cargo test -p codex-tui
preset_matching_accepts_workspace_write_with_extra_roots --lib`
- `cargo test -p codex-config --lib`
534 lines
19 KiB
Rust
534 lines
19 KiB
Rust
use codex_protocol::models::AdditionalPermissionProfile;
|
|
use codex_protocol::models::FileSystemPermissions;
|
|
use codex_protocol::models::NetworkPermissions;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSandboxKind;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::permissions::ReadDenyMatcher;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
|
|
use std::num::NonZeroUsize;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
|
|
pub fn normalize_additional_permissions(
|
|
additional_permissions: AdditionalPermissionProfile,
|
|
) -> Result<AdditionalPermissionProfile, String> {
|
|
let network = additional_permissions
|
|
.network
|
|
.filter(|network| !network.is_empty());
|
|
let file_system = match additional_permissions.file_system {
|
|
Some(file_system) => {
|
|
let mut entries = Vec::with_capacity(file_system.entries.len());
|
|
let glob_scan_max_depth = file_system.glob_scan_max_depth;
|
|
for entry in file_system.entries {
|
|
if matches!(&entry.path, FileSystemPath::GlobPattern { .. })
|
|
&& entry.access != FileSystemAccessMode::None
|
|
{
|
|
return Err(
|
|
"glob file system permissions only support deny-read entries".to_string(),
|
|
);
|
|
}
|
|
let path = match entry.path {
|
|
FileSystemPath::Path { path } => FileSystemPath::Path {
|
|
path: canonicalize_preserving_symlinks(path.as_path())
|
|
.ok()
|
|
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
|
|
.unwrap_or(path),
|
|
},
|
|
FileSystemPath::GlobPattern { pattern } => {
|
|
FileSystemPath::GlobPattern { pattern }
|
|
}
|
|
FileSystemPath::Special { value } => FileSystemPath::Special { value },
|
|
};
|
|
let normalized_entry = FileSystemSandboxEntry {
|
|
path,
|
|
access: entry.access,
|
|
};
|
|
if !entries.contains(&normalized_entry) {
|
|
entries.push(normalized_entry);
|
|
}
|
|
}
|
|
let file_system = FileSystemPermissions {
|
|
entries,
|
|
glob_scan_max_depth,
|
|
};
|
|
(!file_system.is_empty()).then_some(file_system)
|
|
}
|
|
None => None,
|
|
};
|
|
Ok(AdditionalPermissionProfile {
|
|
network,
|
|
file_system,
|
|
})
|
|
}
|
|
|
|
pub fn merge_permission_profiles(
|
|
base: Option<&AdditionalPermissionProfile>,
|
|
permissions: Option<&AdditionalPermissionProfile>,
|
|
) -> Option<AdditionalPermissionProfile> {
|
|
let Some(permissions) = permissions else {
|
|
return base.cloned();
|
|
};
|
|
|
|
match base {
|
|
Some(base) => {
|
|
let network = match (base.network.as_ref(), permissions.network.as_ref()) {
|
|
(
|
|
Some(NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
_,
|
|
)
|
|
| (
|
|
_,
|
|
Some(NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
) => Some(NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
_ => None,
|
|
};
|
|
let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) {
|
|
(Some(base), Some(permissions)) => Some(FileSystemPermissions {
|
|
entries: merge_permission_entries(&base.entries, &permissions.entries),
|
|
glob_scan_max_depth: merge_glob_scan_max_depth(
|
|
&base.entries,
|
|
base.glob_scan_max_depth.map(usize::from),
|
|
&permissions.entries,
|
|
permissions.glob_scan_max_depth.map(usize::from),
|
|
)
|
|
.and_then(NonZeroUsize::new),
|
|
})
|
|
.filter(|file_system| !file_system.is_empty()),
|
|
(Some(base), None) => Some(base.clone()),
|
|
(None, Some(permissions)) => Some(permissions.clone()),
|
|
(None, None) => None,
|
|
};
|
|
|
|
Some(AdditionalPermissionProfile {
|
|
network,
|
|
file_system,
|
|
})
|
|
.filter(|permissions| !permissions.is_empty())
|
|
}
|
|
None => Some(permissions.clone()).filter(|permissions| !permissions.is_empty()),
|
|
}
|
|
}
|
|
|
|
pub fn intersect_permission_profiles(
|
|
requested: AdditionalPermissionProfile,
|
|
granted: AdditionalPermissionProfile,
|
|
cwd: &Path,
|
|
) -> AdditionalPermissionProfile {
|
|
let file_system = requested
|
|
.file_system
|
|
.map(|requested_file_system| {
|
|
let granted_file_system = granted.file_system.unwrap_or_default();
|
|
let requested_policy =
|
|
FileSystemSandboxPolicy::restricted(requested_file_system.entries.clone());
|
|
let requested_read_deny_matcher = ReadDenyMatcher::new(&requested_policy, cwd);
|
|
let mut accepted_entries = Vec::new();
|
|
for entry in granted_file_system.entries.iter().filter(|entry| {
|
|
granted_file_system_entry_within_request(
|
|
&requested_file_system,
|
|
&requested_policy,
|
|
requested_read_deny_matcher.as_ref(),
|
|
entry,
|
|
cwd,
|
|
)
|
|
}) {
|
|
let entry = materialize_cwd_dependent_entry(entry, cwd);
|
|
if !accepted_entries.contains(&entry) {
|
|
accepted_entries.push(entry);
|
|
}
|
|
}
|
|
let mut entries = accepted_entries.clone();
|
|
let requested_retained_deny_entries = retain_constraining_deny_entries(
|
|
&requested_file_system.entries,
|
|
&accepted_entries,
|
|
cwd,
|
|
&mut entries,
|
|
);
|
|
let granted_retained_deny_entries = retain_constraining_deny_entries(
|
|
&granted_file_system.entries,
|
|
&accepted_entries,
|
|
cwd,
|
|
&mut entries,
|
|
);
|
|
FileSystemPermissions {
|
|
glob_scan_max_depth: merge_glob_scan_max_depth(
|
|
&requested_retained_deny_entries,
|
|
requested_file_system.glob_scan_max_depth.map(usize::from),
|
|
&granted_retained_deny_entries,
|
|
granted_file_system.glob_scan_max_depth.map(usize::from),
|
|
)
|
|
.and_then(NonZeroUsize::new),
|
|
entries,
|
|
}
|
|
})
|
|
.filter(|file_system| !file_system.is_empty());
|
|
let network = match (requested.network, granted.network) {
|
|
(
|
|
Some(NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
Some(NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
) => Some(NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
_ => None,
|
|
};
|
|
|
|
AdditionalPermissionProfile {
|
|
network,
|
|
file_system,
|
|
}
|
|
}
|
|
|
|
fn merge_glob_scan_max_depth(
|
|
left_entries: &[FileSystemSandboxEntry],
|
|
left_depth: Option<usize>,
|
|
right_entries: &[FileSystemSandboxEntry],
|
|
right_depth: Option<usize>,
|
|
) -> Option<usize> {
|
|
let left_depth = effective_glob_scan_depth(left_entries, left_depth);
|
|
let right_depth = effective_glob_scan_depth(right_entries, right_depth);
|
|
|
|
match (left_depth, right_depth) {
|
|
(Some(GlobScanDepth::Unbounded), _) | (_, Some(GlobScanDepth::Unbounded)) => None,
|
|
(Some(GlobScanDepth::Bounded(left)), Some(GlobScanDepth::Bounded(right))) => {
|
|
Some(left.max(right))
|
|
}
|
|
(Some(GlobScanDepth::Bounded(depth)), None)
|
|
| (None, Some(GlobScanDepth::Bounded(depth))) => Some(depth),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
|
|
fn effective_glob_scan_depth(
|
|
entries: &[FileSystemSandboxEntry],
|
|
depth: Option<usize>,
|
|
) -> Option<GlobScanDepth> {
|
|
entries
|
|
.iter()
|
|
.any(|entry| {
|
|
entry.access == FileSystemAccessMode::None
|
|
&& matches!(&entry.path, FileSystemPath::GlobPattern { .. })
|
|
})
|
|
.then_some(match depth {
|
|
Some(depth) => GlobScanDepth::Bounded(depth),
|
|
None => GlobScanDepth::Unbounded,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum GlobScanDepth {
|
|
Bounded(usize),
|
|
Unbounded,
|
|
}
|
|
|
|
fn granted_file_system_entry_within_request(
|
|
requested: &FileSystemPermissions,
|
|
requested_policy: &FileSystemSandboxPolicy,
|
|
requested_read_deny_matcher: Option<&ReadDenyMatcher>,
|
|
granted_entry: &FileSystemSandboxEntry,
|
|
cwd: &Path,
|
|
) -> bool {
|
|
if !granted_entry.access.can_read() {
|
|
return false;
|
|
}
|
|
|
|
if let Some(path) = resolve_permission_path(&granted_entry.path, cwd) {
|
|
if requested_read_deny_matcher.is_some_and(|matcher| matcher.is_read_denied(path.as_path()))
|
|
{
|
|
return false;
|
|
}
|
|
return access_covers(
|
|
requested_policy.resolve_access_with_cwd(path.as_path(), cwd),
|
|
granted_entry.access,
|
|
);
|
|
}
|
|
|
|
requested.entries.iter().any(|requested_entry| {
|
|
access_covers(requested_entry.access, granted_entry.access)
|
|
&& requested_entry.path == granted_entry.path
|
|
})
|
|
}
|
|
|
|
fn retain_constraining_deny_entries(
|
|
source_entries: &[FileSystemSandboxEntry],
|
|
accepted_entries: &[FileSystemSandboxEntry],
|
|
cwd: &Path,
|
|
output_entries: &mut Vec<FileSystemSandboxEntry>,
|
|
) -> Vec<FileSystemSandboxEntry> {
|
|
let mut retained_entries = Vec::new();
|
|
for entry in source_entries
|
|
.iter()
|
|
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
|
{
|
|
if !deny_entry_constrains_accepted_grant(entry, accepted_entries, cwd) {
|
|
continue;
|
|
}
|
|
let entry = materialize_cwd_dependent_entry(entry, cwd);
|
|
if !output_entries.contains(&entry) {
|
|
output_entries.push(entry.clone());
|
|
}
|
|
retained_entries.push(entry);
|
|
}
|
|
retained_entries
|
|
}
|
|
|
|
fn deny_entry_constrains_accepted_grant(
|
|
deny_entry: &FileSystemSandboxEntry,
|
|
accepted_entries: &[FileSystemSandboxEntry],
|
|
cwd: &Path,
|
|
) -> bool {
|
|
accepted_entries
|
|
.iter()
|
|
.filter(|entry| entry.access.can_read())
|
|
.any(|entry| {
|
|
let Some(grant_path) = resolve_permission_path(&entry.path, cwd) else {
|
|
return false;
|
|
};
|
|
match &deny_entry.path {
|
|
FileSystemPath::GlobPattern { pattern } => glob_static_prefix_path(pattern, cwd)
|
|
.is_some_and(|prefix| paths_overlap(prefix.as_path(), grant_path.as_path())),
|
|
FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => {
|
|
resolve_permission_path(&deny_entry.path, cwd).is_some_and(|deny_path| {
|
|
paths_overlap(deny_path.as_path(), grant_path.as_path())
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn glob_static_prefix_path(pattern: &str, cwd: &Path) -> Option<AbsolutePathBuf> {
|
|
let resolved_pattern = AbsolutePathBuf::resolve_path_against_base(pattern, cwd);
|
|
let resolved_pattern = resolved_pattern.as_path().to_string_lossy();
|
|
let prefix = match resolved_pattern.find(['*', '?', '[', ']']) {
|
|
Some(0) => return None,
|
|
Some(index) => {
|
|
let prefix = &resolved_pattern[..index];
|
|
if prefix.ends_with(std::path::MAIN_SEPARATOR)
|
|
|| prefix.ends_with('/')
|
|
|| prefix.ends_with('\\')
|
|
{
|
|
Path::new(prefix)
|
|
} else {
|
|
Path::new(prefix).parent()?
|
|
}
|
|
}
|
|
None => Path::new(resolved_pattern.as_ref()),
|
|
};
|
|
AbsolutePathBuf::from_absolute_path(prefix).ok()
|
|
}
|
|
|
|
fn paths_overlap(left: &Path, right: &Path) -> bool {
|
|
left.starts_with(right) || right.starts_with(left)
|
|
}
|
|
|
|
fn access_covers(requested: FileSystemAccessMode, granted: FileSystemAccessMode) -> bool {
|
|
match granted {
|
|
FileSystemAccessMode::Read => requested.can_read(),
|
|
FileSystemAccessMode::Write => requested.can_write(),
|
|
FileSystemAccessMode::None => false,
|
|
}
|
|
}
|
|
|
|
fn materialize_cwd_dependent_entry(
|
|
entry: &FileSystemSandboxEntry,
|
|
cwd: &Path,
|
|
) -> FileSystemSandboxEntry {
|
|
match &entry.path {
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::ProjectRoots { .. },
|
|
} => resolve_permission_path(&entry.path, cwd)
|
|
.map(|path| FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path },
|
|
access: entry.access,
|
|
})
|
|
.unwrap_or_else(|| entry.clone()),
|
|
FileSystemPath::GlobPattern { pattern } => FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: AbsolutePathBuf::resolve_path_against_base(pattern, cwd)
|
|
.to_string_lossy()
|
|
.into_owned(),
|
|
},
|
|
access: entry.access,
|
|
},
|
|
FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => entry.clone(),
|
|
}
|
|
}
|
|
|
|
fn resolve_permission_path(path: &FileSystemPath, cwd: &Path) -> Option<AbsolutePathBuf> {
|
|
match path {
|
|
FileSystemPath::Path { path } => Some(path.clone()),
|
|
FileSystemPath::GlobPattern { .. } => None,
|
|
FileSystemPath::Special { value } => match value {
|
|
FileSystemSpecialPath::Root => {
|
|
let root = cwd.ancestors().last()?;
|
|
AbsolutePathBuf::from_absolute_path(root).ok()
|
|
}
|
|
FileSystemSpecialPath::ProjectRoots { subpath } => {
|
|
let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok()?;
|
|
Some(match subpath {
|
|
Some(subpath) => {
|
|
AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path())
|
|
}
|
|
None => cwd,
|
|
})
|
|
}
|
|
FileSystemSpecialPath::Tmpdir => {
|
|
let tmpdir = std::env::var_os("TMPDIR")?;
|
|
if tmpdir.is_empty() {
|
|
None
|
|
} else {
|
|
AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()
|
|
}
|
|
}
|
|
FileSystemSpecialPath::SlashTmp => AbsolutePathBuf::from_absolute_path("/tmp")
|
|
.ok()
|
|
.filter(|path| path.as_path().is_dir()),
|
|
FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn merge_permission_entries(
|
|
base: &[FileSystemSandboxEntry],
|
|
permissions: &[FileSystemSandboxEntry],
|
|
) -> Vec<FileSystemSandboxEntry> {
|
|
let mut merged = Vec::with_capacity(base.len() + permissions.len());
|
|
for entry in base.iter().chain(permissions.iter()) {
|
|
if !merged.contains(entry) {
|
|
merged.push(entry.clone());
|
|
}
|
|
}
|
|
merged
|
|
}
|
|
|
|
fn merge_file_system_policy_with_additional_permissions(
|
|
file_system_policy: &FileSystemSandboxPolicy,
|
|
additional_permissions: &FileSystemPermissions,
|
|
) -> FileSystemSandboxPolicy {
|
|
match file_system_policy.kind {
|
|
FileSystemSandboxKind::Restricted => {
|
|
let mut merged_policy = file_system_policy.clone();
|
|
for entry in &additional_permissions.entries {
|
|
if !merged_policy.entries.contains(entry) {
|
|
merged_policy.entries.push(entry.clone());
|
|
}
|
|
}
|
|
merged_policy.glob_scan_max_depth = merge_glob_scan_max_depth(
|
|
&file_system_policy.entries,
|
|
file_system_policy.glob_scan_max_depth,
|
|
&additional_permissions.entries,
|
|
additional_permissions.glob_scan_max_depth.map(usize::from),
|
|
);
|
|
merged_policy
|
|
}
|
|
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
|
|
file_system_policy.clone()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn effective_file_system_sandbox_policy(
|
|
file_system_policy: &FileSystemSandboxPolicy,
|
|
additional_permissions: Option<&AdditionalPermissionProfile>,
|
|
) -> FileSystemSandboxPolicy {
|
|
let Some(additional_permissions) = additional_permissions else {
|
|
return file_system_policy.clone();
|
|
};
|
|
|
|
let Some(file_system_permissions) = additional_permissions.file_system.as_ref() else {
|
|
return file_system_policy.clone();
|
|
};
|
|
if file_system_permissions.is_empty() {
|
|
file_system_policy.clone()
|
|
} else {
|
|
merge_file_system_policy_with_additional_permissions(
|
|
file_system_policy,
|
|
file_system_permissions,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn merge_network_access(
|
|
base_network_access: bool,
|
|
additional_permissions: &AdditionalPermissionProfile,
|
|
) -> bool {
|
|
base_network_access
|
|
|| additional_permissions
|
|
.network
|
|
.as_ref()
|
|
.and_then(|network| network.enabled)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub fn effective_network_sandbox_policy(
|
|
network_policy: NetworkSandboxPolicy,
|
|
additional_permissions: Option<&AdditionalPermissionProfile>,
|
|
) -> NetworkSandboxPolicy {
|
|
if additional_permissions
|
|
.is_some_and(|permissions| merge_network_access(network_policy.is_enabled(), permissions))
|
|
{
|
|
NetworkSandboxPolicy::Enabled
|
|
} else if additional_permissions.is_some() {
|
|
NetworkSandboxPolicy::Restricted
|
|
} else {
|
|
network_policy
|
|
}
|
|
}
|
|
|
|
pub fn effective_permission_profile(
|
|
permission_profile: &PermissionProfile,
|
|
additional_permissions: Option<&AdditionalPermissionProfile>,
|
|
) -> PermissionProfile {
|
|
let (file_system_policy, network_policy) = permission_profile.to_runtime_permissions();
|
|
let effective_file_system_policy =
|
|
effective_file_system_sandbox_policy(&file_system_policy, additional_permissions);
|
|
let effective_network_policy =
|
|
effective_network_sandbox_policy(network_policy, additional_permissions);
|
|
PermissionProfile::from_runtime_permissions_with_enforcement(
|
|
permission_profile.enforcement(),
|
|
&effective_file_system_policy,
|
|
effective_network_policy,
|
|
)
|
|
}
|
|
|
|
pub fn should_require_platform_sandbox(
|
|
file_system_policy: &FileSystemSandboxPolicy,
|
|
network_policy: NetworkSandboxPolicy,
|
|
has_managed_network_requirements: bool,
|
|
) -> bool {
|
|
if has_managed_network_requirements {
|
|
return true;
|
|
}
|
|
|
|
if !network_policy.is_enabled() {
|
|
return !matches!(
|
|
file_system_policy.kind,
|
|
FileSystemSandboxKind::ExternalSandbox
|
|
);
|
|
}
|
|
|
|
match file_system_policy.kind {
|
|
FileSystemSandboxKind::Restricted => !file_system_policy.has_full_disk_write_access(),
|
|
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "policy_transforms_tests.rs"]
|
|
mod tests;
|