Files
codex/codex-rs/core/src/config/permissions.rs
T
evawong-oaiandWinston Howes 3cae84009a Use named MITM permissions config (#18240)
## Stack
1. Parent PR: #18868 adds MITM hook config and model only.
2. Parent PR: #20659 wires hook enforcement into the proxy request path.
3. This PR changes the user facing PermissionProfile TOML shape.

## Why
1. The broader goal is to make MITM clamping usable from the same
permission profile that already controls network behavior.
2. This PR is the config UX layer for the stack. It moves MITM policy
into `[permissions.<profile>.network.mitm]` instead of exposing the flat
runtime shape to users.
3. The named hook and action tables belong here because users need
reusable policy blocks that are easy to review, while the proxy runtime
only needs a flat hook list.
4. This PR validates action refs during config parsing so mistakes in
the user facing policy fail before a proxy session starts.
5. Keeping the lowering here lets the proxy keep its simpler runtime
model and lets PermissionProfile remain the single source of network
permission policy.

## Summary
1. Keep MITM policy inside `[permissions.<profile>.network.mitm]` so the
selected PermissionProfile owns network proxy policy.
2. Use named MITM hooks under
`[permissions.<profile>.network.mitm.hooks.<name>]`.
3. Put host, methods, path prefixes, query, headers, body, and action
refs on the hook table.
4. Define reusable action blocks under
`[permissions.<profile>.network.mitm.actions.<name>]`.
5. Represent action blocks with `NetworkMitmActionToml`, then lower them
into the proxy runtime action config.
6. Reject unknown refs, empty refs, and empty action blocks during
config parsing.
7. Keep the runtime hook model unchanged by lowering config into the
existing proxy hook list.
8. Preserve the #20659 activation fix for nested MITM policy.

## Example
```toml
[permissions.workspace.network.mitm]
enabled = true

[permissions.workspace.network.mitm.hooks.github_write]
host = "api.github.com"
methods = ["POST", "PUT"]
path_prefixes = ["/repos/openai/"]
action = ["strip_auth"]

[permissions.workspace.network.mitm.actions.strip_auth]
strip_request_headers = ["authorization"]
```

## Validation
1. Regenerated the config schema.
2. Ran the core MITM config parsing and validation tests.
3. Ran the core PermissionProfile MITM proxy activation tests.
4. Ran the core config schema fixture test.
5. Ran the network proxy MITM policy tests.
6. Ran the scoped Clippy fixer for the network proxy crate.
7. Ran the scoped Clippy fixer for the core crate.

---------

Co-authored-by: Winston Howes <winston@openai.com>
2026-05-20 17:10:37 -07:00

827 lines
30 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::ResolvedPermissionProfileToml;
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,
mitm: None,
}
.apply_to_network_proxy_config(config);
}
pub(crate) fn resolve_permission_profile(
permissions: &PermissionsToml,
profile_name: &str,
) -> io::Result<ResolvedPermissionProfileToml> {
permissions
.resolve_profile(profile_name, extensible_builtin_parent_profile_marker)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string()))
}
/// Built-in parents provide their runtime permissions below. Resolution only
/// needs an empty profile marker so inheritance can terminate while preserving
/// the built-in parent id in `inherited_profile_names`.
fn extensible_builtin_parent_profile_marker(profile_name: &str) -> Option<PermissionProfileToml> {
matches!(
profile_name,
BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE
)
.then_some(PermissionProfileToml {
description: None,
extends: None,
workspace_roots: None,
filesystem: None,
network: None,
})
}
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)?.profile;
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 ResolvedPermissionProfileToml {
profile,
inherited_profile_names,
} = resolve_permission_profile(permissions, profile_name)?;
let base_permissions = inherited_profile_names.iter().find_map(|name| {
match name.as_str() {
BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()),
BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()),
_ => None,
}
.map(|profile| profile.to_runtime_permissions())
});
let (mut file_system_sandbox_policy, base_network_sandbox_policy) = base_permissions
.unwrap_or_else(|| {
(
FileSystemSandboxPolicy::restricted(Vec::new()),
NetworkSandboxPolicy::Restricted,
)
});
if let Some(filesystem) = profile.filesystem.as_ref() {
if filesystem.is_empty() && file_system_sandbox_policy.entries.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. `deny` 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 {
file_system_sandbox_policy
.entries
.extend(compile_filesystem_permission(
path,
permission,
policy_cwd,
startup_warnings,
)?);
}
}
} else if file_system_sandbox_policy.entries.is_empty() {
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),
)?;
if let Some(glob_scan_max_depth) = glob_scan_max_depth {
file_system_sandbox_policy.glob_scan_max_depth = Some(glob_scan_max_depth);
}
let network_sandbox_policy =
compile_network_sandbox_policy(profile.network.as_ref(), base_network_sandbox_policy);
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.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()
})
}
pub(crate) 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>,
base_network_sandbox_policy: NetworkSandboxPolicy,
) -> NetworkSandboxPolicy {
let Some(network) = network else {
return base_network_sandbox_policy;
};
match network.enabled {
Some(true) => NetworkSandboxPolicy::Enabled,
Some(false) => NetworkSandboxPolicy::Restricted,
None => base_network_sandbox_policy,
}
}
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::Deny && 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::Deny {
// 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::Deny {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem glob subpath `{subpath}` only supports `deny` 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 `deny` 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::Deny
&& 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::Deny
&& 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::Deny) => {
if path.contains("**") {
patterns.push(path.clone());
}
}
FilesystemPermissionToml::Access(_) => {}
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
if *access == FileSystemAccessMode::Deny && 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;