mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
## 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
788 lines
29 KiB
Rust
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;
|