Files
codex/codex-rs/core/src/config/permissions.rs
T
Michael Bolin c25d905f61 permissions: support workspace roots in profiles (#22610)
## Why

This is the configuration/model half of the alternative permissions
migration we discussed as a comparison point for
[#22401](https://github.com/openai/codex/pull/22401) and
[#22402](https://github.com/openai/codex/pull/22402).

The old `workspace-write` model mixes three concerns that we want to
keep separate:
- reusable profile rules that should stay immutable once selected
- user/runtime workspace roots from `cwd`, `--add-dir`, and legacy
workspace-write config
- internal Codex writable roots such as memories, which should not be
shown as user workspace roots

This PR gives permission profiles first-class `workspace_roots` so users
can opt multiple repositories into the same `:workspace_roots` rules
without using broad absolute-path write grants. It also starts
separating the raw selected profile from the effective runtime profile
by making `Permissions` expose explicit accessors instead of public
mutable fields.

A representative `config.toml` looks like this:

```toml
default_permissions = "dev"

[permissions.dev.workspace_roots]
"~/code/openai" = true
"~/code/developers-website" = true

[permissions.dev.filesystem.":workspace_roots"]
"." = "write"
".codex" = "read"
".git" = "read"
".vscode" = "read"
```

If Codex starts in `~/code/codex` with that profile selected, the
effective workspace-root set becomes:
- `~/code/codex` from the runtime `cwd`
- `~/code/openai` from the profile
- `~/code/developers-website` from the profile

The `:workspace_roots` rules are materialized across each root, so
`.git`, `.codex`, and `.vscode` stay scoped the same way everywhere.
Runtime additions such as `--add-dir` can still layer on later stack
entries without mutating the selected profile.

## Stack Shape

This PR intentionally stops before the profile-identity cleanup in
[#22683](https://github.com/openai/codex/pull/22683) so the base review
stays focused on config loading, workspace-root materialization, and
compatibility with legacy `workspace-write`.

The representation in this PR is therefore transitional: `Permissions`
carries enough state to distinguish the raw constrained profile from the
effective runtime profile, and there are still call sites that must keep
the active profile identity and constrained profile value in sync. The
follow-up PR replaces that with a single resolved profile state
(`ResolvedPermissionProfile` / `PermissionProfileState`) that keeps the
profile id, immutable `PermissionProfile`, and profile-declared
workspace roots together. That follow-up removes APIs such as
`set_constrained_permission_profile_with_active_profile()` where
separate arguments could drift out of sync.

Downstream PRs then build on this base to switch app-server turn updates
to profile ids plus runtime workspace roots and to finish the
user-visible summary behavior. Reviewers should judge this PR as the
workspace-roots foundation, not as the final in-memory shape of selected
permission profiles.

## Review Guide

Suggested review order:

1. Start with `codex-rs/core/src/config/mod.rs`.
This is the main shape change in the base slice. `Permissions` now
stores a private raw `Constrained<PermissionProfile>` plus runtime
`workspace_roots`. Callers use `permission_profile()` when they need the
raw constrained value and `effective_permission_profile()` when they
need a materialized runtime profile. As noted above,
[#22683](https://github.com/openai/codex/pull/22683) replaces this
transitional shape with a resolved profile state that keeps identity and
profile data together.

2. Review `codex-rs/config/src/permissions_toml.rs` and
`codex-rs/core/src/config/permissions.rs`.
These add `[permissions.<id>.workspace_roots]`, resolve enabled entries
relative to the policy cwd, and keep `:workspace_roots` deny-read glob
patterns symbolic until the actual roots are known.

3. Review `codex-rs/protocol/src/permissions.rs` and
`codex-rs/protocol/src/models.rs`.
These add the policy/profile materialization helpers that expand exact
`:workspace_roots` entries and scoped deny-read globs over every
workspace root. This is also where `ActivePermissionProfileModification`
is removed from the core model.

4. Review the legacy bridge in
`Config::load_from_base_config_with_overrides` and
`Config::set_legacy_sandbox_policy`.
This is where legacy `workspace-write` roots become runtime workspace
roots, while Codex internal writable roots stay internal and do not
appear as user-facing workspace roots.

5. Then skim downstream call sites.
The interesting pattern is raw-vs-effective access: state/proxy/bwrap
paths keep the raw constrained profile, while execution, summaries, and
user-visible status use the effective profile and workspace-root list.

## What Changed

- added `[permissions.<id>.workspace_roots]` to the config model and
schema
- added runtime `workspace_roots` state to `Config`/`Permissions` and
`ConfigOverrides`
- made `Permissions` profile fields private and replaced direct mutation
with accessors/setters
- added `PermissionProfile` and `FileSystemSandboxPolicy` helpers for
materializing `:workspace_roots` exact paths and deny-read globs across
all roots
- moved legacy additional writable roots into runtime workspace-root
state instead of active profile modifications
- removed `ActivePermissionProfileModification` and its app-server
protocol/schema export
- updated sandbox/status summary paths so internal writable roots are
not reported as user workspace roots

## Verification Strategy

The targeted tests cover the behavior at the layers where regressions
are most likely:
- `codex-rs/core/src/config/config_tests.rs` verifies config loading,
legacy workspace-root seeding, effective profile materialization, and
memory-root handling.
- `codex-rs/core/src/config/permissions_tests.rs` verifies profile
`workspace_roots` parsing and `:workspace_roots` scoped/glob
compilation.
- `codex-rs/protocol/src/permissions.rs` unit tests verify exact and
glob materialization over multiple workspace roots.
- `codex-rs/tui/src/status/tests.rs` and
`codex-rs/utils/sandbox-summary/src/sandbox_summary.rs` verify the
user-facing summaries show effective workspace roots and hide internal
writes.

I also ran `cargo check --tests` locally after the latest stack refresh
to catch cross-crate API breakage from the private-field/accessor
changes.







---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22610).
* #22612
* #22611
* #22683
* __->__ #22610
2026-05-14 18:25:23 -07:00

788 lines
29 KiB
Rust

use std::borrow::Cow;
use std::io;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use codex_config::permissions_toml::FilesystemPermissionToml;
use codex_config::permissions_toml::FilesystemPermissionsToml;
use codex_config::permissions_toml::NetworkDomainPermissionToml;
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::permissions_toml::WorkspaceRootsToml;
use codex_config::types::SandboxWorkspaceWrite;
use codex_features::NetworkProxyConfigToml;
use codex_features::NetworkProxyDomainPermissionToml;
use codex_features::NetworkProxyModeToml;
use codex_features::NetworkProxyUnixSocketPermissionToml;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
#[cfg(test)]
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::permissions::project_roots_glob_pattern;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::ProjectConfig;
pub(crate) const BUILT_IN_READ_ONLY_PROFILE: &str = BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
pub(crate) const BUILT_IN_WORKSPACE_PROFILE: &str = BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
pub(crate) const BUILT_IN_DANGER_FULL_ACCESS_PROFILE: &str =
BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
pub(crate) fn default_builtin_permission_profile_name(
active_project: &ProjectConfig,
windows_sandbox_level: WindowsSandboxLevel,
) -> &'static str {
if (active_project.is_trusted() || active_project.is_untrusted())
&& !(cfg!(target_os = "windows") && windows_sandbox_level == WindowsSandboxLevel::Disabled)
{
BUILT_IN_WORKSPACE_PROFILE
} else {
BUILT_IN_READ_ONLY_PROFILE
}
}
pub(crate) fn is_builtin_permission_profile_name(profile_name: &str) -> bool {
matches!(
profile_name,
BUILT_IN_READ_ONLY_PROFILE
| BUILT_IN_WORKSPACE_PROFILE
| BUILT_IN_DANGER_FULL_ACCESS_PROFILE
)
}
pub(crate) fn builtin_permission_profile(
profile_name: &str,
workspace_write: Option<&SandboxWorkspaceWrite>,
) -> Option<PermissionProfile> {
match profile_name {
BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()),
BUILT_IN_WORKSPACE_PROFILE => Some(match workspace_write {
Some(SandboxWorkspaceWrite {
writable_roots: _,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
}) => PermissionProfile::workspace_write_with(
&[],
if *network_access {
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
},
*exclude_tmpdir_env_var,
*exclude_slash_tmp,
),
None => PermissionProfile::workspace_write(),
}),
BUILT_IN_DANGER_FULL_ACCESS_PROFILE => Some(PermissionProfile::Disabled),
_ => None,
}
}
pub(crate) fn validate_user_permission_profile_names(
permissions: Option<&PermissionsToml>,
) -> io::Result<()> {
let Some(permissions) = permissions else {
return Ok(());
};
for profile_name in permissions.entries.keys() {
if profile_name.starts_with(':') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"permissions profile `{profile_name}` uses a reserved built-in profile prefix"
),
));
}
}
Ok(())
}
pub(crate) fn network_proxy_config_from_profile_network(
network: Option<&NetworkToml>,
) -> NetworkProxyConfig {
let mut config = network.map_or_else(
NetworkProxyConfig::default,
NetworkToml::to_network_proxy_config,
);
// Profile `network.enabled` controls sandbox network access. Profiles may
// provide proxy settings for the feature gate to consume when that network
// access is enabled, but they do not start the managed proxy on their own.
config.network.enabled = false;
config
}
pub(crate) fn apply_network_proxy_feature_config(
config: &mut NetworkProxyConfig,
feature_config: &NetworkProxyConfigToml,
) {
NetworkToml {
enabled: feature_config.enabled,
proxy_url: feature_config.proxy_url.clone(),
enable_socks5: feature_config.enable_socks5,
socks_url: feature_config.socks_url.clone(),
enable_socks5_udp: feature_config.enable_socks5_udp,
allow_upstream_proxy: feature_config.allow_upstream_proxy,
dangerously_allow_non_loopback_proxy: feature_config.dangerously_allow_non_loopback_proxy,
dangerously_allow_all_unix_sockets: feature_config.dangerously_allow_all_unix_sockets,
mode: feature_config.mode.map(|mode| match mode {
NetworkProxyModeToml::Limited => NetworkMode::Limited,
NetworkProxyModeToml::Full => NetworkMode::Full,
}),
domains: feature_config
.domains
.as_ref()
.map(|domains| NetworkDomainPermissionsToml {
entries: domains
.iter()
.map(|(pattern, permission)| {
let permission = match permission {
NetworkProxyDomainPermissionToml::Allow => {
NetworkDomainPermissionToml::Allow
}
NetworkProxyDomainPermissionToml::Deny => {
NetworkDomainPermissionToml::Deny
}
};
(pattern.clone(), permission)
})
.collect(),
}),
unix_sockets: feature_config.unix_sockets.as_ref().map(|unix_sockets| {
NetworkUnixSocketPermissionsToml {
entries: unix_sockets
.iter()
.map(|(path, permission)| {
let permission = match permission {
NetworkProxyUnixSocketPermissionToml::Allow => {
NetworkUnixSocketPermissionToml::Allow
}
NetworkProxyUnixSocketPermissionToml::None => {
NetworkUnixSocketPermissionToml::None
}
};
(path.clone(), permission)
})
.collect(),
}
}),
allow_local_binding: feature_config.allow_local_binding,
}
.apply_to_network_proxy_config(config);
}
pub(crate) fn resolve_permission_profile<'a>(
permissions: &'a PermissionsToml,
profile_name: &str,
) -> io::Result<&'a PermissionProfileToml> {
permissions.entries.get(profile_name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("default_permissions refers to undefined profile `{profile_name}`"),
)
})
}
pub(crate) fn network_proxy_config_for_profile_selection(
permissions: Option<&PermissionsToml>,
profile_name: &str,
) -> io::Result<NetworkProxyConfig> {
if is_builtin_permission_profile_name(profile_name) {
return Ok(NetworkProxyConfig::default());
}
reject_unknown_builtin_permission_profile(profile_name)?;
let permissions = permissions.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"default_permissions requires a `[permissions]` table",
)
})?;
let profile = resolve_permission_profile(permissions, profile_name)?;
Ok(network_proxy_config_from_profile_network(
profile.network.as_ref(),
))
}
pub(crate) fn compile_permission_profile(
permissions: &PermissionsToml,
profile_name: &str,
policy_cwd: &Path,
startup_warnings: &mut Vec<String>,
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
let profile = resolve_permission_profile(permissions, profile_name)?;
let mut entries = Vec::new();
if let Some(filesystem) = profile.filesystem.as_ref() {
if filesystem.is_empty() {
push_warning(
startup_warnings,
missing_filesystem_entries_warning(profile_name),
);
} else {
if cfg!(not(target_os = "macos")) {
for pattern in unsupported_read_write_glob_paths(filesystem) {
push_warning(
startup_warnings,
format!(
"Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `none` deny-read globs are supported."
),
);
}
for pattern in unbounded_unreadable_globstar_paths(filesystem) {
push_warning(
startup_warnings,
format!(
"Filesystem deny-read glob `{pattern}` uses `**`. Non-macOS sandboxing does not support unbounded `**` natively; set `glob_scan_max_depth` in this filesystem profile to cap Linux glob expansion and silence this warning, or enumerate explicit depths such as `*.env`, `*/*.env`, and `*/*/*.env`."
),
);
}
}
for (path, permission) in &filesystem.entries {
entries.extend(compile_filesystem_permission(
path,
permission,
policy_cwd,
startup_warnings,
)?);
}
}
} else {
push_warning(
startup_warnings,
missing_filesystem_entries_warning(profile_name),
);
}
let glob_scan_max_depth = validate_glob_scan_max_depth(
profile
.filesystem
.as_ref()
.and_then(|filesystem| filesystem.glob_scan_max_depth),
)?;
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries);
file_system_sandbox_policy.glob_scan_max_depth = glob_scan_max_depth;
Ok((file_system_sandbox_policy, network_sandbox_policy))
}
pub(crate) fn compile_permission_profile_selection(
permissions: Option<&PermissionsToml>,
profile_name: &str,
workspace_write: Option<&SandboxWorkspaceWrite>,
policy_cwd: &Path,
startup_warnings: &mut Vec<String>,
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
if let Some(permission_profile) = builtin_permission_profile(profile_name, workspace_write) {
return Ok(permission_profile.to_runtime_permissions());
}
reject_unknown_builtin_permission_profile(profile_name)?;
let permissions = permissions.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"default_permissions requires a `[permissions]` table",
)
})?;
compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings)
}
pub(crate) fn compile_permission_profile_workspace_roots(
permissions: Option<&PermissionsToml>,
profile_name: &str,
policy_cwd: &Path,
) -> io::Result<Vec<AbsolutePathBuf>> {
if is_builtin_permission_profile_name(profile_name) {
return Ok(Vec::new());
}
reject_unknown_builtin_permission_profile(profile_name)?;
let permissions = permissions.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"default_permissions requires a `[permissions]` table",
)
})?;
let profile = resolve_permission_profile(permissions, profile_name)?;
Ok(compile_workspace_roots(
profile.workspace_roots.as_ref(),
policy_cwd,
))
}
fn compile_workspace_roots(
workspace_roots: Option<&WorkspaceRootsToml>,
policy_cwd: &Path,
) -> Vec<AbsolutePathBuf> {
workspace_roots.map_or_else(Vec::new, |workspace_roots| {
workspace_roots
.enabled_roots()
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, policy_cwd))
.collect()
})
}
fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> {
if profile_name.starts_with(':') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("default_permissions refers to unknown built-in profile `{profile_name}`"),
));
}
Ok(())
}
/// Returns a list of paths that must be readable by shell tools in order
/// for Codex to function. These should always be added to the
/// `FileSystemSandboxPolicy` for a thread.
pub(crate) fn get_readable_roots_required_for_codex_runtime(
codex_home: &Path,
zsh_path: Option<&PathBuf>,
main_execve_wrapper_exe: Option<&PathBuf>,
) -> Vec<AbsolutePathBuf> {
let arg0_root = AbsolutePathBuf::from_absolute_path(codex_home.join("tmp").join("arg0")).ok();
let zsh_path = zsh_path.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok());
let execve_wrapper_root = main_execve_wrapper_exe.and_then(|path| {
let path = AbsolutePathBuf::from_absolute_path(path).ok()?;
if let Some(arg0_root) = arg0_root.as_ref()
&& path.as_path().starts_with(arg0_root.as_path())
{
path.parent()
} else {
Some(path)
}
});
let mut readable_roots = Vec::new();
if let Some(zsh_path) = zsh_path {
readable_roots.push(zsh_path);
}
if let Some(execve_wrapper_root) = execve_wrapper_root {
readable_roots.push(execve_wrapper_root);
}
readable_roots
}
fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy {
let Some(network) = network else {
return NetworkSandboxPolicy::Restricted;
};
match network.enabled {
Some(true) => NetworkSandboxPolicy::Enabled,
_ => NetworkSandboxPolicy::Restricted,
}
}
fn compile_filesystem_permission(
path: &str,
permission: &FilesystemPermissionToml,
policy_cwd: &Path,
startup_warnings: &mut Vec<String>,
) -> io::Result<Vec<FileSystemSandboxEntry>> {
let mut entries = Vec::new();
match permission {
FilesystemPermissionToml::Access(access) => {
entries.push(FileSystemSandboxEntry {
path: compile_filesystem_access_path(path, *access, startup_warnings)?,
access: *access,
});
}
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
let has_glob = contains_glob_chars(subpath);
let can_compile_as_pattern = match parse_special_path(path) {
Some(FileSystemSpecialPath::ProjectRoots { .. }) | None => true,
Some(_) => false,
};
if has_glob && *access == FileSystemAccessMode::None && can_compile_as_pattern {
// Scoped glob syntax is a first-class filesystem policy
// pattern entry. Literal scoped paths continue through the
// exact-path parser so existing path semantics stay intact.
let entry = FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: compile_scoped_filesystem_pattern(
path, subpath, *access, policy_cwd,
)?,
},
access: *access,
};
entries.push(entry);
} else {
let subpath = compile_read_write_glob_path(subpath, *access)?;
entries.push(FileSystemSandboxEntry {
path: compile_scoped_filesystem_path(path, subpath, startup_warnings)?,
access: *access,
});
}
}
}
}
Ok(entries)
}
fn compile_filesystem_access_path(
path: &str,
access: FileSystemAccessMode,
startup_warnings: &mut Vec<String>,
) -> io::Result<FileSystemPath> {
if !contains_glob_chars(path) {
return compile_filesystem_path(path, startup_warnings);
}
if access == FileSystemAccessMode::None {
// At this point `path` is an unscoped filesystem table key. Top-level
// glob deny entries still go through the absolute-path parser before
// becoming policy patterns; relative project-root glob syntax is
// handled by `compile_scoped_filesystem_pattern`.
return Ok(FileSystemPath::GlobPattern {
pattern: parse_absolute_path(path)?.to_string_lossy().into_owned(),
});
}
let path = compile_read_write_glob_path(path, access)?;
compile_filesystem_path(path, startup_warnings)
}
fn compile_filesystem_path(
path: &str,
startup_warnings: &mut Vec<String>,
) -> io::Result<FileSystemPath> {
if let Some(special) = parse_special_path(path) {
maybe_push_unknown_special_path_warning(&special, startup_warnings);
return Ok(FileSystemPath::Special { value: special });
}
let path = parse_absolute_path(path)?;
Ok(FileSystemPath::Path { path })
}
fn compile_scoped_filesystem_path(
path: &str,
subpath: &str,
startup_warnings: &mut Vec<String>,
) -> io::Result<FileSystemPath> {
if subpath == "." {
return compile_filesystem_path(path, startup_warnings);
}
if let Some(special) = parse_special_path(path) {
let subpath = parse_relative_subpath(subpath)?;
let special = match special {
FileSystemSpecialPath::ProjectRoots { .. } => Ok(FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(subpath)),
}),
FileSystemSpecialPath::Unknown { path, .. } => Ok(FileSystemPath::Special {
value: FileSystemSpecialPath::unknown(path, Some(subpath)),
}),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem path `{path}` does not support nested entries"),
)),
}?;
if let FileSystemPath::Special { value } = &special {
maybe_push_unknown_special_path_warning(value, startup_warnings);
}
return Ok(special);
}
let subpath = parse_relative_subpath(subpath)?;
let base = parse_absolute_path(path)?;
let path = AbsolutePathBuf::resolve_path_against_base(&subpath, base.as_path());
Ok(FileSystemPath::Path { path })
}
fn compile_scoped_filesystem_pattern(
path: &str,
subpath: &str,
access: FileSystemAccessMode,
_policy_cwd: &Path,
) -> io::Result<String> {
// Pattern entries currently mean deny-read only. Supporting broader access
// modes here would imply glob-based read/write allow semantics that the
// sandbox policy does not express yet.
if access != FileSystemAccessMode::None {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem glob subpath `{subpath}` only supports `none` access"),
));
}
let subpath = parse_relative_subpath(subpath)?;
match parse_special_path(path) {
Some(FileSystemSpecialPath::ProjectRoots { .. }) => {
// Keep `:workspace_roots` glob patterns symbolic until the active
// workspace roots are known, then materialize them for cwd and any
// runtime/profile-added workspace roots together.
Ok(project_roots_glob_pattern(&subpath))
}
Some(_) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem path `{path}` does not support nested entries"),
)),
None => {
let base = parse_absolute_path(path)?;
Ok(base.join(&subpath).to_string_lossy().to_string())
}
}
}
fn compile_read_write_glob_path(path: &str, access: FileSystemAccessMode) -> io::Result<&str> {
if !contains_glob_chars(path) {
return Ok(path);
}
let path_without_trailing_glob = remove_trailing_glob_suffix(path);
if !contains_glob_chars(path_without_trailing_glob) {
return Ok(path_without_trailing_glob);
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem glob path `{path}` only supports `none` access; use an exact path or trailing `/**` for `{access}` subtree access"
),
))
}
fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) -> Vec<String> {
let mut patterns = Vec::new();
for (path, permission) in &filesystem.entries {
match permission {
FilesystemPermissionToml::Access(access) => {
if *access != FileSystemAccessMode::None
&& contains_glob_chars(remove_trailing_glob_suffix(path))
{
patterns.push(path.clone());
}
}
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
if *access != FileSystemAccessMode::None
&& contains_glob_chars(remove_trailing_glob_suffix(subpath))
{
patterns.push(format!("{path}/{subpath}"));
}
}
}
}
}
patterns
}
fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) -> Vec<String> {
if filesystem.glob_scan_max_depth.is_some() {
return Vec::new();
}
let mut patterns = Vec::new();
for (path, permission) in &filesystem.entries {
match permission {
FilesystemPermissionToml::Access(FileSystemAccessMode::None) => {
if path.contains("**") {
patterns.push(path.clone());
}
}
FilesystemPermissionToml::Access(_) => {}
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
if *access == FileSystemAccessMode::None && subpath.contains("**") {
patterns.push(format!("{path}/{subpath}"));
}
}
}
}
}
patterns
}
fn validate_glob_scan_max_depth(max_depth: Option<usize>) -> io::Result<Option<usize>> {
match max_depth {
Some(0) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"glob_scan_max_depth must be at least 1",
)),
_ => Ok(max_depth),
}
}
fn contains_glob_chars(path: &str) -> bool {
contains_glob_chars_for_platform(path, cfg!(windows))
}
fn contains_glob_chars_for_platform(path: &str, is_windows: bool) -> bool {
let normalized_windows_path = if is_windows {
normalize_windows_device_path(path)
} else {
None
};
let path = normalized_windows_path.as_deref().unwrap_or(path);
path.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
}
fn remove_trailing_glob_suffix(path: &str) -> &str {
path.strip_suffix("/**").unwrap_or(path)
}
// WARNING: keep this parser forward-compatible.
// Adding a new `:special_path` must not make older Codex versions reject the
// config. Unknown values intentionally round-trip through
// `FileSystemSpecialPath::Unknown` so they can be surfaced as warnings and
// ignored, rather than aborting config load.
fn parse_special_path(path: &str) -> Option<FileSystemSpecialPath> {
match path {
":root" => Some(FileSystemSpecialPath::Root),
":minimal" => Some(FileSystemSpecialPath::Minimal),
":workspace_roots" => Some(FileSystemSpecialPath::project_roots(/*subpath*/ None)),
":tmpdir" => Some(FileSystemSpecialPath::Tmpdir),
_ if path.starts_with(':') => {
Some(FileSystemSpecialPath::unknown(path, /*subpath*/ None))
}
_ => None,
}
}
fn parse_absolute_path(path: &str) -> io::Result<AbsolutePathBuf> {
parse_absolute_path_for_platform(path, cfg!(windows))
}
fn parse_absolute_path_for_platform(path: &str, is_windows: bool) -> io::Result<AbsolutePathBuf> {
let path_ref = normalize_absolute_path_for_platform(path, is_windows);
if !is_absolute_path_for_platform(path, path_ref.as_ref(), is_windows)
&& path != "~"
&& !path.starts_with("~/")
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem path `{path}` must be absolute, use `~/...`, or start with `:`"),
));
}
AbsolutePathBuf::from_absolute_path(path_ref.as_ref())
}
fn is_absolute_path_for_platform(path: &str, normalized_path: &Path, is_windows: bool) -> bool {
if is_windows {
is_windows_absolute_path(path)
|| is_windows_absolute_path(&normalized_path.to_string_lossy())
} else {
normalized_path.is_absolute()
}
}
fn normalize_absolute_path_for_platform(path: &str, is_windows: bool) -> Cow<'_, Path> {
if !is_windows {
return Cow::Borrowed(Path::new(path));
}
match normalize_windows_device_path(path) {
Some(normalized) => Cow::Owned(PathBuf::from(normalized)),
None => Cow::Borrowed(Path::new(path)),
}
}
fn normalize_windows_device_path(path: &str) -> Option<String> {
if let Some(unc) = path.strip_prefix(r"\\?\UNC\") {
return Some(format!(r"\\{unc}"));
}
if let Some(unc) = path.strip_prefix(r"\\.\UNC\") {
return Some(format!(r"\\{unc}"));
}
if let Some(path) = path.strip_prefix(r"\\?\")
&& is_windows_drive_absolute_path(path)
{
return Some(path.to_string());
}
if let Some(path) = path.strip_prefix(r"\\.\")
&& is_windows_drive_absolute_path(path)
{
return Some(path.to_string());
}
None
}
fn is_windows_absolute_path(path: &str) -> bool {
is_windows_drive_absolute_path(path) || path.starts_with(r"\\")
}
fn is_windows_drive_absolute_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
}
fn parse_relative_subpath(subpath: &str) -> io::Result<PathBuf> {
let path = Path::new(subpath);
if !subpath.is_empty()
&& path
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return Ok(path.to_path_buf());
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem subpath `{}` must be a descendant path without `.` or `..` components",
path.display()
),
))
}
fn push_warning(startup_warnings: &mut Vec<String>, message: String) {
tracing::warn!("{message}");
startup_warnings.push(message);
}
fn missing_filesystem_entries_warning(profile_name: &str) -> String {
format!(
"Permissions profile `{profile_name}` does not define any recognized filesystem entries for this version of Codex. Filesystem access will remain restricted. Upgrade Codex if this profile expects filesystem permissions."
)
}
fn maybe_push_unknown_special_path_warning(
special: &FileSystemSpecialPath,
startup_warnings: &mut Vec<String>,
) {
let FileSystemSpecialPath::Unknown { path, subpath } = special else {
return;
};
push_warning(
startup_warnings,
match subpath.as_deref() {
Some(subpath) => format!(
"Configured filesystem path `{path}` with nested entry `{}` is not recognized by this version of Codex and will be ignored. Upgrade Codex if this path is required.",
subpath.display()
),
None => format!(
"Configured filesystem path `{path}` is not recognized by this version of Codex and will be ignored. Upgrade Codex if this path is required."
),
},
);
}
#[cfg(test)]
#[path = "permissions_tests.rs"]
mod tests;