permissions: add built-in default profiles

This commit is contained in:
Michael Bolin
2026-04-27 21:12:16 -07:00
parent ba5cc6d918
commit f435a6cd02
5 changed files with 335 additions and 42 deletions

View File

@@ -114,7 +114,8 @@ pub struct ConfigToml {
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
/// Default named permissions profile to apply from the `[permissions]`
/// Default permissions profile to apply. Names starting with `:` refer to
/// built-in profiles; other names are resolved from the `[permissions]`
/// table.
pub default_permissions: Option<String>,

View File

@@ -2494,7 +2494,7 @@
"type": "string"
},
"default_permissions": {
"description": "Default named permissions profile to apply from the `[permissions]` table.",
"description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.",
"type": "string"
},
"developer_instructions": {

View File

@@ -1189,6 +1189,193 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<(
Ok(())
}
#[tokio::test]
async fn default_permissions_can_select_builtin_profile_without_permissions_table()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":workspace".to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected :workspace to allow writing the project root, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()),
"expected :workspace to protect project metadata, policy: {policy:?}"
);
Ok(())
}
#[tokio::test]
async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let project_key = cwd.path().to_string_lossy().to_string();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
if cfg!(target_os = "windows") {
assert!(
!policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected trusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}"
);
} else {
assert!(
policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected trusted project fallback to use :workspace, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()),
"expected :workspace metadata carveouts, policy: {policy:?}"
);
}
Ok(())
}
#[tokio::test]
async fn empty_config_defaults_to_builtin_read_only_without_trust_decision() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_read_path_with_cwd(cwd.path(), cwd.path()),
"expected :read-only to allow reads, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected :read-only to deny writes, policy: {policy:?}"
);
Ok(())
}
#[tokio::test]
async fn default_permissions_can_select_builtin_no_sandbox_profile() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":danger-no-sandbox".to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
config.permissions.permission_profile(),
PermissionProfile::Disabled
);
Ok(())
}
#[tokio::test]
async fn user_defined_permission_profile_names_cannot_use_builtin_prefix() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":custom".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
":custom".to_string(),
PermissionProfileToml::default(),
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("reserved profile name should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"permissions profile `:custom` uses a reserved built-in profile prefix"
);
Ok(())
}
#[tokio::test]
async fn unknown_builtin_permission_profile_name_is_rejected() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some(":unknown".to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect_err("unknown built-in profile name should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"default_permissions refers to unknown built-in profile `:unknown`"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
-> std::io::Result<()> {

View File

@@ -99,9 +99,12 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::config::permissions::compile_permission_profile;
use crate::config::permissions::builtin_permission_profile;
use crate::config::permissions::compile_permission_profile_selection;
use crate::config::permissions::default_builtin_permission_profile_name;
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
use crate::config::permissions::network_proxy_config_from_profile_network;
use crate::config::permissions::network_proxy_config_for_profile_selection;
use crate::config::permissions::validate_user_permission_profile_names;
use codex_network_proxy::NetworkProxyConfig;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
@@ -1861,6 +1864,7 @@ impl Config {
.permissions
.as_ref()
.is_some_and(|profiles| !profiles.is_empty());
validate_user_permission_profile_names(cfg.permissions.as_ref())?;
if has_permission_profiles
&& !matches!(
permission_config_syntax,
@@ -1891,8 +1895,7 @@ impl Config {
let profiles_are_active = matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
) || (permission_config_syntax.is_none()
&& has_permission_profiles);
) || permission_config_syntax.is_none();
let (
configured_network_proxy_config,
permission_profile,
@@ -1902,24 +1905,19 @@ impl Config {
permission_profile.to_runtime_permissions();
let configured_network_proxy_config =
if network_sandbox_policy.is_enabled() && profiles_are_active {
let permissions = cfg.permissions.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"default_permissions requires a `[permissions]` table",
)
})?;
let default_permissions = cfg.default_permissions.as_deref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"default_permissions requires a named permissions profile",
)
})?;
let profile = resolve_permission_profile(permissions, default_permissions)?;
// PermissionProfile carries the active network sandbox bit, not the configured
// proxy/allowlist policy. Keep that config so active profiles can round-trip
// without broadening network behavior.
network_proxy_config_from_profile_network(profile.network.as_ref())
let default_permissions = cfg.default_permissions.as_deref().unwrap_or_else(|| {
default_builtin_permission_profile_name(
&active_project,
windows_sandbox_level,
)
});
network_proxy_config_for_profile_selection(
cfg.permissions.as_ref(),
default_permissions,
)?
} else {
NetworkProxyConfig::default()
};
@@ -1947,32 +1945,27 @@ impl Config {
file_system_sandbox_policy,
)
} else if profiles_are_active {
let permissions = cfg.permissions.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"default_permissions requires a `[permissions]` table",
)
})?;
let default_permissions = cfg.default_permissions.as_deref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"default_permissions requires a named permissions profile",
)
})?;
let profile = resolve_permission_profile(permissions, default_permissions)?;
let configured_network_proxy_config =
network_proxy_config_from_profile_network(profile.network.as_ref());
let default_permissions = cfg.default_permissions.as_deref().unwrap_or_else(|| {
default_builtin_permission_profile_name(&active_project, windows_sandbox_level)
});
let configured_network_proxy_config = network_proxy_config_for_profile_selection(
cfg.permissions.as_ref(),
default_permissions,
)?;
let (mut file_system_sandbox_policy, network_sandbox_policy) =
compile_permission_profile(
permissions,
compile_permission_profile_selection(
cfg.permissions.as_ref(),
default_permissions,
resolved_cwd.as_path(),
&mut startup_warnings,
)?;
let mut permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
network_sandbox_policy,
);
let mut permission_profile = builtin_permission_profile(default_permissions)
.unwrap_or_else(|| {
PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
network_sandbox_policy,
)
});
let sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
&permission_profile,
&file_system_sandbox_policy,

View File

@@ -12,6 +12,8 @@ use codex_config::permissions_toml::PermissionsToml;
use codex_network_proxy::NetworkProxyConfig;
#[cfg(test)]
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -20,6 +22,64 @@ use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::ProjectConfig;
pub(crate) const BUILT_IN_READ_ONLY_PROFILE: &str = ":read-only";
pub(crate) const BUILT_IN_WORKSPACE_PROFILE: &str = ":workspace";
pub(crate) const BUILT_IN_DANGER_NO_SANDBOX_PROFILE: &str = ":danger-no-sandbox";
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_NO_SANDBOX_PROFILE
)
}
pub(crate) fn builtin_permission_profile(profile_name: &str) -> Option<PermissionProfile> {
match profile_name {
BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()),
BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()),
BUILT_IN_DANGER_NO_SANDBOX_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 {
@@ -41,6 +101,27 @@ pub(crate) fn resolve_permission_profile<'a>(
})
}
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,
@@ -103,6 +184,37 @@ pub(crate) fn compile_permission_profile(
Ok((file_system_sandbox_policy, network_sandbox_policy))
}
pub(crate) fn compile_permission_profile_selection(
permissions: Option<&PermissionsToml>,
profile_name: &str,
policy_cwd: &Path,
startup_warnings: &mut Vec<String>,
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
if let Some(permission_profile) = builtin_permission_profile(profile_name) {
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)
}
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.