config: add v3 filesystem permission profiles

This commit is contained in:
Michael Bolin
2026-03-03 22:54:50 -08:00
parent d622bff384
commit 7abd70178a
8 changed files with 1432 additions and 147 deletions

View File

@@ -562,6 +562,30 @@
},
"type": "object"
},
"FileSystemAccessMode": {
"enum": [
"none",
"read",
"write"
],
"type": "string"
},
"FilesystemPermissionToml": {
"anyOf": [
{
"$ref": "#/definitions/FileSystemAccessMode"
},
{
"additionalProperties": {
"$ref": "#/definitions/FileSystemAccessMode"
},
"type": "object"
}
]
},
"FilesystemPermissionsToml": {
"type": "object"
},
"ForcedLoginMethod": {
"enum": [
"chatgpt",
@@ -1089,20 +1113,30 @@
},
"type": "object"
},
"PermissionsToml": {
"PermissionProfileNetworkToml": {
"additionalProperties": false,
"properties": {
"network": {
"allOf": [
{
"$ref": "#/definitions/NetworkToml"
}
],
"description": "Network proxy settings from `[permissions.network]`. User config can enable the proxy; managed requirements may still constrain values."
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"PermissionProfileToml": {
"additionalProperties": false,
"properties": {
"filesystem": {
"$ref": "#/definitions/FilesystemPermissionsToml"
},
"network": {
"$ref": "#/definitions/PermissionProfileNetworkToml"
}
},
"type": "object"
},
"PermissionsToml": {
"type": "object"
},
"Personality": {
"enum": [
"none",
@@ -1663,6 +1697,10 @@
"description": "Compact prompt used for history compaction.",
"type": "string"
},
"default_permissions": {
"description": "Default named permissions profile to apply from the `[permissions]` table.",
"type": "string"
},
"developer_instructions": {
"default": null,
"description": "Developer instructions inserted as a `developer` role message.",
@@ -2025,6 +2063,15 @@
],
"description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`)."
},
"network": {
"allOf": [
{
"$ref": "#/definitions/NetworkToml"
}
],
"default": null,
"description": "Top-level network proxy settings."
},
"notice": {
"allOf": [
{
@@ -2060,7 +2107,7 @@
}
],
"default": null,
"description": "Nested permissions settings."
"description": "Named permissions profiles."
},
"personality": {
"allOf": [

View File

@@ -27,6 +27,7 @@ use crate::config::types::WindowsSandboxModeToml;
use crate::config::types::WindowsToml;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConstrainedWithSource;
use crate::config_loader::LoaderOverrides;
@@ -49,6 +50,10 @@ use crate::model_provider_info::built_in_model_providers;
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
use crate::protocol::AskForApproval;
#[cfg(test)]
use crate::protocol::FileSystemAccessMode;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS;
@@ -86,7 +91,8 @@ use std::path::PathBuf;
#[cfg(test)]
use tempfile::tempdir;
use crate::config::permissions::network_proxy_config_from_permissions;
use crate::config::permissions::compile_permission_profile;
use crate::config::permissions::network_proxy_config_from_network;
use crate::config::profile::ConfigProfile;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
@@ -107,7 +113,11 @@ pub use codex_network_proxy::NetworkProxyAuditMetadata;
pub use managed_features::ManagedFeatures;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub use permissions::FilesystemPermissionToml;
pub use permissions::FilesystemPermissionsToml;
pub use permissions::NetworkToml;
pub use permissions::PermissionProfileNetworkToml;
pub use permissions::PermissionProfileToml;
pub use permissions::PermissionsToml;
pub use service::ConfigService;
pub use service::ConfigServiceError;
@@ -155,6 +165,12 @@ pub struct Permissions {
pub approval_policy: Constrained<AskForApproval>,
/// Effective sandbox policy used for shell/unified exec.
pub sandbox_policy: Constrained<SandboxPolicy>,
/// Effective filesystem sandbox policy, including entries that cannot yet
/// be fully represented by the legacy [`SandboxPolicy`] projection.
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
/// Effective network sandbox policy split out from the legacy
/// [`SandboxPolicy`] projection.
pub network_sandbox_policy: NetworkSandboxPolicy,
/// Effective network configuration applied to all spawned processes.
pub network: Option<NetworkProxySpec>,
/// Whether the model may request a login shell for shell-based tools.
@@ -1047,10 +1063,18 @@ pub struct ConfigToml {
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
/// Nested permissions settings.
/// Default named permissions profile to apply from the `[permissions]`
/// table.
pub default_permissions: Option<String>,
/// Named permissions profiles.
#[serde(default)]
pub permissions: Option<PermissionsToml>,
/// Top-level network proxy settings.
#[serde(default)]
pub network: Option<NetworkToml>,
/// Optional external command to spawn for end-user notifications.
#[serde(default)]
pub notify: Option<Vec<String>>,
@@ -1565,6 +1589,78 @@ impl ConfigToml {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionConfigSyntax {
Legacy,
Profiles,
}
#[derive(Debug, Deserialize, Default)]
struct PermissionSelectionToml {
default_permissions: Option<String>,
sandbox_mode: Option<SandboxMode>,
}
fn resolve_permission_config_syntax(
config_layer_stack: &ConfigLayerStack,
cfg: &ConfigToml,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
) -> Option<PermissionConfigSyntax> {
if sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() {
return Some(PermissionConfigSyntax::Legacy);
}
let mut selection = None;
for layer in
config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false)
{
let Ok(layer_selection) = layer.config.clone().try_into::<PermissionSelectionToml>() else {
continue;
};
if layer_selection.sandbox_mode.is_some() {
selection = Some(PermissionConfigSyntax::Legacy);
}
if layer_selection.default_permissions.is_some() {
selection = Some(PermissionConfigSyntax::Profiles);
}
}
selection.or_else(|| {
if cfg.default_permissions.is_some() {
Some(PermissionConfigSyntax::Profiles)
} else if cfg.sandbox_mode.is_some() {
Some(PermissionConfigSyntax::Legacy)
} else {
None
}
})
}
fn add_additional_file_system_writes(
file_system_sandbox_policy: &mut FileSystemSandboxPolicy,
additional_writable_roots: &[AbsolutePathBuf],
) {
for path in additional_writable_roots {
let exists = file_system_sandbox_policy.entries.iter().any(|entry| {
matches!(
&entry.path,
crate::protocol::FileSystemPath::Path { path: existing }
if existing == path && entry.access == crate::protocol::FileSystemAccessMode::Write
)
});
if !exists {
file_system_sandbox_policy
.entries
.push(crate::protocol::FileSystemSandboxEntry {
path: crate::protocol::FileSystemPath::Path { path: path.clone() },
access: crate::protocol::FileSystemAccessMode::Write,
});
}
}
}
/// Optional overrides for user configuration (e.g., from CLI flags).
#[derive(Default, Debug, Clone)]
pub struct ConfigOverrides {
@@ -1753,7 +1849,7 @@ impl Config {
None => ConfigProfile::default(),
};
let configured_network_proxy_config =
network_proxy_config_from_permissions(cfg.permissions.as_ref());
network_proxy_config_from_network(cfg.network.as_ref());
let feature_overrides = FeatureOverrides {
include_apply_patch_tool: include_apply_patch_tool_override,
@@ -1788,29 +1884,112 @@ impl Config {
let active_project = cfg
.get_active_project(&resolved_cwd)
.unwrap_or(ProjectConfig { trust_level: None });
let permission_config_syntax = resolve_permission_config_syntax(
&config_layer_stack,
&cfg,
sandbox_mode,
config_profile.sandbox_mode,
);
let has_permission_profiles = cfg
.permissions
.as_ref()
.is_some_and(|profiles| !profiles.is_empty());
let legacy_permissions_network_table =
cfg.permissions.as_ref().is_some_and(|permissions| {
permissions.entries.get("network").is_some_and(|profile| {
profile.filesystem.is_none() && profile.network.is_none()
})
});
if legacy_permissions_network_table {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"legacy `[permissions.network]` is no longer supported; move this config to the top-level `[network]` table",
));
}
if has_permission_profiles
&& !matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Legacy)
)
&& cfg.default_permissions.is_none()
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config defines `[permissions]` profiles but does not set `default_permissions`",
));
}
let sandbox_mode_was_explicit = sandbox_mode.is_some()
|| config_profile.sandbox_mode.is_some()
|| cfg.sandbox_mode.is_some();
|| cfg.sandbox_mode.is_some()
|| matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
);
let windows_sandbox_level = match windows_sandbox_mode {
Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated,
Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken,
None => WindowsSandboxLevel::from_features(&features),
};
let mut sandbox_policy = cfg.derive_sandbox_policy(
sandbox_mode,
config_profile.sandbox_mode,
windows_sandbox_level,
&resolved_cwd,
Some(&constrained_sandbox_policy),
);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
for path in additional_writable_roots {
if !writable_roots.iter().any(|existing| existing == &path) {
writable_roots.push(path);
let profiles_are_active = matches!(
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
) || (permission_config_syntax.is_none()
&& has_permission_profiles);
let (sandbox_policy, file_system_sandbox_policy, network_sandbox_policy) =
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 (mut file_system_sandbox_policy, network_sandbox_policy) =
compile_permission_profile(permissions, default_permissions)?;
let mut sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
add_additional_file_system_writes(
&mut file_system_sandbox_policy,
&additional_writable_roots,
);
sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
}
}
}
(
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
)
} else {
let mut sandbox_policy = cfg.derive_sandbox_policy(
sandbox_mode,
config_profile.sandbox_mode,
windows_sandbox_level,
&resolved_cwd,
Some(&constrained_sandbox_policy),
);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
for path in &additional_writable_roots {
if !writable_roots.iter().any(|existing| existing == path) {
writable_roots.push(path.clone());
}
}
}
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
(
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
)
};
let approval_policy_was_explicit = approval_policy_override.is_some()
|| config_profile.approval_policy.is_some()
|| cfg.approval_policy.is_some();
@@ -2078,6 +2257,7 @@ impl Config {
.map(AbsolutePathBuf::to_path_buf)
.or_else(|| resolve_sqlite_home_env(&resolved_cwd))
.unwrap_or_else(|| codex_home.to_path_buf());
let original_sandbox_policy = sandbox_policy.clone();
apply_requirement_constrained_value(
"approval_policy",
@@ -2125,6 +2305,19 @@ impl Config {
} else {
network.enabled().then_some(network)
};
let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone();
let effective_file_system_sandbox_policy =
if effective_sandbox_policy == original_sandbox_policy {
file_system_sandbox_policy
} else {
FileSystemSandboxPolicy::from(&effective_sandbox_policy)
};
let effective_network_sandbox_policy =
if effective_sandbox_policy == original_sandbox_policy {
network_sandbox_policy
} else {
NetworkSandboxPolicy::from(&effective_sandbox_policy)
};
let config = Self {
model,
@@ -2139,6 +2332,8 @@ impl Config {
permissions: Permissions {
approval_policy: constrained_approval_policy.value,
sandbox_policy: constrained_sandbox_policy.value,
file_system_sandbox_policy: effective_file_system_sandbox_policy,
network_sandbox_policy: effective_network_sandbox_policy,
network,
allow_login_shell,
shell_environment_policy,
@@ -2701,20 +2896,18 @@ consolidation_model = "gpt-5"
#[test]
fn config_toml_deserializes_permissions_network() {
let toml = r#"
[permissions.network]
[network]
enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
allow_upstream_proxy = false
allowed_domains = ["openai.com"]
"#;
let cfg: ConfigToml = toml::from_str(toml)
.expect("TOML deserialization should succeed for permissions.network");
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for [network]");
assert_eq!(
cfg.permissions
.and_then(|permissions| permissions.network)
.expect("permissions.network should deserialize"),
cfg.network.expect("[network] should deserialize"),
NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
@@ -2739,13 +2932,11 @@ allowed_domains = ["openai.com"]
fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
permissions: Some(PermissionsToml {
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
network: Some(NetworkToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
enable_socks5: Some(false),
..Default::default()
}),
..Default::default()
};
@@ -2770,11 +2961,9 @@ allowed_domains = ["openai.com"]
fn permissions_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
permissions: Some(PermissionsToml {
network: Some(NetworkToml {
allowed_domains: Some(vec!["openai.com".to_string()]),
..Default::default()
}),
network: Some(NetworkToml {
allowed_domains: Some(vec!["openai.com".to_string()]),
..Default::default()
}),
..Default::default()
};
@@ -2788,6 +2977,349 @@ allowed_domains = ["openai.com"]
Ok(())
}
#[test]
fn config_toml_deserializes_permission_profiles() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.filesystem.":project_roots"]
"." = "write"
"docs" = "read"
"#;
let cfg: ConfigToml = toml::from_str(toml)
.expect("TOML deserialization should succeed for permissions profiles");
assert_eq!(cfg.default_permissions.as_deref(), Some("workspace"));
assert_eq!(
cfg.permissions.expect("[permissions] should deserialize"),
PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([
(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("docs".to_string(), FileSystemAccessMode::Read),
])),
),
]),
}),
network: None,
},
)]),
}
);
}
#[test]
fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::create_dir_all(cwd.path().join("docs"))?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let cfg = ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([
(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("docs".to_string(), FileSystemAccessMode::Read),
])),
),
]),
}),
network: None,
},
)]),
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert_eq!(
config.permissions.file_system_sandbox_policy,
FileSystemSandboxPolicy::restricted(vec![
crate::protocol::FileSystemSandboxEntry {
path: crate::protocol::FileSystemPath::Special {
value: crate::protocol::FileSystemSpecialPath {
kind: crate::protocol::FileSystemSpecialPathKind::Minimal,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
},
crate::protocol::FileSystemSandboxEntry {
path: crate::protocol::FileSystemPath::Special {
value: crate::protocol::FileSystemSpecialPath {
kind: crate::protocol::FileSystemSpecialPathKind::ProjectRoots,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
},
crate::protocol::FileSystemSandboxEntry {
path: crate::protocol::FileSystemPath::Special {
value: crate::protocol::FileSystemSpecialPath {
kind: crate::protocol::FileSystemSpecialPathKind::ProjectRoots,
subpath: Some("docs".into()),
},
},
access: FileSystemAccessMode::Read,
},
]),
);
assert_eq!(
config.permissions.sandbox_policy.get(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![
AbsolutePathBuf::try_from(cwd.path().join("docs"))
.expect("absolute docs path"),
],
},
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
assert_eq!(
config.permissions.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
Ok(())
}
#[test]
fn permissions_profiles_require_default_permissions() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("missing default_permissions should be rejected");
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"config defines `[permissions]` profiles but does not set `default_permissions`"
);
Ok(())
}
#[test]
fn legacy_permissions_network_requires_migration_to_top_level_network() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let cfg: ConfigToml = toml::from_str(
r#"
[permissions.network]
enabled = true
proxy_url = "http://127.0.0.1:43128"
"#,
)
.expect("legacy permissions.network TOML should deserialize");
let err = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("legacy permissions.network should require migration");
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"legacy `[permissions.network]` is no longer supported; move this config to the top-level `[network]` table"
);
Ok(())
}
#[test]
fn legacy_permissions_network_requires_migration_even_with_legacy_sandbox_mode()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let cfg: ConfigToml = toml::from_str(
r#"
sandbox_mode = "read-only"
[permissions.network]
enabled = true
proxy_url = "http://127.0.0.1:43128"
"#,
)
.expect("legacy permissions.network TOML should deserialize");
let err = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("legacy permissions.network should require migration");
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"legacy `[permissions.network]` is no longer supported; move this config to the top-level `[network]` table"
);
Ok(())
}
#[test]
fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let external_write_path = if cfg!(windows) { r"C:\temp" } else { "/tmp" };
let err = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
external_write_path.to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("writes outside the workspace root should be rejected");
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("filesystem writes outside the workspace root"),
"{err}"
);
Ok(())
}
#[test]
fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":minimal".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
)]),
}),
network: Some(PermissionProfileNetworkToml {
enabled: Some(true),
}),
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert!(
config.permissions.network_sandbox_policy.is_enabled(),
"expected network sandbox policy to be enabled",
);
assert!(
config
.permissions
.sandbox_policy
.get()
.has_full_network_access()
);
Ok(())
}
#[test]
fn tui_theme_deserializes_from_toml() {
let cfg = r#"
@@ -5129,6 +5661,10 @@ model_verbosity = "high"
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -5259,6 +5795,10 @@ model_verbosity = "high"
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -5387,6 +5927,10 @@ model_verbosity = "high"
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -5501,6 +6045,10 @@ model_verbosity = "high"
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),

View File

@@ -1,15 +1,63 @@
use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::FileSystemSpecialPathKind;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PermissionsToml {
/// Network proxy settings from `[permissions.network]`.
/// User config can enable the proxy; managed requirements may still constrain values.
pub network: Option<NetworkToml>,
#[serde(flatten)]
pub entries: BTreeMap<String, PermissionProfileToml>,
}
impl PermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PermissionProfileToml {
pub filesystem: Option<FilesystemPermissionsToml>,
pub network: Option<PermissionProfileNetworkToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct FilesystemPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, FilesystemPermissionToml>,
}
impl FilesystemPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum FilesystemPermissionToml {
Access(FileSystemAccessMode),
Scoped(BTreeMap<String, FileSystemAccessMode>),
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PermissionProfileNetworkToml {
pub enabled: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
@@ -102,13 +150,161 @@ impl NetworkToml {
}
}
pub(crate) fn network_proxy_config_from_permissions(
permissions: Option<&PermissionsToml>,
pub(crate) fn network_proxy_config_from_network(
network: Option<&NetworkToml>,
) -> NetworkProxyConfig {
permissions
.and_then(|permissions| permissions.network.as_ref())
.map_or_else(
NetworkProxyConfig::default,
NetworkToml::to_network_proxy_config,
)
network.map_or_else(
NetworkProxyConfig::default,
NetworkToml::to_network_proxy_config,
)
}
pub(crate) fn compile_permission_profile(
permissions: &PermissionsToml,
profile_name: &str,
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
let profile = permissions.entries.get(profile_name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("default_permissions refers to undefined profile `{profile_name}`"),
)
})?;
let filesystem = profile.filesystem.as_ref().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"permissions profile `{profile_name}` must define a `[permissions.{profile_name}.filesystem]` table"
),
)
})?;
if filesystem.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"permissions profile `{profile_name}` must define at least one filesystem entry"
),
));
}
let mut entries = Vec::new();
for (path, permission) in &filesystem.entries {
compile_filesystem_permission(path, permission, &mut entries)?;
}
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
Ok((
FileSystemSandboxPolicy::restricted(entries),
network_sandbox_policy,
))
}
fn compile_network_sandbox_policy(
network: Option<&PermissionProfileNetworkToml>,
) -> 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,
entries: &mut Vec<FileSystemSandboxEntry>,
) -> io::Result<()> {
match permission {
FilesystemPermissionToml::Access(access) => entries.push(FileSystemSandboxEntry {
path: compile_filesystem_path(path)?,
access: *access,
}),
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
entries.push(FileSystemSandboxEntry {
path: compile_scoped_filesystem_path(path, subpath)?,
access: *access,
});
}
}
}
Ok(())
}
fn compile_filesystem_path(path: &str) -> io::Result<FileSystemPath> {
if let Some(special) = parse_special_path(path)? {
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) -> io::Result<FileSystemPath> {
if subpath == "." {
return compile_filesystem_path(path);
}
let subpath = Path::new(subpath);
if subpath.is_absolute() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem subpath `{}` must be relative",
subpath.display()
),
));
}
if let Some(mut special) = parse_special_path(path)? {
if !matches!(special.kind, FileSystemSpecialPathKind::ProjectRoots) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem path `{path}` does not support nested entries"),
));
}
special.subpath = Some(subpath.to_path_buf());
return Ok(FileSystemPath::Special { value: special });
}
let base = parse_absolute_path(path)?;
let path = AbsolutePathBuf::resolve_path_against_base(subpath, base.as_path())?;
Ok(FileSystemPath::Path { path })
}
fn parse_special_path(path: &str) -> io::Result<Option<FileSystemSpecialPath>> {
let kind = match path {
":root" => Some(FileSystemSpecialPathKind::Root),
":minimal" => Some(FileSystemSpecialPathKind::Minimal),
":project_roots" => Some(FileSystemSpecialPathKind::ProjectRoots),
":tmpdir" => Some(FileSystemSpecialPathKind::Tmpdir),
_ if path.starts_with(':') => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown filesystem special path `{path}`"),
));
}
_ => None,
};
Ok(kind.map(|kind| FileSystemSpecialPath {
kind,
subpath: None,
}))
}
fn parse_absolute_path(path: &str) -> io::Result<AbsolutePathBuf> {
let path_ref = Path::new(path);
if !path_ref.is_absolute() && 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)
}

View File

@@ -1,5 +1,4 @@
use crate::config::NetworkToml;
use crate::config::PermissionsToml;
use crate::config::find_codex_home;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
@@ -121,12 +120,6 @@ fn network_constraints_from_trusted_layers(
if let Some(network) = parsed.network {
apply_network_constraints(network, &mut constraints);
}
if let Some(network) = parsed
.permissions
.and_then(|permissions| permissions.network)
{
apply_network_constraints(network, &mut constraints);
}
}
Ok(constraints)
}
@@ -171,7 +164,6 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx
#[derive(Debug, Clone, Default, Deserialize)]
struct NetworkTablesToml {
network: Option<NetworkToml>,
permissions: Option<PermissionsToml>,
}
fn network_tables_from_toml(value: &toml::Value) -> Result<NetworkTablesToml> {
@@ -185,12 +177,6 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo
if let Some(network) = parsed.network {
network.apply_to_network_proxy_config(config);
}
if let Some(network) = parsed
.permissions
.and_then(|permissions| permissions.network)
{
network.apply_to_network_proxy_config(config);
}
}
fn config_from_layers(
@@ -315,10 +301,10 @@ mod tests {
use pretty_assertions::assert_eq;
#[test]
fn higher_precedence_network_table_beats_lower_permissions_network_table() {
let lower_permissions: toml::Value = toml::from_str(
fn higher_precedence_network_table_beats_lower_network_table() {
let lower_network: toml::Value = toml::from_str(
r#"
[permissions.network]
[network]
allowed_domains = ["lower.example.com"]
"#,
)
@@ -334,7 +320,7 @@ allowed_domains = ["higher.example.com"]
let mut config = NetworkProxyConfig::default();
apply_network_tables(
&mut config,
network_tables_from_toml(&lower_permissions).expect("lower layer should deserialize"),
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
);
apply_network_tables(
&mut config,

View File

@@ -12,6 +12,10 @@ use codex_protocol::models::MacOsSeatbeltProfileExtensions;
#[cfg(any(unix, test))]
use codex_protocol::models::PermissionProfile;
#[cfg(any(unix, test))]
use codex_protocol::protocol::FileSystemSandboxPolicy;
#[cfg(any(unix, test))]
use codex_protocol::protocol::NetworkSandboxPolicy;
#[cfg(any(unix, test))]
use codex_utils_absolute_path::AbsolutePathBuf;
#[cfg(any(unix, test))]
use dunce::canonicalize as canonicalize_path;
@@ -89,10 +93,14 @@ pub(crate) fn compile_permission_profile(
let macos_permissions = macos.unwrap_or_default();
let macos_seatbelt_profile_extensions =
build_macos_seatbelt_profile_extensions(&macos_permissions);
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
Some(Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(sandbox_policy),
file_system_sandbox_policy,
network_sandbox_policy,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -234,6 +242,8 @@ mod tests {
use codex_protocol::models::MacOsPreferencesValue;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
@@ -243,6 +253,25 @@ mod tests {
AbsolutePathBuf::try_from(path).expect("absolute path")
}
fn expected_permissions(sandbox_policy: SandboxPolicy) -> Permissions {
Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(sandbox_policy.clone()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: Some(
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
),
#[cfg(not(target_os = "macos"))]
macos_seatbelt_profile_extensions: None,
}
}
#[test]
fn compile_permission_profile_normalizes_paths() {
let tempdir = tempfile::tempdir().expect("tempdir");
@@ -269,37 +298,24 @@ mod tests {
assert_eq!(
profile,
Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::WorkspaceWrite {
writable_roots: vec![
AbsolutePathBuf::try_from(skill_dir.join("output"))
.expect("absolute output path")
expected_permissions(SandboxPolicy::WorkspaceWrite {
writable_roots: vec![
AbsolutePathBuf::try_from(skill_dir.join("output"))
.expect("absolute output path")
],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![
AbsolutePathBuf::try_from(
dunce::canonicalize(&read_dir).unwrap_or(read_dir)
)
.expect("absolute read path")
],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![
AbsolutePathBuf::try_from(
dunce::canonicalize(&read_dir).unwrap_or(read_dir)
)
.expect("absolute read path")
],
},
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: Some(
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
),
#[cfg(not(target_os = "macos"))]
macos_seatbelt_profile_extensions: None,
}
},
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
})
);
}
@@ -330,23 +346,10 @@ mod tests {
assert_eq!(
profile,
Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: true,
}),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: Some(
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
),
#[cfg(not(target_os = "macos"))]
macos_seatbelt_profile_extensions: None,
}
expected_permissions(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: true,
})
);
}
@@ -371,31 +374,18 @@ mod tests {
assert_eq!(
profile,
Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![
AbsolutePathBuf::try_from(
dunce::canonicalize(&read_dir).unwrap_or(read_dir)
)
.expect("absolute read path")
],
},
network_access: true,
}),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: Some(
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
),
#[cfg(not(target_os = "macos"))]
macos_seatbelt_profile_extensions: None,
}
expected_permissions(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![
AbsolutePathBuf::try_from(
dunce::canonicalize(&read_dir).unwrap_or(read_dir)
)
.expect("absolute read path")
],
},
network_access: true,
})
);
}

View File

@@ -30,6 +30,10 @@ use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
#[cfg(target_os = "macos")]
use codex_protocol::protocol::FileSystemSandboxPolicy;
#[cfg(target_os = "macos")]
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_shell_escalation::EscalationExecution;
use codex_shell_escalation::EscalationPermissions;
use codex_shell_escalation::ExecResult;
@@ -481,6 +485,10 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
let permissions = Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),

View File

@@ -2234,7 +2234,7 @@ async fn denying_network_policy_amendment_persists_policy_and_skips_future_netwo
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
r#"[permissions.network]
r#"[network]
enabled = true
mode = "limited"
allow_local_binding = true

View File

@@ -7,6 +7,7 @@ use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fmt;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
@@ -537,6 +538,279 @@ impl NetworkAccess {
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum NetworkSandboxPolicy {
#[default]
Restricted,
Enabled,
}
impl NetworkSandboxPolicy {
pub fn is_enabled(self) -> bool {
matches!(self, NetworkSandboxPolicy::Enabled)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum FileSystemAccessMode {
None,
Read,
Write,
}
impl FileSystemAccessMode {
pub fn can_read(self) -> bool {
!matches!(self, FileSystemAccessMode::None)
}
pub fn can_write(self) -> bool {
matches!(self, FileSystemAccessMode::Write)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum FileSystemSpecialPathKind {
Root,
Minimal,
CurrentWorkingDirectory,
ProjectRoots,
Tmpdir,
SlashTmp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub struct FileSystemSpecialPath {
pub kind: FileSystemSpecialPathKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub subpath: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum FileSystemPath {
Path { path: AbsolutePathBuf },
Special { value: FileSystemSpecialPath },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
pub struct FileSystemSandboxEntry {
pub path: FileSystemPath,
pub access: FileSystemAccessMode,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum FileSystemSandboxKind {
#[default]
Restricted,
Unrestricted,
ExternalSandbox,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
pub struct FileSystemSandboxPolicy {
pub kind: FileSystemSandboxKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<FileSystemSandboxEntry>,
}
impl Default for FileSystemSandboxPolicy {
fn default() -> Self {
Self {
kind: FileSystemSandboxKind::Restricted,
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
}],
}
}
}
impl FileSystemSandboxPolicy {
pub fn unrestricted() -> Self {
Self {
kind: FileSystemSandboxKind::Unrestricted,
entries: Vec::new(),
}
}
pub fn external_sandbox() -> Self {
Self {
kind: FileSystemSandboxKind::ExternalSandbox,
entries: Vec::new(),
}
}
pub fn restricted(entries: Vec<FileSystemSandboxEntry>) -> Self {
Self {
kind: FileSystemSandboxKind::Restricted,
entries,
}
}
pub fn to_legacy_sandbox_policy(
&self,
network_policy: NetworkSandboxPolicy,
cwd: &Path,
) -> io::Result<SandboxPolicy> {
Ok(match self.kind {
FileSystemSandboxKind::ExternalSandbox => SandboxPolicy::ExternalSandbox {
network_access: if network_policy.is_enabled() {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
},
},
FileSystemSandboxKind::Unrestricted => {
if network_policy.is_enabled() {
SandboxPolicy::DangerFullAccess
} else {
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}
}
}
FileSystemSandboxKind::Restricted => {
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
let mut include_platform_defaults = false;
let mut has_full_disk_read_access = false;
let mut has_full_disk_write_access = false;
let mut workspace_root_writable = false;
let mut writable_roots = Vec::new();
let mut readable_roots = Vec::new();
let mut tmpdir_writable = false;
let mut slash_tmp_writable = false;
for entry in &self.entries {
match &entry.path {
FileSystemPath::Path { path } => {
if entry.access.can_write() {
if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) {
workspace_root_writable = true;
} else {
writable_roots.push(path.clone());
}
} else if entry.access.can_read() {
readable_roots.push(path.clone());
}
}
FileSystemPath::Special { value } => match value.kind {
FileSystemSpecialPathKind::Root => match entry.access {
FileSystemAccessMode::None => {}
FileSystemAccessMode::Read => has_full_disk_read_access = true,
FileSystemAccessMode::Write => {
has_full_disk_read_access = true;
has_full_disk_write_access = true;
}
},
FileSystemSpecialPathKind::Minimal => {
if entry.access.can_read() {
include_platform_defaults = true;
}
}
FileSystemSpecialPathKind::CurrentWorkingDirectory
| FileSystemSpecialPathKind::ProjectRoots => {
if value.subpath.is_none() && entry.access.can_write() {
workspace_root_writable = true;
} else if let Some(path) =
resolve_file_system_special_path(value, cwd_absolute.as_ref())
{
if entry.access.can_write() {
writable_roots.push(path);
} else if entry.access.can_read() {
readable_roots.push(path);
}
}
}
FileSystemSpecialPathKind::Tmpdir => {
if entry.access.can_write() {
tmpdir_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
}
}
FileSystemSpecialPathKind::SlashTmp => {
if entry.access.can_write() {
slash_tmp_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
}
}
},
}
}
if has_full_disk_write_access {
return Ok(if network_policy.is_enabled() {
SandboxPolicy::DangerFullAccess
} else {
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}
});
}
let read_only_access = if has_full_disk_read_access {
ReadOnlyAccess::FullAccess
} else {
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots: dedup_absolute_paths(readable_roots),
}
};
if workspace_root_writable {
SandboxPolicy::WorkspaceWrite {
writable_roots: dedup_absolute_paths(writable_roots),
read_only_access,
network_access: network_policy.is_enabled(),
exclude_tmpdir_env_var: !tmpdir_writable,
exclude_slash_tmp: !slash_tmp_writable,
}
} else if !writable_roots.is_empty() || tmpdir_writable || slash_tmp_writable {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly",
));
} else {
SandboxPolicy::ReadOnly {
access: read_only_access,
network_access: network_policy.is_enabled(),
}
}
}
})
}
}
fn default_include_platform_defaults() -> bool {
true
}
@@ -923,6 +1197,212 @@ impl SandboxPolicy {
}
}
impl From<&SandboxPolicy> for NetworkSandboxPolicy {
fn from(value: &SandboxPolicy) -> Self {
if value.has_full_network_access() {
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
}
}
}
impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
fn from(value: &SandboxPolicy) -> Self {
match value {
SandboxPolicy::DangerFullAccess => FileSystemSandboxPolicy::unrestricted(),
SandboxPolicy::ExternalSandbox { .. } => FileSystemSandboxPolicy::external_sandbox(),
SandboxPolicy::ReadOnly { access, .. } => {
let mut entries = Vec::new();
match access {
ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
}),
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots,
} => {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
});
if *include_platform_defaults {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Minimal,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
});
}
entries.extend(readable_roots.iter().cloned().map(|path| {
FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
}
}));
}
}
FileSystemSandboxPolicy::restricted(entries)
}
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
} => {
let mut entries = Vec::new();
match read_only_access {
ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
}),
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots,
} => {
if *include_platform_defaults {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Minimal,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
});
}
entries.extend(readable_roots.iter().cloned().map(|path| {
FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
}
}));
}
}
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
});
if !exclude_slash_tmp {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::SlashTmp,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
});
}
if !exclude_tmpdir_env_var {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Tmpdir,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
});
}
entries.extend(
writable_roots
.iter()
.cloned()
.map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
}),
);
FileSystemSandboxPolicy::restricted(entries)
}
}
}
}
fn resolve_file_system_special_path(
value: &FileSystemSpecialPath,
cwd: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
match value.kind {
FileSystemSpecialPathKind::Root | FileSystemSpecialPathKind::Minimal => None,
FileSystemSpecialPathKind::CurrentWorkingDirectory
| FileSystemSpecialPathKind::ProjectRoots => {
let cwd = cwd?;
match value.subpath.as_ref() {
Some(subpath) => {
AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path()).ok()
}
None => Some(cwd.clone()),
}
}
FileSystemSpecialPathKind::Tmpdir => {
let tmpdir = std::env::var_os("TMPDIR")?;
if tmpdir.is_empty() {
None
} else {
let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?;
match value.subpath.as_ref() {
Some(subpath) => {
AbsolutePathBuf::resolve_path_against_base(subpath, tmpdir.as_path()).ok()
}
None => Some(tmpdir),
}
}
}
FileSystemSpecialPathKind::SlashTmp => {
#[allow(clippy::expect_used)]
let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
if !slash_tmp.as_path().is_dir() {
return None;
}
match value.subpath.as_ref() {
Some(subpath) => {
AbsolutePathBuf::resolve_path_against_base(subpath, slash_tmp.as_path()).ok()
}
None => Some(slash_tmp),
}
}
}
}
fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
let mut deduped = Vec::with_capacity(paths.len());
let mut seen = HashSet::new();
for path in paths {
if seen.insert(path.to_path_buf()) {
deduped.push(path);
}
}
deduped
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
@@ -3201,6 +3681,36 @@ mod tests {
}
}
#[test]
fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() {
let cwd = if cfg!(windows) {
Path::new(r"C:\workspace")
} else {
Path::new("/tmp/workspace")
};
let external_write_path = if cfg!(windows) {
AbsolutePathBuf::from_absolute_path(r"C:\temp").expect("absolute windows temp path")
} else {
AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute tmp path")
};
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: external_write_path,
},
access: FileSystemAccessMode::Write,
}]);
let err = policy
.to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd)
.expect_err("non-workspace writes should be rejected");
assert!(
err.to_string()
.contains("filesystem writes outside the workspace root"),
"{err}"
);
}
#[test]
fn item_started_event_from_web_search_emits_begin_event() {
let event = ItemStartedEvent {