Compare commits

...

10 Commits

Author SHA1 Message Date
Michael Bolin
0fbe89feee linux-sandbox: honor split filesystem policies in bwrap 2026-03-04 01:58:13 -08:00
Michael Bolin
304b726043 protocol: keep root carveouts sandboxed 2026-03-04 01:56:05 -08:00
Michael Bolin
cfa19e21f5 sandboxing: preserve denied paths when widening permissions 2026-03-04 01:46:54 -08:00
Michael Bolin
6790e7fa4a linux-sandbox: plumb split sandbox policies through helper 2026-03-04 01:44:48 -08:00
Michael Bolin
ed7a864c46 seatbelt: honor split filesystem sandbox policies 2026-03-04 01:44:48 -08:00
Michael Bolin
1434bba73c safety: honor filesystem policy carveouts in apply_patch 2026-03-04 01:44:48 -08:00
Michael Bolin
fab10a6b54 artifacts: honor filesystem policy carveouts in path checks 2026-03-04 01:44:48 -08:00
Michael Bolin
2adc12ed7c protocol: derive effective file access from filesystem policies 2026-03-04 01:44:48 -08:00
Michael Bolin
6c47eda8a4 sandboxing: plumb split sandbox policies through runtime 2026-03-04 01:44:48 -08:00
Michael Bolin
7abd70178a config: add v3 filesystem permission profiles 2026-03-04 01:10:16 -08:00
34 changed files with 3571 additions and 537 deletions

View File

@@ -1506,9 +1506,19 @@ impl CodexMessageProcessor {
};
let requested_policy = params.sandbox_policy.map(|policy| policy.to_core());
let effective_policy = match requested_policy {
let (
effective_policy,
effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
) = match requested_policy {
Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) {
Ok(()) => policy,
Ok(()) => {
let file_system_sandbox_policy =
codex_protocol::protocol::FileSystemSandboxPolicy::from(&policy);
let network_sandbox_policy =
codex_protocol::protocol::NetworkSandboxPolicy::from(&policy);
(policy, file_system_sandbox_policy, network_sandbox_policy)
}
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
@@ -1519,7 +1529,11 @@ impl CodexMessageProcessor {
return;
}
},
None => self.config.permissions.sandbox_policy.get().clone(),
None => (
self.config.permissions.sandbox_policy.get().clone(),
self.config.permissions.file_system_sandbox_policy.clone(),
self.config.permissions.network_sandbox_policy,
),
};
let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
@@ -1534,6 +1548,8 @@ impl CodexMessageProcessor {
match codex_core::exec::process_exec_tool_call(
exec_params,
&effective_policy,
&effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
use_linux_sandbox_bwrap,

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

@@ -40,6 +40,7 @@ pub(crate) async fn apply_patch(
&action,
turn_context.approval_policy.value(),
turn_context.sandbox_policy.get(),
&turn_context.file_system_sandbox_policy,
&turn_context.cwd,
turn_context.windows_sandbox_level,
) {

View File

@@ -222,10 +222,12 @@ use crate::protocol::ErrorEvent;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::ExecApprovalRequestEvent;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::McpServerRefreshConfig;
use crate::protocol::ModelRerouteEvent;
use crate::protocol::ModelRerouteReason;
use crate::protocol::NetworkApprovalContext;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::Op;
use crate::protocol::PlanDeltaEvent;
use crate::protocol::RateLimitSnapshot;
@@ -482,6 +484,8 @@ impl Codex {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -676,6 +680,8 @@ pub(crate) struct TurnContext {
pub(crate) personality: Option<Personality>,
pub(crate) approval_policy: Constrained<AskForApproval>,
pub(crate) sandbox_policy: Constrained<SandboxPolicy>,
pub(crate) file_system_sandbox_policy: FileSystemSandboxPolicy,
pub(crate) network_sandbox_policy: NetworkSandboxPolicy,
pub(crate) network: Option<NetworkProxy>,
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
@@ -764,6 +770,8 @@ impl TurnContext {
personality: self.personality,
approval_policy: self.approval_policy.clone(),
sandbox_policy: self.sandbox_policy.clone(),
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
network_sandbox_policy: self.network_sandbox_policy,
network: self.network.clone(),
windows_sandbox_level: self.windows_sandbox_level,
shell_environment_policy: self.shell_environment_policy.clone(),
@@ -867,6 +875,8 @@ pub(crate) struct SessionConfiguration {
approval_policy: Constrained<AskForApproval>,
/// How to sandbox commands executed in the system
sandbox_policy: Constrained<SandboxPolicy>,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
/// Working directory that should be treated as the *root* of the
@@ -933,6 +943,10 @@ impl SessionConfiguration {
}
if let Some(sandbox_policy) = updates.sandbox_policy.clone() {
next_configuration.sandbox_policy.set(sandbox_policy)?;
next_configuration.file_system_sandbox_policy =
FileSystemSandboxPolicy::from(next_configuration.sandbox_policy.get());
next_configuration.network_sandbox_policy =
NetworkSandboxPolicy::from(next_configuration.sandbox_policy.get());
}
if let Some(windows_sandbox_level) = updates.windows_sandbox_level {
next_configuration.windows_sandbox_level = windows_sandbox_level;
@@ -1145,6 +1159,8 @@ impl Session {
personality: session_configuration.personality,
approval_policy: session_configuration.approval_policy.clone(),
sandbox_policy: session_configuration.sandbox_policy.clone(),
file_system_sandbox_policy: session_configuration.file_system_sandbox_policy.clone(),
network_sandbox_policy: session_configuration.network_sandbox_policy,
network,
windows_sandbox_level: session_configuration.windows_sandbox_level,
shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(),
@@ -4805,6 +4821,8 @@ async fn spawn_review_thread(
personality: parent_turn_context.personality,
approval_policy: parent_turn_context.approval_policy.clone(),
sandbox_policy: parent_turn_context.sandbox_policy.clone(),
file_system_sandbox_policy: parent_turn_context.file_system_sandbox_policy.clone(),
network_sandbox_policy: parent_turn_context.network_sandbox_policy,
network: parent_turn_context.network.clone(),
windows_sandbox_level: parent_turn_context.windows_sandbox_level,
shell_environment_policy: parent_turn_context.shell_environment_policy.clone(),
@@ -7832,6 +7850,8 @@ mod tests {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -7927,6 +7947,8 @@ mod tests {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -8247,6 +8269,8 @@ mod tests {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -8306,6 +8330,8 @@ mod tests {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -8398,6 +8424,8 @@ mod tests {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -8647,6 +8675,8 @@ mod tests {
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
network_sandbox_policy: config.permissions.network_sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
@@ -10072,11 +10102,15 @@ mod tests {
// Now retry the same command WITHOUT escalated permissions; should succeed.
// Force DangerFullAccess to avoid platform sandbox dependencies in tests.
Arc::get_mut(&mut turn_context)
.expect("unique turn context Arc")
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc");
turn_context_mut
.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
turn_context_mut.file_system_sandbox_policy =
FileSystemSandboxPolicy::from(turn_context_mut.sandbox_policy.get());
turn_context_mut.network_sandbox_policy =
NetworkSandboxPolicy::from(turn_context_mut.sandbox_policy.get());
let resp2 = handler
.handle(ToolInvocation {

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

@@ -24,11 +24,15 @@ use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandOutputDeltaEvent;
use crate::protocol::ExecOutputStream;
use crate::protocol::FileSystemSandboxKind;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::SandboxPolicy;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::should_require_platform_sandbox;
use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
@@ -149,9 +153,12 @@ pub struct StdoutStream {
pub tx_event: Sender<Event>,
}
#[allow(clippy::too_many_arguments)]
pub async fn process_exec_tool_call(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_cwd: &Path,
codex_linux_sandbox_exe: &Option<PathBuf>,
use_linux_sandbox_bwrap: bool,
@@ -159,22 +166,17 @@ pub async fn process_exec_tool_call(
) -> Result<ExecToolCallOutput> {
let windows_sandbox_level = params.windows_sandbox_level;
let enforce_managed_network = params.network.is_some();
let sandbox_type = match &sandbox_policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
if enforce_managed_network {
get_platform_sandbox(
windows_sandbox_level
!= codex_protocol::config_types::WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
}
_ => get_platform_sandbox(
let sandbox_type = if should_require_platform_sandbox(
file_system_sandbox_policy,
network_sandbox_policy,
enforce_managed_network,
) {
get_platform_sandbox(
windows_sandbox_level != codex_protocol::config_types::WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None),
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
};
tracing::debug!("Sandbox type: {sandbox_type:?}");
@@ -215,6 +217,8 @@ pub async fn process_exec_tool_call(
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
sandbox: sandbox_type,
enforce_managed_network,
network: network.as_ref(),
@@ -233,7 +237,6 @@ pub async fn process_exec_tool_call(
pub(crate) async fn execute_exec_env(
env: ExecRequest,
sandbox_policy: &SandboxPolicy,
stdout_stream: Option<StdoutStream>,
) -> Result<ExecToolCallOutput> {
let ExecRequest {
@@ -245,7 +248,9 @@ pub(crate) async fn execute_exec_env(
sandbox,
windows_sandbox_level,
sandbox_permissions,
sandbox_policy: _sandbox_policy_from_env,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
justification,
arg0,
} = env;
@@ -263,7 +268,15 @@ pub(crate) async fn execute_exec_env(
};
let start = Instant::now();
let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream).await;
let raw_output_result = exec(
params,
sandbox,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
stdout_stream,
)
.await;
let duration = start.elapsed();
finalize_exec_result(raw_output_result, sandbox, duration)
}
@@ -692,15 +705,16 @@ async fn exec(
params: ExecParams,
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
stdout_stream: Option<StdoutStream>,
) -> Result<RawExecToolCallOutput> {
#[cfg(target_os = "windows")]
if sandbox == SandboxType::WindowsRestrictedToken
&& !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
{
if should_use_windows_restricted_token_sandbox(
sandbox,
sandbox_policy,
file_system_sandbox_policy,
) {
return exec_windows_sandbox(params, sandbox_policy).await;
}
let ExecParams {
@@ -729,7 +743,7 @@ async fn exec(
args: args.into(),
arg0: arg0_ref,
cwd,
sandbox_policy,
network_sandbox_policy,
// The environment already has attempt-scoped proxy settings from
// apply_to_env_for_attempt above. Passing network here would reapply
// non-attempt proxy vars and drop attempt correlation metadata.
@@ -741,6 +755,20 @@ async fn exec(
consume_truncated_output(child, expiration, stdout_stream).await
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn should_use_windows_restricted_token_sandbox(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> bool {
sandbox == SandboxType::WindowsRestrictedToken
&& file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted
&& !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
}
/// Consumes the output of a child process, truncating it so it is suitable for
/// use as the output of a `shell` tool call. Also enforces specified timeout.
async fn consume_truncated_output(
@@ -1093,6 +1121,38 @@ mod tests {
assert_eq!(aggregated.truncated_after_lines, None);
}
#[test]
fn windows_restricted_token_skips_external_sandbox_policies() {
let policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
should_use_windows_restricted_token_sandbox(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
),
false
);
}
#[test]
fn windows_restricted_token_runs_for_legacy_restricted_policies() {
let policy = SandboxPolicy::new_read_only_policy();
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
should_use_windows_restricted_token_sandbox(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
),
true
);
}
#[cfg(unix)]
#[test]
fn sandbox_detection_flags_sigsys_exit_code() {
@@ -1135,6 +1195,8 @@ mod tests {
params,
SandboxType::None,
&SandboxPolicy::new_read_only_policy(),
&FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()),
NetworkSandboxPolicy::Restricted,
None,
)
.await?;
@@ -1190,6 +1252,8 @@ mod tests {
let result = process_exec_tool_call(
params,
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess),
NetworkSandboxPolicy::Enabled,
cwd.as_path(),
&None,
false,

View File

@@ -1,3 +1,5 @@
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::SandboxPolicy;
use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
@@ -13,9 +15,9 @@ use tokio::process::Child;
/// isolation plus seccomp for network restrictions.
///
/// Unlike macOS Seatbelt where we directly embed the policy text, the Linux
/// helper accepts a list of `--sandbox-permission`/`-s` flags mirroring the
/// public CLI. We convert the internal [`SandboxPolicy`] representation into
/// the equivalent CLI options.
/// helper is a separate executable. We pass the legacy [`SandboxPolicy`] plus
/// split filesystem/network policies as JSON so the helper can migrate
/// incrementally without breaking older call sites.
#[allow(clippy::too_many_arguments)]
pub async fn spawn_command_under_linux_sandbox<P>(
codex_linux_sandbox_exe: P,
@@ -31,9 +33,13 @@ pub async fn spawn_command_under_linux_sandbox<P>(
where
P: AsRef<Path>,
{
let args = create_linux_sandbox_command_args(
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(sandbox_policy);
let args = create_linux_sandbox_command_args_for_policies(
command,
sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd,
use_bwrap_sandbox,
allow_network_for_proxy(false),
@@ -44,7 +50,7 @@ where
args,
arg0,
cwd: command_cwd,
sandbox_policy,
network_sandbox_policy,
network,
stdio_policy,
env,
@@ -59,13 +65,55 @@ pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool {
enforce_managed_network
}
/// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`.
/// Converts the sandbox policies into the CLI invocation for
/// `codex-linux-sandbox`.
///
/// The helper performs the actual sandboxing (bubblewrap + seccomp) after
/// parsing these arguments. See `docs/linux_sandbox.md` for the Linux semantics.
pub(crate) fn create_linux_sandbox_command_args(
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_linux_sandbox_command_args_for_policies(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
use_bwrap_sandbox: bool,
allow_network_for_proxy: bool,
) -> Vec<String> {
#[expect(clippy::expect_used)]
let sandbox_policy_json =
serde_json::to_string(sandbox_policy).expect("Failed to serialize SandboxPolicy to JSON");
#[expect(clippy::expect_used)]
let file_system_policy_json = serde_json::to_string(file_system_sandbox_policy)
.expect("Failed to serialize FileSystemSandboxPolicy to JSON");
#[expect(clippy::expect_used)]
let network_policy_json = serde_json::to_string(&network_sandbox_policy)
.expect("Failed to serialize NetworkSandboxPolicy to JSON");
let mut linux_cmd = create_linux_sandbox_command_args(
command,
sandbox_policy_cwd,
use_bwrap_sandbox,
allow_network_for_proxy,
);
linux_cmd.splice(
2..2,
[
"--sandbox-policy".to_string(),
sandbox_policy_json,
"--file-system-sandbox-policy".to_string(),
file_system_policy_json,
"--network-sandbox-policy".to_string(),
network_policy_json,
],
);
linux_cmd
}
/// Converts the sandbox cwd and execution options into the CLI invocation for
/// `codex-linux-sandbox`.
pub(crate) fn create_linux_sandbox_command_args(
command: Vec<String>,
sandbox_policy_cwd: &Path,
use_bwrap_sandbox: bool,
allow_network_for_proxy: bool,
@@ -76,16 +124,7 @@ pub(crate) fn create_linux_sandbox_command_args(
.expect("cwd must be valid UTF-8")
.to_string();
#[expect(clippy::expect_used)]
let sandbox_policy_json =
serde_json::to_string(sandbox_policy).expect("Failed to serialize SandboxPolicy to JSON");
let mut linux_cmd: Vec<String> = vec![
"--sandbox-policy-cwd".to_string(),
sandbox_policy_cwd,
"--sandbox-policy".to_string(),
sandbox_policy_json,
];
let mut linux_cmd: Vec<String> = vec!["--sandbox-policy-cwd".to_string(), sandbox_policy_cwd];
if use_bwrap_sandbox {
linux_cmd.push("--use-bwrap-sandbox".to_string());
}
@@ -112,16 +151,14 @@ mod tests {
fn bwrap_flags_are_feature_gated() {
let command = vec!["/bin/true".to_string()];
let cwd = Path::new("/tmp");
let policy = SandboxPolicy::new_read_only_policy();
let with_bwrap =
create_linux_sandbox_command_args(command.clone(), &policy, cwd, true, false);
let with_bwrap = create_linux_sandbox_command_args(command.clone(), cwd, true, false);
assert_eq!(
with_bwrap.contains(&"--use-bwrap-sandbox".to_string()),
true
);
let without_bwrap = create_linux_sandbox_command_args(command, &policy, cwd, false, false);
let without_bwrap = create_linux_sandbox_command_args(command, cwd, false, false);
assert_eq!(
without_bwrap.contains(&"--use-bwrap-sandbox".to_string()),
false
@@ -132,15 +169,46 @@ mod tests {
fn proxy_flag_is_included_when_requested() {
let command = vec!["/bin/true".to_string()];
let cwd = Path::new("/tmp");
let policy = SandboxPolicy::new_read_only_policy();
let args = create_linux_sandbox_command_args(command, &policy, cwd, true, true);
let args = create_linux_sandbox_command_args(command, cwd, true, true);
assert_eq!(
args.contains(&"--allow-network-for-proxy".to_string()),
true
);
}
#[test]
fn split_policy_flags_are_included() {
let command = vec!["/bin/true".to_string()];
let cwd = Path::new("/tmp");
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
let args = create_linux_sandbox_command_args_for_policies(
command,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
cwd,
true,
false,
);
assert_eq!(
args.windows(2).any(|window| {
window[0] == "--file-system-sandbox-policy" && !window[1].is_empty()
}),
true
);
assert_eq!(
args.windows(2)
.any(|window| window[0] == "--network-sandbox-policy"
&& window[1] == "\"restricted\""),
true
);
}
#[test]
fn proxy_network_requires_managed_requirements() {
assert_eq!(allow_network_for_proxy(false), false);

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

@@ -9,6 +9,7 @@ use crate::exec::SandboxType;
use crate::util::resolve_path;
use crate::protocol::AskForApproval;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::SandboxPolicy;
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -28,6 +29,7 @@ pub fn assess_patch_safety(
action: &ApplyPatchAction,
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
windows_sandbox_level: WindowsSandboxLevel,
) -> SafetyCheck {
@@ -60,7 +62,7 @@ pub fn assess_patch_safety(
// Even though the patch appears to be constrained to writable paths, it is
// possible that paths in the patch are hard links to files outside the
// writable roots, so we should still run `apply_patch` in a sandbox in that case.
if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd)
if is_write_patch_constrained_to_writable_paths(action, file_system_sandbox_policy, cwd)
|| matches!(policy, AskForApproval::OnFailure)
{
if matches!(
@@ -122,20 +124,9 @@ pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option<SandboxType
fn is_write_patch_constrained_to_writable_paths(
action: &ApplyPatchAction,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> bool {
// Earlyexit if there are no declared writable roots.
let writable_roots = match sandbox_policy {
SandboxPolicy::ReadOnly { .. } => {
return false;
}
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
return true;
}
SandboxPolicy::WorkspaceWrite { .. } => sandbox_policy.get_writable_roots_with_cwd(cwd),
};
// Normalize a path by removing `.` and resolving `..` without touching the
// filesystem (works even if the file does not exist).
fn normalize(path: &Path) -> Option<PathBuf> {
@@ -152,6 +143,9 @@ fn is_write_patch_constrained_to_writable_paths(
Some(out)
}
let unreadable_roots = file_system_sandbox_policy.get_unreadable_roots_with_cwd(cwd);
let writable_roots = file_system_sandbox_policy.get_writable_roots_with_cwd(cwd);
// Determine whether `path` is inside **any** writable root. Both `path`
// and roots are converted to absolute, normalized forms before the
// prefix check.
@@ -162,6 +156,17 @@ fn is_write_patch_constrained_to_writable_paths(
None => return false,
};
if unreadable_roots
.iter()
.any(|root| abs.starts_with(root.as_path()))
{
return false;
}
if file_system_sandbox_policy.has_full_disk_write_access() {
return true;
}
writable_roots
.iter()
.any(|writable_root| writable_root.is_path_writable(&abs))
@@ -193,6 +198,11 @@ fn is_write_patch_constrained_to_writable_paths(
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::FileSystemSpecialPathKind;
use codex_protocol::protocol::RejectConfig;
use codex_utils_absolute_path::AbsolutePathBuf;
use tempfile::TempDir;
@@ -223,13 +233,13 @@ mod tests {
assert!(is_write_patch_constrained_to_writable_paths(
&add_inside,
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
));
assert!(!is_write_patch_constrained_to_writable_paths(
&add_outside,
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
));
@@ -244,7 +254,7 @@ mod tests {
};
assert!(is_write_patch_constrained_to_writable_paths(
&add_outside,
&policy_with_parent,
&FileSystemSandboxPolicy::from(&policy_with_parent),
&cwd,
));
}
@@ -264,6 +274,7 @@ mod tests {
&add_inside,
AskForApproval::OnRequest,
&policy,
&FileSystemSandboxPolicy::from(&policy),
&cwd,
WindowsSandboxLevel::Disabled
),
@@ -294,6 +305,7 @@ mod tests {
&add_outside,
AskForApproval::OnRequest,
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
WindowsSandboxLevel::Disabled,
),
@@ -308,6 +320,7 @@ mod tests {
mcp_elicitations: false,
}),
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
WindowsSandboxLevel::Disabled,
),
@@ -339,6 +352,7 @@ mod tests {
mcp_elicitations: false,
}),
&policy_workspace_only,
&FileSystemSandboxPolicy::from(&policy_workspace_only),
&cwd,
WindowsSandboxLevel::Disabled,
),
@@ -348,4 +362,52 @@ mod tests {
},
);
}
#[test]
fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let blocked_path = cwd.join("blocked.txt");
let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string());
let sandbox_policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: Some(PathBuf::from("blocked.txt")),
},
},
access: FileSystemAccessMode::None,
},
]);
assert!(!is_write_patch_constrained_to_writable_paths(
&action,
&file_system_sandbox_policy,
&cwd,
));
assert_eq!(
assess_patch_safety(
&action,
AskForApproval::OnRequest,
&sandbox_policy,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
),
SafetyCheck::AskUser,
);
}
}

View File

@@ -12,12 +12,18 @@ use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::execute_exec_env;
use crate::landlock::allow_network_for_proxy;
use crate::landlock::create_linux_sandbox_command_args;
use crate::landlock::create_linux_sandbox_command_args_for_policies;
use crate::protocol::FileSystemAccessMode;
use crate::protocol::FileSystemPath;
use crate::protocol::FileSystemSandboxEntry;
use crate::protocol::FileSystemSandboxKind;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
#[cfg(target_os = "macos")]
use crate::seatbelt::create_seatbelt_command_args_with_extensions;
use crate::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
#[cfg(target_os = "macos")]
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
@@ -29,6 +35,7 @@ use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
pub use codex_protocol::models::SandboxPermissions;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
@@ -60,6 +67,8 @@ pub struct ExecRequest {
pub windows_sandbox_level: WindowsSandboxLevel,
pub sandbox_permissions: SandboxPermissions,
pub sandbox_policy: SandboxPolicy,
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
pub network_sandbox_policy: NetworkSandboxPolicy,
pub justification: Option<String>,
pub arg0: Option<String>,
}
@@ -70,6 +79,8 @@ pub struct ExecRequest {
pub(crate) struct SandboxTransformRequest<'a> {
pub spec: CommandSpec,
pub policy: &'a SandboxPolicy,
pub file_system_policy: &'a FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
pub sandbox: SandboxType,
pub enforce_managed_network: bool,
// TODO(viyatb): Evaluate switching this to Option<Arc<NetworkProxy>>
@@ -169,6 +180,40 @@ fn additional_permission_roots(
)
}
fn merge_file_system_policy_with_additional_permissions(
file_system_policy: &FileSystemSandboxPolicy,
extra_reads: Vec<AbsolutePathBuf>,
extra_writes: Vec<AbsolutePathBuf>,
) -> FileSystemSandboxPolicy {
match file_system_policy.kind {
FileSystemSandboxKind::Restricted => {
let mut merged_policy = file_system_policy.clone();
for path in extra_reads {
let entry = FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
};
if !merged_policy.entries.contains(&entry) {
merged_policy.entries.push(entry);
}
}
for path in extra_writes {
let entry = FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
};
if !merged_policy.entries.contains(&entry) {
merged_policy.entries.push(entry);
}
}
merged_policy
}
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
file_system_policy.clone()
}
}
}
fn merge_read_only_access_with_additional_reads(
read_only_access: &ReadOnlyAccess,
extra_reads: Vec<AbsolutePathBuf>,
@@ -212,9 +257,17 @@ fn sandbox_policy_with_additional_permissions(
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
let policy = match sandbox_policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
sandbox_policy.clone()
}
SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess,
SandboxPolicy::ExternalSandbox { network_access } => SandboxPolicy::ExternalSandbox {
network_access: if merge_network_access(
network_access.is_enabled(),
additional_permissions,
) {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
},
},
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
@@ -265,6 +318,28 @@ fn sandbox_policy_with_additional_permissions(
Ok(policy)
}
pub(crate) fn should_require_platform_sandbox(
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
has_managed_network_requirements: bool,
) -> bool {
if has_managed_network_requirements {
return true;
}
if !network_policy.is_enabled() {
return !matches!(
file_system_policy.kind,
FileSystemSandboxKind::ExternalSandbox
);
}
match file_system_policy.kind {
FileSystemSandboxKind::Restricted => !file_system_policy.has_full_disk_write_access(),
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => false,
}
}
#[derive(Default)]
pub struct SandboxManager;
@@ -275,7 +350,8 @@ impl SandboxManager {
pub(crate) fn select_initial(
&self,
policy: &SandboxPolicy,
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
pref: SandboxablePreference,
windows_sandbox_level: WindowsSandboxLevel,
has_managed_network_requirements: bool,
@@ -290,22 +366,20 @@ impl SandboxManager {
)
.unwrap_or(SandboxType::None)
}
SandboxablePreference::Auto => match policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
if has_managed_network_requirements {
crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
SandboxablePreference::Auto => {
if should_require_platform_sandbox(
file_system_policy,
network_policy,
has_managed_network_requirements,
) {
crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
_ => crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None),
},
}
}
}
@@ -316,6 +390,8 @@ impl SandboxManager {
let SandboxTransformRequest {
mut spec,
policy,
file_system_policy,
network_policy,
sandbox,
enforce_managed_network,
network,
@@ -326,14 +402,34 @@ impl SandboxManager {
use_linux_sandbox_bwrap,
windows_sandbox_level,
} = request;
let effective_policy =
if let Some(additional_permissions) = spec.additional_permissions.take() {
sandbox_policy_with_additional_permissions(policy, &additional_permissions)?
let effective_policy = if let Some(additional_permissions) =
spec.additional_permissions.take()
{
let (extra_reads, extra_writes) = additional_permission_roots(&additional_permissions);
let effective_policy =
sandbox_policy_with_additional_permissions(policy, &additional_permissions)?;
let file_system_sandbox_policy = if extra_reads.is_empty() && extra_writes.is_empty() {
file_system_policy.clone()
} else {
policy.clone()
merge_file_system_policy_with_additional_permissions(
file_system_policy,
extra_reads,
extra_writes,
)
};
let network_sandbox_policy = NetworkSandboxPolicy::from(&effective_policy);
(
effective_policy,
file_system_sandbox_policy,
network_sandbox_policy,
)
} else {
(policy.clone(), file_system_policy.clone(), network_policy)
};
let (effective_policy, effective_file_system_policy, effective_network_policy) =
effective_policy;
let mut env = spec.env;
if !effective_policy.has_full_network_access() {
if !effective_network_policy.is_enabled() {
env.insert(
CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(),
"1".to_string(),
@@ -350,9 +446,10 @@ impl SandboxManager {
SandboxType::MacosSeatbelt => {
let mut seatbelt_env = HashMap::new();
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
let mut args = create_seatbelt_command_args_with_extensions(
let mut args = create_seatbelt_command_args_for_policies_with_extensions(
command.clone(),
&effective_policy,
&effective_file_system_policy,
effective_network_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
@@ -369,9 +466,11 @@ impl SandboxManager {
let exe = codex_linux_sandbox_exe
.ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?;
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
let mut args = create_linux_sandbox_command_args(
let mut args = create_linux_sandbox_command_args_for_policies(
command.clone(),
&effective_policy,
&effective_file_system_policy,
effective_network_policy,
sandbox_policy_cwd,
use_linux_sandbox_bwrap,
allow_proxy_network,
@@ -407,6 +506,8 @@ impl SandboxManager {
windows_sandbox_level,
sandbox_permissions: spec.sandbox_permissions,
sandbox_policy: effective_policy,
file_system_sandbox_policy: effective_file_system_policy,
network_sandbox_policy: effective_network_policy,
justification: spec.justification,
arg0: arg0_override,
})
@@ -421,16 +522,25 @@ pub async fn execute_env(
env: ExecRequest,
stdout_stream: Option<StdoutStream>,
) -> crate::error::Result<ExecToolCallOutput> {
let effective_policy = env.sandbox_policy.clone();
execute_exec_env(env, &effective_policy, stdout_stream).await
execute_exec_env(env, stdout_stream).await
}
#[cfg(test)]
mod tests {
use super::SandboxManager;
use super::merge_file_system_policy_with_additional_permissions;
use super::normalize_additional_permissions;
use super::sandbox_policy_with_additional_permissions;
use super::should_require_platform_sandbox;
use crate::exec::SandboxType;
use crate::protocol::FileSystemAccessMode;
use crate::protocol::FileSystemPath;
use crate::protocol::FileSystemSandboxEntry;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::FileSystemSpecialPath;
use crate::protocol::FileSystemSpecialPathKind;
use crate::protocol::NetworkAccess;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::tools::sandboxing::SandboxablePreference;
@@ -441,13 +551,15 @@ mod tests {
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use tempfile::TempDir;
#[test]
fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() {
let manager = SandboxManager::new();
let sandbox = manager.select_initial(
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
false,
@@ -460,7 +572,8 @@ mod tests {
let manager = SandboxManager::new();
let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let sandbox = manager.select_initial(
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
true,
@@ -468,6 +581,136 @@ mod tests {
assert_eq!(sandbox, expected);
}
#[test]
fn restricted_file_system_uses_platform_sandbox_without_managed_network() {
let manager = SandboxManager::new();
let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let sandbox = manager.select_initial(
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
}]),
NetworkSandboxPolicy::Enabled,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
false,
);
assert_eq!(sandbox, expected);
}
#[test]
fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
}]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false),
false
);
}
#[test]
fn root_write_policy_with_carveouts_still_uses_platform_sandbox() {
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: Some("blocked".into()),
},
},
access: FileSystemAccessMode::None,
},
]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false),
true
);
}
#[test]
fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_network() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
}]);
assert_eq!(
should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Restricted, false),
true
);
}
#[test]
fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
sandbox_permissions: super::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: crate::protocol::NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
assert_eq!(
exec_request.file_system_sandbox_policy,
FileSystemSandboxPolicy::unrestricted()
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
}
#[test]
fn normalize_additional_permissions_preserves_network() {
let temp_dir = TempDir::new().expect("create temp dir");
@@ -541,4 +784,139 @@ mod tests {
}
);
}
#[test]
fn external_sandbox_additional_permissions_can_enable_network() {
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let policy = sandbox_policy_with_additional_permissions(
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
&PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
..Default::default()
},
)
.expect("policy");
assert_eq!(
policy,
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
}
);
}
#[test]
fn transform_additional_permissions_enable_network_for_external_sandbox() {
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let exec_request = manager
.transform(super::SandboxTransformRequest {
spec: super::CommandSpec {
program: "true".to_string(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
expiration: crate::exec::ExecExpiration::DefaultTimeout,
sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
..Default::default()
}),
justification: None,
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
assert_eq!(
exec_request.sandbox_policy,
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
}
);
assert_eq!(
exec_request.network_sandbox_policy,
NetworkSandboxPolicy::Enabled
);
}
#[test]
fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() {
let temp_dir = TempDir::new().expect("create temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let allowed_path = cwd.join("allowed").expect("allowed path");
let denied_path = cwd.join("denied").expect("denied path");
let merged_policy = merge_file_system_policy_with_additional_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
},
access: FileSystemAccessMode::None,
},
]),
vec![allowed_path.clone()],
Vec::new(),
);
assert_eq!(
merged_policy.get_unreadable_roots_with_cwd(cwd.as_path()),
vec![denied_path]
);
assert_eq!(
merged_policy.entries.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path { path: allowed_path },
access: FileSystemAccessMode::Read,
}),
true
);
}
}

View File

@@ -15,6 +15,8 @@ use tokio::process::Child;
use tracing::warn;
use url::Url;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::SandboxPolicy;
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
use crate::seatbelt_permissions::build_seatbelt_extensions;
@@ -51,7 +53,7 @@ pub async fn spawn_command_under_seatbelt(
args,
arg0,
cwd: command_cwd,
sandbox_policy,
network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy),
network,
stdio_policy,
env,
@@ -231,10 +233,23 @@ fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String {
.collect()
}
#[cfg_attr(not(test), allow(dead_code))]
fn dynamic_network_policy(
sandbox_policy: &SandboxPolicy,
enforce_managed_network: bool,
proxy: &ProxyPolicyInputs,
) -> String {
dynamic_network_policy_for_network(
NetworkSandboxPolicy::from(sandbox_policy),
enforce_managed_network,
proxy,
)
}
fn dynamic_network_policy_for_network(
network_policy: NetworkSandboxPolicy,
enforce_managed_network: bool,
proxy: &ProxyPolicyInputs,
) -> String {
if !proxy.ports.is_empty() {
let mut policy =
@@ -270,7 +285,7 @@ fn dynamic_network_policy(
return String::new();
}
if sandbox_policy.has_full_network_access() {
if network_policy.is_enabled() {
// No proxy env is configured: retain the existing full-network behavior.
format!(
"(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}"
@@ -287,9 +302,28 @@ pub(crate) fn create_seatbelt_command_args(
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
) -> Vec<String> {
create_seatbelt_command_args_with_extensions(
create_seatbelt_command_args_for_policies(
command,
sandbox_policy,
&FileSystemSandboxPolicy::from(sandbox_policy),
NetworkSandboxPolicy::from(sandbox_policy),
sandbox_policy_cwd,
enforce_managed_network,
network,
)
}
pub(crate) fn create_seatbelt_command_args_for_policies(
command: Vec<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
) -> Vec<String> {
create_seatbelt_command_args_for_policies_with_extensions(
command,
file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
@@ -297,6 +331,64 @@ pub(crate) fn create_seatbelt_command_args(
)
}
fn root_absolute_path() -> AbsolutePathBuf {
match AbsolutePathBuf::from_absolute_path(Path::new("/")) {
Ok(path) => path,
Err(err) => panic!("root path must be absolute: {err}"),
}
}
#[derive(Debug, Clone)]
struct SeatbeltAccessRoot {
root: AbsolutePathBuf,
excluded_subpaths: Vec<AbsolutePathBuf>,
}
fn build_seatbelt_access_policy(
action: &str,
param_prefix: &str,
roots: Vec<SeatbeltAccessRoot>,
) -> (String, Vec<(String, PathBuf)>) {
let mut policy_components = Vec::new();
let mut params = Vec::new();
for (index, access_root) in roots.into_iter().enumerate() {
let root =
normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root);
let root_param = format!("{param_prefix}_{index}");
params.push((root_param.clone(), root.into_path_buf()));
if access_root.excluded_subpaths.is_empty() {
policy_components.push(format!("(subpath (param \"{root_param}\"))"));
continue;
}
let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")];
for (excluded_index, excluded_subpath) in
access_root.excluded_subpaths.into_iter().enumerate()
{
let excluded_subpath =
normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath);
let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}");
params.push((excluded_param.clone(), excluded_subpath.into_path_buf()));
require_parts.push(format!(
"(require-not (subpath (param \"{excluded_param}\")))"
));
}
policy_components.push(format!("(require-all {} )", require_parts.join(" ")));
}
if policy_components.is_empty() {
(String::new(), Vec::new())
} else {
(
format!("(allow {action}\n{}\n)", policy_components.join(" ")),
params,
)
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn create_seatbelt_command_args_with_extensions(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
@@ -305,101 +397,132 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Vec<String> {
let (file_write_policy, file_write_dir_params) = {
if sandbox_policy.has_full_disk_write_access() {
// Allegedly, this is more permissive than `(allow file-write*)`.
(
r#"(allow file-write* (regex #"^/"))"#.to_string(),
Vec::new(),
)
} else {
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
create_seatbelt_command_args_for_policies_with_extensions(
command,
&FileSystemSandboxPolicy::from(sandbox_policy),
NetworkSandboxPolicy::from(sandbox_policy),
sandbox_policy_cwd,
enforce_managed_network,
network,
extensions,
)
}
let mut writable_folder_policies: Vec<String> = Vec::new();
let mut file_write_params = Vec::new();
pub(crate) fn create_seatbelt_command_args_for_policies_with_extensions(
command: Vec<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Vec<String> {
create_seatbelt_command_args_from_policies_inner(
command,
file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
extensions,
)
}
for (index, wr) in writable_roots.iter().enumerate() {
// Canonicalize to avoid mismatches like /var vs /private/var on macOS.
let canonical_root = wr
.root
.as_path()
.canonicalize()
.unwrap_or_else(|_| wr.root.to_path_buf());
let root_param = format!("WRITABLE_ROOT_{index}");
file_write_params.push((root_param.clone(), canonical_root));
if wr.read_only_subpaths.is_empty() {
writable_folder_policies.push(format!("(subpath (param \"{root_param}\"))"));
} else {
// Add parameters for each read-only subpath and generate
// the `(require-not ...)` clauses.
let mut require_parts: Vec<String> = Vec::new();
require_parts.push(format!("(subpath (param \"{root_param}\"))"));
for (subpath_index, ro) in wr.read_only_subpaths.iter().enumerate() {
let canonical_ro = ro
.as_path()
.canonicalize()
.unwrap_or_else(|_| ro.to_path_buf());
let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}");
require_parts
.push(format!("(require-not (subpath (param \"{ro_param}\")))"));
file_write_params.push((ro_param, canonical_ro));
}
let policy_component = format!("(require-all {} )", require_parts.join(" "));
writable_folder_policies.push(policy_component);
}
}
if writable_folder_policies.is_empty() {
("".to_string(), Vec::new())
fn create_seatbelt_command_args_from_policies_inner(
command: Vec<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Vec<String> {
let unreadable_roots =
file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd);
let (file_write_policy, file_write_dir_params) =
if file_system_sandbox_policy.has_full_disk_write_access() {
if unreadable_roots.is_empty() {
// Allegedly, this is more permissive than `(allow file-write*)`.
(
r#"(allow file-write* (regex #"^/"))"#.to_string(),
Vec::new(),
)
} else {
let file_write_policy = format!(
"(allow file-write*\n{}\n)",
writable_folder_policies.join(" ")
);
(file_write_policy, file_write_params)
build_seatbelt_access_policy(
"file-write*",
"WRITABLE_ROOT",
vec![SeatbeltAccessRoot {
root: root_absolute_path(),
excluded_subpaths: unreadable_roots.clone(),
}],
)
}
}
};
let (file_read_policy, file_read_dir_params) = if sandbox_policy.has_full_disk_read_access() {
(
"; allow read-only file operations\n(allow file-read*)".to_string(),
Vec::new(),
)
} else {
let mut readable_roots_policies: Vec<String> = Vec::new();
let mut file_read_params = Vec::new();
for (index, root) in sandbox_policy
.get_readable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
.enumerate()
{
// Canonicalize to avoid mismatches like /var vs /private/var on macOS.
let canonical_root = root
.as_path()
.canonicalize()
.unwrap_or_else(|_| root.to_path_buf());
let root_param = format!("READABLE_ROOT_{index}");
file_read_params.push((root_param.clone(), canonical_root));
readable_roots_policies.push(format!("(subpath (param \"{root_param}\"))"));
}
if readable_roots_policies.is_empty() {
("".to_string(), Vec::new())
} else {
(
format!(
"; allow read-only file operations\n(allow file-read*\n{}\n)",
readable_roots_policies.join(" ")
),
file_read_params,
build_seatbelt_access_policy(
"file-write*",
"WRITABLE_ROOT",
file_system_sandbox_policy
.get_writable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
.map(|root| SeatbeltAccessRoot {
root: root.root,
excluded_subpaths: root.read_only_subpaths,
})
.collect(),
)
}
};
};
let (file_read_policy, file_read_dir_params) =
if file_system_sandbox_policy.has_full_disk_read_access() {
if unreadable_roots.is_empty() {
(
"; allow read-only file operations\n(allow file-read*)".to_string(),
Vec::new(),
)
} else {
let (policy, params) = build_seatbelt_access_policy(
"file-read*",
"READABLE_ROOT",
vec![SeatbeltAccessRoot {
root: root_absolute_path(),
excluded_subpaths: unreadable_roots,
}],
);
(
format!("; allow read-only file operations\n{policy}"),
params,
)
}
} else {
let (policy, params) = build_seatbelt_access_policy(
"file-read*",
"READABLE_ROOT",
file_system_sandbox_policy
.get_readable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
.map(|root| SeatbeltAccessRoot {
excluded_subpaths: unreadable_roots
.iter()
.filter(|path| path.as_path().starts_with(root.as_path()))
.cloned()
.collect(),
root,
})
.collect(),
);
if policy.is_empty() {
(String::new(), params)
} else {
(
format!("; allow read-only file operations\n{policy}"),
params,
)
}
};
let proxy = proxy_policy_inputs(network);
let network_policy = dynamic_network_policy(sandbox_policy, enforce_managed_network, &proxy);
let network_policy =
dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy);
let seatbelt_extensions = extensions.map_or_else(
|| {
// Backward-compatibility default when no extension profile is provided.
@@ -408,7 +531,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
build_seatbelt_extensions,
);
let include_platform_defaults = sandbox_policy.include_platform_defaults();
let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults();
let mut policy_sections = vec![
MACOS_SEATBELT_BASE_POLICY.to_string(),
file_read_policy,
@@ -475,12 +598,18 @@ mod tests {
use super::ProxyPolicyInputs;
use super::UnixDomainSocketPolicy;
use super::create_seatbelt_command_args;
use super::create_seatbelt_command_args_for_policies;
use super::create_seatbelt_command_args_with_extensions;
use super::dynamic_network_policy;
use super::macos_dir_params;
use super::normalize_path_for_sandbox;
use super::unix_socket_dir_params;
use super::unix_socket_policy;
use crate::protocol::FileSystemAccessMode;
use crate::protocol::FileSystemPath;
use crate::protocol::FileSystemSandboxEntry;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::SandboxPolicy;
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
use crate::seatbelt_permissions::MacOsAutomationPermission;
@@ -508,6 +637,15 @@ mod tests {
AbsolutePathBuf::from_absolute_path(Path::new(path)).expect("absolute path")
}
fn seatbelt_policy_arg(args: &[String]) -> &str {
let policy_index = args
.iter()
.position(|arg| arg == "-p")
.expect("seatbelt args should include -p");
args.get(policy_index + 1)
.expect("seatbelt args should include policy text")
}
#[test]
fn base_policy_allows_node_cpu_sysctls() {
assert!(
@@ -555,6 +693,96 @@ mod tests {
);
}
#[test]
fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() {
let unreadable = absolute_path("/tmp/codex-unreadable");
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: crate::protocol::FileSystemSpecialPath {
kind: crate::protocol::FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: unreadable },
access: FileSystemAccessMode::None,
},
]);
let args = create_seatbelt_command_args_for_policies(
vec!["/bin/true".to_string()],
&file_system_policy,
NetworkSandboxPolicy::Restricted,
Path::new("/"),
false,
None,
);
let policy = seatbelt_policy_arg(&args);
assert!(
policy.contains("(require-not (subpath (param \"READABLE_ROOT_0_RO_0\")))"),
"expected read carveout in policy:\n{policy}"
);
assert!(
policy.contains("(require-not (subpath (param \"WRITABLE_ROOT_0_RO_0\")))"),
"expected write carveout in policy:\n{policy}"
);
assert!(
args.iter()
.any(|arg| arg == "-DREADABLE_ROOT_0_RO_0=/tmp/codex-unreadable"),
"expected read carveout parameter in args: {args:#?}"
);
assert!(
args.iter()
.any(|arg| arg == "-DWRITABLE_ROOT_0_RO_0=/tmp/codex-unreadable"),
"expected write carveout parameter in args: {args:#?}"
);
}
#[test]
fn explicit_unreadable_paths_are_excluded_from_readable_roots() {
let root = absolute_path("/tmp/codex-readable");
let unreadable = absolute_path("/tmp/codex-readable/private");
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: root },
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: unreadable },
access: FileSystemAccessMode::None,
},
]);
let args = create_seatbelt_command_args_for_policies(
vec!["/bin/true".to_string()],
&file_system_policy,
NetworkSandboxPolicy::Restricted,
Path::new("/"),
false,
None,
);
let policy = seatbelt_policy_arg(&args);
assert!(
policy.contains("(require-not (subpath (param \"READABLE_ROOT_0_RO_0\")))"),
"expected read carveout in policy:\n{policy}"
);
assert!(
args.iter()
.any(|arg| arg == "-DREADABLE_ROOT_0=/tmp/codex-readable"),
"expected readable root parameter in args: {args:#?}"
);
assert!(
args.iter()
.any(|arg| arg == "-DREADABLE_ROOT_0_RO_0=/tmp/codex-readable/private"),
"expected read carveout parameter in args: {args:#?}"
);
}
#[test]
fn seatbelt_args_include_macos_permission_extensions() {
let cwd = std::env::temp_dir();
@@ -933,7 +1161,7 @@ sys.exit(0 if allowed else 13)
; allow read-only file operations
(allow file-read*)
(allow file-write*
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2"))
(subpath (param "WRITABLE_ROOT_0")) (require-all (subpath (param "WRITABLE_ROOT_1")) (require-not (subpath (param "WRITABLE_ROOT_1_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_1_RO_1"))) ) (subpath (param "WRITABLE_ROOT_2"))
)
; macOS permission profile extensions
@@ -946,43 +1174,51 @@ sys.exit(0 if allowed else 13)
"#,
);
let mut expected_args = vec![
"-p".to_string(),
expected_policy,
assert_eq!(seatbelt_policy_arg(&args), expected_policy);
let expected_definitions = [
format!(
"-DWRITABLE_ROOT_0={}",
vulnerable_root_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_0_RO_0={}",
dot_git_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_0_RO_1={}",
dot_codex_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1={}",
empty_root_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_2={}",
cwd.canonicalize()
.expect("canonicalize cwd")
.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1={}",
vulnerable_root_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1_RO_0={}",
dot_git_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1_RO_1={}",
dot_codex_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_2={}",
empty_root_canonical.to_string_lossy()
),
];
for expected_definition in expected_definitions {
assert!(
args.contains(&expected_definition),
"expected definition arg `{expected_definition}` in {args:#?}"
);
}
for (key, value) in macos_dir_params() {
let expected_definition = format!("-D{key}={}", value.to_string_lossy());
assert!(
args.contains(&expected_definition),
"expected definition arg `{expected_definition}` in {args:#?}"
);
}
expected_args.extend(
macos_dir_params()
.into_iter()
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())),
);
expected_args.push("--".to_string());
expected_args.extend(shell_command);
assert_eq!(expected_args, args);
let command_index = args
.iter()
.position(|arg| arg == "--")
.expect("seatbelt args should include command separator");
assert_eq!(args[command_index + 1..], shell_command);
// Verify that .codex/config.toml cannot be modified under the generated
// Seatbelt policy.

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

@@ -6,13 +6,13 @@ use tokio::process::Child;
use tokio::process::Command;
use tracing::trace;
use crate::protocol::SandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
/// Experimental environment variable that will be set to some non-empty value
/// if both of the following are true:
///
/// 1. The process was spawned by Codex as part of a shell tool call.
/// 2. SandboxPolicy.has_full_network_access() was false for the tool call.
/// 2. NetworkSandboxPolicy is restricted for the tool call.
///
/// We may try to have just one environment variable for all sandboxing
/// attributes, so this may change in the future.
@@ -33,15 +33,15 @@ pub enum StdioPolicy {
/// ensuring the args and environment variables used to create the `Command`
/// (and `Child`) honor the configuration.
///
/// For now, we take `SandboxPolicy` as a parameter to spawn_child() because
/// we need to determine whether to set the
/// For now, we take `NetworkSandboxPolicy` as a parameter to spawn_child()
/// because we need to determine whether to set the
/// `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` environment variable.
pub(crate) struct SpawnChildRequest<'a> {
pub program: PathBuf,
pub args: Vec<String>,
pub arg0: Option<&'a str>,
pub cwd: PathBuf,
pub sandbox_policy: &'a SandboxPolicy,
pub network_sandbox_policy: NetworkSandboxPolicy,
pub network: Option<&'a NetworkProxy>,
pub stdio_policy: StdioPolicy,
pub env: HashMap<String, String>,
@@ -53,14 +53,14 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io
args,
arg0,
cwd,
sandbox_policy,
network_sandbox_policy,
network,
stdio_policy,
mut env,
} = request;
trace!(
"spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}"
"spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {network_sandbox_policy:?} {stdio_policy:?} {env:?}"
);
let mut cmd = Command::new(&program);
@@ -74,7 +74,7 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io
cmd.env_clear();
cmd.envs(env);
if !sandbox_policy.has_full_network_access() {
if !network_sandbox_policy.is_enabled() {
cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1");
}

View File

@@ -22,6 +22,8 @@ use crate::protocol::ExecCommandBeginEvent;
use crate::protocol::ExecCommandEndEvent;
use crate::protocol::ExecCommandSource;
use crate::protocol::ExecCommandStatus;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::SandboxPolicy;
use crate::protocol::TurnStartedEvent;
use crate::sandboxing::ExecRequest;
@@ -163,6 +165,8 @@ pub(crate) async fn execute_user_shell_command(
windows_sandbox_level: turn_context.windows_sandbox_level,
sandbox_permissions: SandboxPermissions::UseDefault,
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
justification: None,
arg0: None,
};
@@ -173,7 +177,7 @@ pub(crate) async fn execute_user_shell_command(
tx_event: session.get_tx_event(),
});
let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream)
let exec_result = execute_exec_env(exec_env, stdout_stream)
.or_cancel(&cancellation_token)
.await;

View File

@@ -0,0 +1,123 @@
use codex_protocol::protocol::FileSystemSandboxPolicy;
use std::path::Path;
pub(super) fn path_is_readable(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
path: &Path,
) -> bool {
if file_system_sandbox_policy
.get_unreadable_roots_with_cwd(cwd)
.iter()
.any(|root| path.starts_with(root.as_path()))
{
return false;
}
if file_system_sandbox_policy.has_full_disk_read_access() {
return true;
}
file_system_sandbox_policy
.get_readable_roots_with_cwd(cwd)
.iter()
.any(|root| path.starts_with(root.as_path()))
}
pub(super) fn path_is_writable(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
path: &Path,
) -> bool {
if file_system_sandbox_policy
.get_unreadable_roots_with_cwd(cwd)
.iter()
.any(|root| path.starts_with(root.as_path()))
{
return false;
}
if file_system_sandbox_policy.has_full_disk_write_access() {
return true;
}
file_system_sandbox_policy
.get_writable_roots_with_cwd(cwd)
.iter()
.any(|root| root.is_path_writable(path))
}
#[cfg(test)]
mod tests {
use super::path_is_readable;
use super::path_is_writable;
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 pretty_assertions::assert_eq;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn unreadable_subpaths_override_root_read_access() {
let cwd = TempDir::new().expect("tempdir");
let readable = cwd.path().join("readable.txt");
let blocked = cwd.path().join("blocked.txt");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: Some(PathBuf::from("blocked.txt")),
},
},
access: FileSystemAccessMode::None,
},
]);
assert_eq!(path_is_readable(&policy, cwd.path(), &readable), true);
assert_eq!(path_is_readable(&policy, cwd.path(), &blocked), false);
}
#[test]
fn unreadable_subpaths_override_writable_roots() {
let cwd = TempDir::new().expect("tempdir");
let allowed = cwd.path().join("allowed.txt");
let blocked = cwd.path().join("blocked.txt");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: Some(PathBuf::from("blocked.txt")),
},
},
access: FileSystemAccessMode::None,
},
]);
assert_eq!(path_is_writable(&policy, cwd.path(), &allowed), true);
assert_eq!(path_is_writable(&policy, cwd.path(), &blocked), false);
}
}

View File

@@ -1,5 +1,6 @@
pub(crate) mod agent_jobs;
pub mod apply_patch;
mod artifact_path_access;
mod dynamic;
mod grep_files;
mod js_repl;

View File

@@ -19,6 +19,7 @@ use crate::path_utils::resolve_symlink_write_paths;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::artifact_path_access;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
@@ -111,8 +112,16 @@ async fn authorize_path_access(
PathAccessKind::Write => effective_write_path(&access.path),
};
let allowed = match access.kind {
PathAccessKind::Read => path_is_readable(turn, &effective_path),
PathAccessKind::Write => path_is_writable(turn, &effective_path),
PathAccessKind::Read => artifact_path_access::path_is_readable(
&turn.file_system_sandbox_policy,
&turn.cwd,
&effective_path,
),
PathAccessKind::Write => artifact_path_access::path_is_writable(
&turn.file_system_sandbox_policy,
&turn.cwd,
&effective_path,
),
};
if allowed {
return Ok(());
@@ -188,28 +197,6 @@ async fn authorize_path_access(
)))
}
fn path_is_readable(turn: &TurnContext, path: &Path) -> bool {
if turn.sandbox_policy.has_full_disk_read_access() {
return true;
}
turn.sandbox_policy
.get_readable_roots_with_cwd(&turn.cwd)
.iter()
.any(|root| path.starts_with(root.as_path()))
}
fn path_is_writable(turn: &TurnContext, path: &Path) -> bool {
if turn.sandbox_policy.has_full_disk_write_access() {
return true;
}
turn.sandbox_policy
.get_writable_roots_with_cwd(&turn.cwd)
.iter()
.any(|root| root.is_path_writable(path))
}
fn effective_read_path(path: &Path) -> PathBuf {
normalize_for_path_comparison(path).unwrap_or_else(|_| normalize_without_fs(path))
}

View File

@@ -20,6 +20,7 @@ use crate::path_utils::resolve_symlink_write_paths;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::artifact_path_access;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
@@ -100,8 +101,16 @@ async fn authorize_path_access(
PathAccessKind::Write => effective_write_path(&access.path),
};
let allowed = match access.kind {
PathAccessKind::Read => path_is_readable(turn, &effective_path),
PathAccessKind::Write => path_is_writable(turn, &effective_path),
PathAccessKind::Read => artifact_path_access::path_is_readable(
&turn.file_system_sandbox_policy,
&turn.cwd,
&effective_path,
),
PathAccessKind::Write => artifact_path_access::path_is_writable(
&turn.file_system_sandbox_policy,
&turn.cwd,
&effective_path,
),
};
if allowed {
return Ok(());
@@ -173,28 +182,6 @@ async fn authorize_path_access(
)))
}
fn path_is_readable(turn: &TurnContext, path: &Path) -> bool {
if turn.sandbox_policy.has_full_disk_read_access() {
return true;
}
turn.sandbox_policy
.get_readable_roots_with_cwd(&turn.cwd)
.iter()
.any(|root| path.starts_with(root.as_path()))
}
fn path_is_writable(turn: &TurnContext, path: &Path) -> bool {
if turn.sandbox_policy.has_full_disk_write_access() {
return true;
}
turn.sandbox_policy
.get_writable_roots_with_cwd(&turn.cwd)
.iter()
.any(|root| root.is_path_writable(path))
}
fn effective_read_path(path: &Path) -> PathBuf {
normalize_for_path_comparison(path).unwrap_or_else(|_| normalize_without_fs(path))
}

View File

@@ -852,7 +852,8 @@ impl JsReplManager {
.network
.is_some();
let sandbox_type = sandbox.select_initial(
&turn.sandbox_policy,
&turn.file_system_sandbox_policy,
turn.network_sandbox_policy,
SandboxablePreference::Auto,
turn.windows_sandbox_level,
has_managed_network_requirements,
@@ -861,6 +862,8 @@ impl JsReplManager {
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: &turn.sandbox_policy,
file_system_policy: &turn.file_system_sandbox_policy,
network_policy: turn.network_sandbox_policy,
sandbox: sandbox_type,
enforce_managed_network: has_managed_network_requirements,
network: None,
@@ -1727,6 +1730,16 @@ mod tests {
use std::path::Path;
use tempfile::tempdir;
fn set_danger_full_access(turn: &mut crate::codex::TurnContext) {
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
turn.file_system_sandbox_policy =
crate::protocol::FileSystemSandboxPolicy::from(turn.sandbox_policy.get());
turn.network_sandbox_policy =
crate::protocol::NetworkSandboxPolicy::from(turn.sandbox_policy.get());
}
#[test]
fn node_version_parses_v_prefix_and_suffix() {
let version = NodeVersion::parse("v25.1.0-nightly.2024").unwrap();
@@ -2405,9 +2418,7 @@ mod tests {
turn.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
set_danger_full_access(&mut turn);
let session = Arc::new(session);
let turn = Arc::new(turn);
@@ -2459,9 +2470,7 @@ console.log("cell-complete");
turn.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
set_danger_full_access(&mut turn);
let session = Arc::new(session);
let turn = Arc::new(turn);
@@ -2517,9 +2526,7 @@ console.log(out.type);
turn.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
set_danger_full_access(&mut turn);
let session = Arc::new(session);
let turn = Arc::new(turn);

View File

@@ -169,7 +169,8 @@ impl ToolOrchestrator {
let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) {
SandboxOverride::BypassSandboxFirstAttempt => crate::exec::SandboxType::None,
SandboxOverride::NoOverride => self.sandbox.select_initial(
&turn_ctx.sandbox_policy,
&turn_ctx.file_system_sandbox_policy,
turn_ctx.network_sandbox_policy,
tool.sandbox_preference(),
turn_ctx.windows_sandbox_level,
has_managed_network_requirements,
@@ -182,6 +183,8 @@ impl ToolOrchestrator {
let initial_attempt = SandboxAttempt {
sandbox: initial_sandbox,
policy: &turn_ctx.sandbox_policy,
file_system_policy: &turn_ctx.file_system_sandbox_policy,
network_policy: turn_ctx.network_sandbox_policy,
enforce_managed_network: has_managed_network_requirements,
manager: &self.sandbox,
sandbox_cwd: &turn_ctx.cwd,
@@ -296,6 +299,8 @@ impl ToolOrchestrator {
let escalated_attempt = SandboxAttempt {
sandbox: crate::exec::SandboxType::None,
policy: &turn_ctx.sandbox_policy,
file_system_policy: &turn_ctx.file_system_sandbox_policy,
network_policy: turn_ctx.network_sandbox_policy,
enforce_managed_network: has_managed_network_requirements,
manager: &self.sandbox,
sandbox_cwd: &turn_ctx.cwd,

View File

@@ -25,7 +25,9 @@ use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_protocol::protocol::RejectConfig;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
@@ -92,6 +94,8 @@ pub(super) async fn try_run_zsh_fork(
windows_sandbox_level,
sandbox_permissions,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
justification,
arg0,
} = sandbox_exec_request;
@@ -107,6 +111,8 @@ pub(super) async fn try_run_zsh_fork(
command,
cwd: sandbox_cwd,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
sandbox,
env: sandbox_env,
network: sandbox_network,
@@ -629,6 +635,8 @@ struct CoreShellCommandExecutor {
command: Vec<String>,
cwd: PathBuf,
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox: SandboxType,
env: HashMap<String, String>,
network: Option<codex_network_proxy::NetworkProxy>,
@@ -669,6 +677,8 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
windows_sandbox_level: self.windows_sandbox_level,
sandbox_permissions: self.sandbox_permissions,
sandbox_policy: self.sandbox_policy.clone(),
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
network_sandbox_policy: self.network_sandbox_policy,
justification: self.justification.clone(),
arg0: self.arg0.clone(),
},
@@ -713,6 +723,8 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
workdir,
env,
&self.sandbox_policy,
&self.file_system_sandbox_policy,
self.network_sandbox_policy,
None,
self.macos_seatbelt_profile_extensions.as_ref(),
)?,
@@ -723,15 +735,23 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
workdir,
env,
&self.sandbox_policy,
&self.file_system_sandbox_policy,
self.network_sandbox_policy,
Some(permission_profile),
None,
)?,
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from(&permissions.sandbox_policy);
let network_sandbox_policy =
NetworkSandboxPolicy::from(&permissions.sandbox_policy);
self.prepare_sandboxed_exec(
command,
workdir,
env,
&permissions.sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
None,
permissions.macos_seatbelt_profile_extensions.as_ref(),
)?
@@ -743,12 +763,15 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
}
impl CoreShellCommandExecutor {
#[allow(clippy::too_many_arguments)]
fn prepare_sandboxed_exec(
&self,
command: Vec<String>,
workdir: &AbsolutePathBuf,
env: HashMap<String, String>,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
additional_permissions: Option<PermissionProfile>,
#[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: Option<
&MacOsSeatbeltProfileExtensions,
@@ -762,7 +785,8 @@ impl CoreShellCommandExecutor {
.ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?;
let sandbox_manager = crate::sandboxing::SandboxManager::new();
let sandbox = sandbox_manager.select_initial(
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
SandboxablePreference::Auto,
self.windows_sandbox_level,
self.network.is_some(),
@@ -784,6 +808,8 @@ impl CoreShellCommandExecutor {
justification: self.justification.clone(),
},
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
sandbox,
enforce_managed_network: self.network.is_some(),
network: self.network.as_ref(),

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;
@@ -418,6 +422,10 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions
network: None,
sandbox: SandboxType::None,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
@@ -468,6 +476,8 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
network: None,
sandbox: SandboxType::None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess),
network_sandbox_policy: NetworkSandboxPolicy::Enabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
@@ -481,6 +491,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

@@ -7,6 +7,8 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::error::CodexErr;
use crate::protocol::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
use crate::protocol::SandboxPolicy;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxManager;
@@ -318,6 +320,8 @@ pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {
pub(crate) struct SandboxAttempt<'a> {
pub sandbox: crate::exec::SandboxType,
pub policy: &'a crate::protocol::SandboxPolicy,
pub file_system_policy: &'a FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
pub enforce_managed_network: bool,
pub(crate) manager: &'a SandboxManager,
pub(crate) sandbox_cwd: &'a Path,
@@ -336,6 +340,8 @@ impl<'a> SandboxAttempt<'a> {
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: self.policy,
file_system_policy: self.file_system_policy,
network_policy: self.network_policy,
sandbox: self.sandbox,
enforce_managed_network: self.enforce_managed_network,
network,

View File

@@ -201,6 +201,10 @@ mod tests {
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
turn.file_system_sandbox_policy =
crate::protocol::FileSystemSandboxPolicy::from(turn.sandbox_policy.get());
turn.network_sandbox_policy =
crate::protocol::NetworkSandboxPolicy::from(turn.sandbox_policy.get());
(Arc::new(session), Arc::new(turn))
}

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

@@ -10,6 +10,8 @@ use codex_core::exec::process_exec_tool_call;
use codex_core::sandboxing::SandboxPermissions;
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use tempfile::TempDir;
@@ -45,7 +47,17 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput
let policy = SandboxPolicy::new_read_only_policy();
process_exec_tool_call(params, &policy, tmp.path(), &None, false, None).await
process_exec_tool_call(
params,
&policy,
&FileSystemSandboxPolicy::from(&policy),
NetworkSandboxPolicy::from(&policy),
tmp.path(),
&None,
false,
None,
)
.await
}
/// Command succeeds with exit code 0 normally

View File

@@ -5,6 +5,7 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ExecOutputStream;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnAbortReason;
@@ -23,6 +24,7 @@ use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use core_test_support::wait_for_event_with_timeout;
use pretty_assertions::assert_eq;
use regex_lite::escape;
use std::path::PathBuf;
use tempfile::TempDir;
@@ -328,6 +330,35 @@ async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyh
Ok(())
}
#[tokio::test]
async fn user_shell_command_does_not_set_network_sandbox_env_var() -> anyhow::Result<()> {
let server = responses::start_mock_server().await;
let mut builder = core_test_support::test_codex::test_codex().with_config(|config| {
config.permissions.network_sandbox_policy = NetworkSandboxPolicy::Restricted;
});
let test = builder.build(&server).await?;
#[cfg(windows)]
let command = r#"$val = $env:CODEX_SANDBOX_NETWORK_DISABLED; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string();
#[cfg(not(windows))]
let command =
r#"sh -c "printf '%s' \"${CODEX_SANDBOX_NETWORK_DISABLED:-not-set}\"""#.to_string();
test.codex
.submit(Op::RunUserShellCommand { command })
.await?;
let end_event = wait_for_event_match(&test.codex, |ev| match ev {
EventMsg::ExecCommandEnd(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(end_event.exit_code, 0);
assert_eq!(end_event.stdout.trim(), "not-set");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(not(target_os = "windows"))] // TODO: unignore on windows
async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<()> {

View File

@@ -15,6 +15,7 @@ use std::path::PathBuf;
use codex_core::error::CodexErr;
use codex_core::error::Result;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::WritableRoot;
@@ -89,7 +90,17 @@ pub(crate) fn create_bwrap_command_args(
cwd: &Path,
options: BwrapOptions,
) -> Result<Vec<String>> {
if sandbox_policy.has_full_disk_write_access() {
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(sandbox_policy);
create_bwrap_command_args_for_policy(command, &file_system_sandbox_policy, cwd, options)
}
pub(crate) fn create_bwrap_command_args_for_policy(
command: Vec<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
options: BwrapOptions,
) -> Result<Vec<String>> {
if file_system_sandbox_policy.has_full_disk_write_access() {
return if options.network_mode == BwrapNetworkMode::FullAccess {
Ok(command)
} else {
@@ -97,7 +108,7 @@ pub(crate) fn create_bwrap_command_args(
};
}
create_bwrap_flags(command, sandbox_policy, cwd, options)
create_bwrap_flags(command, file_system_sandbox_policy, cwd, options)
}
fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOptions) -> Vec<String> {
@@ -124,14 +135,14 @@ fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOption
/// Build the bubblewrap flags (everything after `argv[0]`).
fn create_bwrap_flags(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
options: BwrapOptions,
) -> Result<Vec<String>> {
let mut args = Vec::new();
args.push("--new-session".to_string());
args.push("--die-with-parent".to_string());
args.extend(create_filesystem_args(sandbox_policy, cwd)?);
args.extend(create_filesystem_args(file_system_sandbox_policy, cwd)?);
// Isolate the PID namespace.
args.push("--unshare-pid".to_string());
if options.network_mode.should_unshare_network() {
@@ -147,7 +158,7 @@ fn create_bwrap_flags(
Ok(args)
}
/// Build the bubblewrap filesystem mounts for a given sandbox policy.
/// Build the bubblewrap filesystem mounts for a given filesystem policy.
///
/// The mount order is important:
/// 1. Full-read policies use `--ro-bind / /`; restricted-read policies start
@@ -158,11 +169,14 @@ fn create_bwrap_flags(
/// writable subpaths under `/dev` (for example, `/dev/shm`).
/// 4. `--ro-bind <subpath> <subpath>` re-applies read-only protections under
/// those writable roots so protected subpaths win.
fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result<Vec<String>> {
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
fn create_filesystem_args(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> Result<Vec<String>> {
let writable_roots = file_system_sandbox_policy.get_writable_roots_with_cwd(cwd);
ensure_mount_targets_exist(&writable_roots)?;
let mut args = if sandbox_policy.has_full_disk_read_access() {
let mut args = if file_system_sandbox_policy.has_full_disk_read_access() {
// Read-only root, then mount a minimal device tree.
// In bubblewrap (`bubblewrap.c`, `SETUP_MOUNT_DEV`), `--dev /dev`
// creates the standard minimal nodes: null, zero, full, random,
@@ -185,12 +199,12 @@ fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result<
"/dev".to_string(),
];
let mut readable_roots: BTreeSet<PathBuf> = sandbox_policy
let mut readable_roots: BTreeSet<PathBuf> = file_system_sandbox_policy
.get_readable_roots_with_cwd(cwd)
.into_iter()
.map(PathBuf::from)
.collect();
if sandbox_policy.include_platform_defaults() {
if file_system_sandbox_policy.include_platform_defaults() {
readable_roots.extend(
LINUX_PLATFORM_DEFAULT_READ_ROOTS
.iter()
@@ -380,6 +394,12 @@ fn find_first_non_existent_component(target_path: &Path) -> Option<PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
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::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -521,4 +541,47 @@ mod tests {
);
}
}
#[test]
fn split_policy_reapplies_unreadable_carveouts_after_writable_binds() {
let temp_dir = TempDir::new().expect("temp dir");
let writable_root = temp_dir.path().join("workspace");
let blocked = writable_root.join("blocked");
std::fs::create_dir_all(&blocked).expect("create blocked dir");
let writable_root =
AbsolutePathBuf::from_absolute_path(&writable_root).expect("absolute writable root");
let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked dir");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: writable_root.clone(),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: blocked.clone(),
},
access: FileSystemAccessMode::None,
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
let writable_root_str = path_to_string(writable_root.as_path());
let blocked_str = path_to_string(blocked.as_path());
assert!(args.windows(3).any(|window| {
window
== [
"--bind",
writable_root_str.as_str(),
writable_root_str.as_str(),
]
}));
assert!(
args.windows(3).any(|window| {
window == ["--ro-bind", blocked_str.as_str(), blocked_str.as_str()]
})
);
}
}

View File

@@ -8,6 +8,7 @@ use std::path::Path;
use codex_core::error::CodexErr;
use codex_core::error::Result;
use codex_core::error::SandboxErr;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -40,13 +41,14 @@ use seccompiler::apply_filter;
/// Filesystem restrictions are intentionally handled by bubblewrap.
pub(crate) fn apply_sandbox_policy_to_current_thread(
sandbox_policy: &SandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
cwd: &Path,
apply_landlock_fs: bool,
allow_network_for_proxy: bool,
proxy_routed_network: bool,
) -> Result<()> {
let network_seccomp_mode = network_seccomp_mode(
sandbox_policy,
network_sandbox_policy,
allow_network_for_proxy,
proxy_routed_network,
);
@@ -91,20 +93,20 @@ enum NetworkSeccompMode {
}
fn should_install_network_seccomp(
sandbox_policy: &SandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
) -> bool {
// Managed-network sessions should remain fail-closed even for policies that
// would normally grant full network access (for example, DangerFullAccess).
!sandbox_policy.has_full_network_access() || allow_network_for_proxy
!network_sandbox_policy.is_enabled() || allow_network_for_proxy
}
fn network_seccomp_mode(
sandbox_policy: &SandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
proxy_routed_network: bool,
) -> Option<NetworkSeccompMode> {
if !should_install_network_seccomp(sandbox_policy, allow_network_for_proxy) {
if !should_install_network_seccomp(network_sandbox_policy, allow_network_for_proxy) {
None
} else if proxy_routed_network {
Some(NetworkSeccompMode::ProxyRouted)

View File

@@ -8,12 +8,15 @@ use std::path::PathBuf;
use crate::bwrap::BwrapNetworkMode;
use crate::bwrap::BwrapOptions;
use crate::bwrap::create_bwrap_command_args;
use crate::bwrap::create_bwrap_command_args_for_policy;
use crate::landlock::apply_sandbox_policy_to_current_thread;
use crate::proxy_routing::activate_proxy_routes_in_netns;
use crate::proxy_routing::prepare_host_proxy_route_spec;
use crate::vendored_bwrap::exec_vendored_bwrap;
use crate::vendored_bwrap::run_vendored_bwrap_main;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
#[derive(Debug, Parser)]
/// CLI surface for the Linux sandbox helper.
@@ -26,8 +29,18 @@ pub struct LandlockCommand {
#[arg(long = "sandbox-policy-cwd")]
pub sandbox_policy_cwd: PathBuf,
#[arg(long = "sandbox-policy")]
pub sandbox_policy: codex_protocol::protocol::SandboxPolicy,
/// Legacy compatibility policy.
///
/// Newer callers pass split filesystem/network policies as well so the
/// helper can migrate incrementally without breaking older invocations.
#[arg(long = "sandbox-policy", hide = true)]
pub sandbox_policy: Option<SandboxPolicy>,
#[arg(long = "file-system-sandbox-policy", hide = true)]
pub file_system_sandbox_policy: Option<FileSystemSandboxPolicy>,
#[arg(long = "network-sandbox-policy", hide = true)]
pub network_sandbox_policy: Option<NetworkSandboxPolicy>,
/// Opt-in: use the bubblewrap-based Linux sandbox pipeline.
///
@@ -77,6 +90,8 @@ pub fn run_main() -> ! {
let LandlockCommand {
sandbox_policy_cwd,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
use_bwrap_sandbox,
apply_seccomp_then_exec,
allow_network_for_proxy,
@@ -89,6 +104,16 @@ pub fn run_main() -> ! {
panic!("No command specified to execute.");
}
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_bwrap_sandbox);
let EffectiveSandboxPolicies {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
} = resolve_sandbox_policies(
sandbox_policy_cwd.as_path(),
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
);
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
// established the filesystem view.
@@ -104,6 +129,7 @@ pub fn run_main() -> ! {
let proxy_routing_active = allow_network_for_proxy;
if let Err(e) = apply_sandbox_policy_to_current_thread(
&sandbox_policy,
network_sandbox_policy,
&sandbox_policy_cwd,
false,
allow_network_for_proxy,
@@ -114,9 +140,10 @@ pub fn run_main() -> ! {
exec_or_panic(command);
}
if sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy {
if file_system_sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy {
if let Err(e) = apply_sandbox_policy_to_current_thread(
&sandbox_policy,
network_sandbox_policy,
&sandbox_policy_cwd,
false,
allow_network_for_proxy,
@@ -142,6 +169,8 @@ pub fn run_main() -> ! {
let inner = build_inner_seccomp_command(
&sandbox_policy_cwd,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
use_bwrap_sandbox,
allow_network_for_proxy,
proxy_route_spec,
@@ -149,7 +178,8 @@ pub fn run_main() -> ! {
);
run_bwrap_with_proc_fallback(
&sandbox_policy_cwd,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
inner,
!no_proc,
allow_network_for_proxy,
@@ -159,6 +189,7 @@ pub fn run_main() -> ! {
// Legacy path: Landlock enforcement only, when bwrap sandboxing is not enabled.
if let Err(e) = apply_sandbox_policy_to_current_thread(
&sandbox_policy,
network_sandbox_policy,
&sandbox_policy_cwd,
true,
allow_network_for_proxy,
@@ -169,6 +200,53 @@ pub fn run_main() -> ! {
exec_or_panic(command);
}
#[derive(Debug, Clone)]
struct EffectiveSandboxPolicies {
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
}
fn resolve_sandbox_policies(
sandbox_policy_cwd: &Path,
sandbox_policy: Option<SandboxPolicy>,
file_system_sandbox_policy: Option<FileSystemSandboxPolicy>,
network_sandbox_policy: Option<NetworkSandboxPolicy>,
) -> EffectiveSandboxPolicies {
match (
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
) {
(Some(sandbox_policy), Some(file_system_sandbox_policy), Some(network_sandbox_policy)) => {
EffectiveSandboxPolicies {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
}
}
(Some(sandbox_policy), None, None) => EffectiveSandboxPolicies {
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
sandbox_policy,
},
(None, Some(file_system_sandbox_policy), Some(network_sandbox_policy)) => {
let sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, sandbox_policy_cwd)
.unwrap_or_else(|err| {
panic!("failed to derive legacy sandbox policy from split policies: {err}")
});
EffectiveSandboxPolicies {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
}
}
(None, None, None) => panic!("missing sandbox policy configuration"),
_ => panic!("file-system and network sandbox policies must be provided together"),
}
}
fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool) {
if apply_seccomp_then_exec && !use_bwrap_sandbox {
panic!("--apply-seccomp-then-exec requires --use-bwrap-sandbox");
@@ -177,15 +255,21 @@ fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_san
fn run_bwrap_with_proc_fallback(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
inner: Vec<String>,
mount_proc: bool,
allow_network_for_proxy: bool,
) -> ! {
let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy);
let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy);
let mut mount_proc = mount_proc;
if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy, network_mode)
if mount_proc
&& !preflight_proc_mount_support(
sandbox_policy_cwd,
file_system_sandbox_policy,
network_mode,
)
{
eprintln!("codex-linux-sandbox: bwrap could not mount /proc; retrying with --no-proc");
mount_proc = false;
@@ -195,17 +279,22 @@ fn run_bwrap_with_proc_fallback(
mount_proc,
network_mode,
};
let argv = build_bwrap_argv(inner, sandbox_policy, sandbox_policy_cwd, options);
let argv = build_bwrap_argv(
inner,
file_system_sandbox_policy,
sandbox_policy_cwd,
options,
);
exec_vendored_bwrap(argv);
}
fn bwrap_network_mode(
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
) -> BwrapNetworkMode {
if allow_network_for_proxy {
BwrapNetworkMode::ProxyOnly
} else if sandbox_policy.has_full_network_access() {
} else if network_sandbox_policy.is_enabled() {
BwrapNetworkMode::FullAccess
} else {
BwrapNetworkMode::Isolated
@@ -214,12 +303,17 @@ fn bwrap_network_mode(
fn build_bwrap_argv(
inner: Vec<String>,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_policy_cwd: &Path,
options: BwrapOptions,
) -> Vec<String> {
let mut args = create_bwrap_command_args(inner, sandbox_policy, sandbox_policy_cwd, options)
.unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}"));
let mut args = create_bwrap_command_args_for_policy(
inner,
file_system_sandbox_policy,
sandbox_policy_cwd,
options,
)
.unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}"));
let command_separator_index = args
.iter()
@@ -237,24 +331,24 @@ fn build_bwrap_argv(
fn preflight_proc_mount_support(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_mode: BwrapNetworkMode,
) -> bool {
let preflight_argv =
build_preflight_bwrap_argv(sandbox_policy_cwd, sandbox_policy, network_mode);
build_preflight_bwrap_argv(sandbox_policy_cwd, file_system_sandbox_policy, network_mode);
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
!is_proc_mount_failure(stderr.as_str())
}
fn build_preflight_bwrap_argv(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_mode: BwrapNetworkMode,
) -> Vec<String> {
let preflight_command = vec![resolve_true_command()];
build_bwrap_argv(
preflight_command,
sandbox_policy,
file_system_sandbox_policy,
sandbox_policy_cwd,
BwrapOptions {
mount_proc: true,
@@ -361,7 +455,9 @@ fn is_proc_mount_failure(stderr: &str) -> bool {
/// Build the inner command that applies seccomp after bubblewrap.
fn build_inner_seccomp_command(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
use_bwrap_sandbox: bool,
allow_network_for_proxy: bool,
proxy_route_spec: Option<String>,
@@ -375,6 +471,14 @@ fn build_inner_seccomp_command(
Ok(json) => json,
Err(err) => panic!("failed to serialize sandbox policy: {err}"),
};
let file_system_policy_json = match serde_json::to_string(file_system_sandbox_policy) {
Ok(json) => json,
Err(err) => panic!("failed to serialize filesystem sandbox policy: {err}"),
};
let network_policy_json = match serde_json::to_string(&network_sandbox_policy) {
Ok(json) => json,
Err(err) => panic!("failed to serialize network sandbox policy: {err}"),
};
let mut inner = vec![
current_exe.to_string_lossy().to_string(),
@@ -382,6 +486,10 @@ fn build_inner_seccomp_command(
sandbox_policy_cwd.to_string_lossy().to_string(),
"--sandbox-policy".to_string(),
policy_json,
"--file-system-sandbox-policy".to_string(),
file_system_policy_json,
"--network-sandbox-policy".to_string(),
network_policy_json,
];
if use_bwrap_sandbox {
inner.push("--use-bwrap-sandbox".to_string());

View File

@@ -1,7 +1,13 @@
#[cfg(test)]
use super::*;
#[cfg(test)]
use codex_protocol::protocol::FileSystemSandboxPolicy;
#[cfg(test)]
use codex_protocol::protocol::NetworkSandboxPolicy;
#[cfg(test)]
use codex_protocol::protocol::SandboxPolicy;
#[cfg(test)]
use pretty_assertions::assert_eq;
#[test]
fn detects_proc_mount_invalid_argument_failure() {
@@ -29,9 +35,10 @@ fn ignores_non_proc_mount_errors() {
#[test]
fn inserts_bwrap_argv0_before_command_separator() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::new_read_only_policy(),
&FileSystemSandboxPolicy::from(&sandbox_policy),
Path::new("/"),
BwrapOptions {
mount_proc: true,
@@ -62,9 +69,10 @@ fn inserts_bwrap_argv0_before_command_separator() {
#[test]
fn inserts_unshare_net_when_network_isolation_requested() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::new_read_only_policy(),
&FileSystemSandboxPolicy::from(&sandbox_policy),
Path::new("/"),
BwrapOptions {
mount_proc: true,
@@ -76,9 +84,10 @@ fn inserts_unshare_net_when_network_isolation_requested() {
#[test]
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::new_read_only_policy(),
&FileSystemSandboxPolicy::from(&sandbox_policy),
Path::new("/"),
BwrapOptions {
mount_proc: true,
@@ -90,22 +99,29 @@ fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
#[test]
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
let mode = bwrap_network_mode(NetworkSandboxPolicy::Enabled, true);
assert_eq!(mode, BwrapNetworkMode::ProxyOnly);
}
#[test]
fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() {
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
let argv = build_preflight_bwrap_argv(Path::new("/"), &SandboxPolicy::DangerFullAccess, mode);
let mode = bwrap_network_mode(NetworkSandboxPolicy::Enabled, true);
let argv = build_preflight_bwrap_argv(
Path::new("/"),
&FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess),
mode,
);
assert!(argv.iter().any(|arg| arg == "--"));
}
#[test]
fn managed_proxy_inner_command_includes_route_spec() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let args = build_inner_seccomp_command(
Path::new("/tmp"),
&SandboxPolicy::new_read_only_policy(),
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::Restricted,
true,
true,
Some("{\"routes\":[]}".to_string()),
@@ -117,10 +133,31 @@ fn managed_proxy_inner_command_includes_route_spec() {
}
#[test]
fn non_managed_inner_command_omits_route_spec() {
fn inner_command_includes_split_policy_flags() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let args = build_inner_seccomp_command(
Path::new("/tmp"),
&SandboxPolicy::new_read_only_policy(),
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::Restricted,
true,
false,
None,
vec!["/bin/true".to_string()],
);
assert!(args.iter().any(|arg| arg == "--file-system-sandbox-policy"));
assert!(args.iter().any(|arg| arg == "--network-sandbox-policy"));
}
#[test]
fn non_managed_inner_command_omits_route_spec() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let args = build_inner_seccomp_command(
Path::new("/tmp"),
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::Restricted,
true,
false,
None,
@@ -133,9 +170,12 @@ fn non_managed_inner_command_omits_route_spec() {
#[test]
fn managed_proxy_inner_command_requires_route_spec() {
let result = std::panic::catch_unwind(|| {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
build_inner_seccomp_command(
Path::new("/tmp"),
&SandboxPolicy::new_read_only_policy(),
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::Restricted,
true,
true,
None,
@@ -145,6 +185,59 @@ fn managed_proxy_inner_command_requires_route_spec() {
assert!(result.is_err());
}
#[test]
fn resolve_sandbox_policies_derives_split_policies_from_legacy_policy() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let resolved =
resolve_sandbox_policies(Path::new("/tmp"), Some(sandbox_policy.clone()), None, None);
assert_eq!(resolved.sandbox_policy, sandbox_policy.clone());
assert_eq!(
resolved.file_system_sandbox_policy,
FileSystemSandboxPolicy::from(&sandbox_policy)
);
assert_eq!(
resolved.network_sandbox_policy,
NetworkSandboxPolicy::from(&sandbox_policy)
);
}
#[test]
fn resolve_sandbox_policies_derives_legacy_policy_from_split_policies() {
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
let resolved = resolve_sandbox_policies(
Path::new("/tmp"),
None,
Some(file_system_sandbox_policy.clone()),
Some(network_sandbox_policy),
);
assert_eq!(resolved.sandbox_policy, sandbox_policy);
assert_eq!(
resolved.file_system_sandbox_policy,
file_system_sandbox_policy
);
assert_eq!(resolved.network_sandbox_policy, network_sandbox_policy);
}
#[test]
fn resolve_sandbox_policies_rejects_partial_split_policies() {
let result = std::panic::catch_unwind(|| {
resolve_sandbox_policies(
Path::new("/tmp"),
Some(SandboxPolicy::new_read_only_policy()),
Some(FileSystemSandboxPolicy::default()),
None,
)
});
assert!(result.is_err());
}
#[test]
fn apply_seccomp_then_exec_without_bwrap_panics() {
let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false));

View File

@@ -9,6 +9,11 @@ use codex_core::exec::process_exec_tool_call;
use codex_core::exec_env::create_env;
use codex_core::sandboxing::SandboxPermissions;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -69,20 +74,6 @@ async fn run_cmd_result_with_writable_roots(
use_bwrap_sandbox: bool,
network_access: bool,
) -> Result<codex_core::exec::ExecToolCallOutput> {
let cwd = std::env::current_dir().expect("cwd should exist");
let sandbox_cwd = cwd.clone();
let params = ExecParams {
command: cmd.iter().copied().map(str::to_owned).collect(),
cwd,
expiration: timeout_ms.into(),
env: create_env_from_core_vars(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
arg0: None,
};
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots
.iter()
@@ -96,12 +87,49 @@ async fn run_cmd_result_with_writable_roots(
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
run_cmd_result_with_policies(
cmd,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
timeout_ms,
use_bwrap_sandbox,
)
.await
}
#[expect(clippy::expect_used)]
async fn run_cmd_result_with_policies(
cmd: &[&str],
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
timeout_ms: u64,
use_bwrap_sandbox: bool,
) -> Result<codex_core::exec::ExecToolCallOutput> {
let cwd = std::env::current_dir().expect("cwd should exist");
let sandbox_cwd = cwd.clone();
let params = ExecParams {
command: cmd.iter().copied().map(str::to_owned).collect(),
cwd,
expiration: timeout_ms.into(),
env: create_env_from_core_vars(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
arg0: None,
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
process_exec_tool_call(
params,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
use_bwrap_sandbox,
@@ -333,6 +361,8 @@ async fn assert_network_blocked(cmd: &[&str]) {
let result = process_exec_tool_call(
params,
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
false,
@@ -473,6 +503,59 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() {
assert_ne!(codex_output.exit_code, 0);
}
#[tokio::test]
async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let tmpdir = tempfile::tempdir().expect("tempdir");
let blocked = tmpdir.path().join("blocked");
std::fs::create_dir_all(&blocked).expect("create blocked dir");
let blocked_target = blocked.join("secret.txt");
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir")],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir"),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(blocked.as_path()).expect("absolute blocked dir"),
},
access: FileSystemAccessMode::None,
},
]);
let output = expect_denied(
run_cmd_result_with_policies(
&[
"bash",
"-lc",
&format!("echo denied > {}", blocked_target.to_string_lossy()),
],
sandbox_policy,
file_system_sandbox_policy,
NetworkSandboxPolicy::Enabled,
LONG_TIMEOUT_MS,
true,
)
.await,
"explicit split-policy carveout should be denied under bubblewrap",
);
assert_ne!(output.exit_code, 0);
}
#[tokio::test]
async fn sandbox_blocks_ssh() {
// Force ssh to attempt a real TCP connection but fail quickly. `BatchMode`

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,425 @@ 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 {
fn has_root_access(&self, predicate: impl Fn(FileSystemAccessMode) -> bool) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Special { value }
if value.kind == FileSystemSpecialPathKind::Root && predicate(entry.access)
)
})
}
fn has_explicit_deny_entries(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::None)
}
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,
}
}
/// Returns true when filesystem reads are unrestricted.
pub fn has_full_disk_read_access(&self) -> bool {
match self.kind {
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
FileSystemSandboxKind::Restricted => {
self.has_root_access(FileSystemAccessMode::can_read)
&& !self.has_explicit_deny_entries()
}
}
}
/// Returns true when filesystem writes are unrestricted.
pub fn has_full_disk_write_access(&self) -> bool {
match self.kind {
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
FileSystemSandboxKind::Restricted => {
self.has_root_access(FileSystemAccessMode::can_write)
&& !self.has_explicit_deny_entries()
}
}
}
/// Returns true when platform-default readable roots should be included.
pub fn include_platform_defaults(&self) -> bool {
!self.has_full_disk_read_access()
&& matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Special { value }
if value.kind == FileSystemSpecialPathKind::Minimal
&& entry.access.can_read()
)
})
}
/// Returns the explicit readable roots resolved against the provided cwd.
pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
if self.has_full_disk_read_access() {
return Vec::new();
}
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
let mut readable_roots = Vec::new();
if self.has_root_access(FileSystemAccessMode::can_read)
&& let Some(cwd_absolute) = cwd_absolute.as_ref()
{
readable_roots.push(absolute_root_path_for_cwd(cwd_absolute));
}
dedup_absolute_paths(
readable_roots
.into_iter()
.chain(
self.entries
.iter()
.filter(|entry| entry.access.can_read())
.filter_map(|entry| {
resolve_file_system_path(&entry.path, cwd_absolute.as_ref())
}),
)
.collect(),
)
}
/// Returns the writable roots together with read-only carveouts resolved
/// against the provided cwd.
pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
if self.has_full_disk_write_access() {
return Vec::new();
}
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
let unreadable_roots = self.get_unreadable_roots_with_cwd(cwd);
let mut writable_roots = Vec::new();
if self.has_root_access(FileSystemAccessMode::can_write)
&& let Some(cwd_absolute) = cwd_absolute.as_ref()
{
writable_roots.push(absolute_root_path_for_cwd(cwd_absolute));
}
dedup_absolute_paths(
writable_roots
.into_iter()
.chain(
self.entries
.iter()
.filter(|entry| entry.access.can_write())
.filter_map(|entry| {
resolve_file_system_path(&entry.path, cwd_absolute.as_ref())
}),
)
.collect(),
)
.into_iter()
.map(|root| {
let mut read_only_subpaths = default_read_only_subpaths_for_writable_root(&root);
read_only_subpaths.extend(
unreadable_roots
.iter()
.filter(|path| path.as_path().starts_with(root.as_path()))
.cloned(),
);
WritableRoot {
root,
read_only_subpaths: dedup_absolute_paths(read_only_subpaths),
}
})
.collect()
}
/// Returns explicit unreadable roots resolved against the provided cwd.
pub fn get_unreadable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
return Vec::new();
}
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
dedup_absolute_paths(
self.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
.filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref()))
.collect(),
)
}
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
}
@@ -715,6 +1135,22 @@ impl FromStr for SandboxPolicy {
}
}
impl FromStr for FileSystemSandboxPolicy {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_str(s)
}
}
impl FromStr for NetworkSandboxPolicy {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_str(s)
}
}
impl SandboxPolicy {
/// Returns a policy with read-only disk access and no network.
pub fn new_read_only_policy() -> Self {
@@ -877,45 +1313,11 @@ impl SandboxPolicy {
// For each root, compute subpaths that should remain read-only.
roots
.into_iter()
.map(|writable_root| {
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
#[allow(clippy::expect_used)]
let top_level_git = writable_root
.join(".git")
.expect(".git is a valid relative path");
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
&& !subpaths
.iter()
.any(|subpath| subpath.as_path() == gitdir.as_path())
{
subpaths.push(gitdir);
}
subpaths.push(top_level_git);
}
// Make .agents/skills and .codex/config.toml and
// related files read-only to the agent, by default.
for subdir in &[".agents", ".codex"] {
#[allow(clippy::expect_used)]
let top_level_codex =
writable_root.join(subdir).expect("valid relative path");
if top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
}
WritableRoot {
root: writable_root,
read_only_subpaths: subpaths,
}
.map(|writable_root| WritableRoot {
read_only_subpaths: default_read_only_subpaths_for_writable_root(
&writable_root,
),
root: writable_root,
})
.collect()
}
@@ -923,6 +1325,268 @@ 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 absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
let root = cwd
.as_path()
.ancestors()
.last()
.unwrap_or_else(|| panic!("cwd must have a filesystem root"));
AbsolutePathBuf::from_absolute_path(root)
.unwrap_or_else(|err| panic!("cwd root must be an absolute path: {err}"))
}
fn resolve_file_system_path(
path: &FileSystemPath,
cwd: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
match path {
FileSystemPath::Path { path } => Some(path.clone()),
FileSystemPath::Special { value } => resolve_file_system_special_path(value, cwd),
}
}
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 default_read_only_subpaths_for_writable_root(
writable_root: &AbsolutePathBuf,
) -> Vec<AbsolutePathBuf> {
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
#[allow(clippy::expect_used)]
let top_level_git = writable_root
.join(".git")
.expect(".git is a valid relative path");
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
{
subpaths.push(gitdir);
}
subpaths.push(top_level_git);
}
// Make .agents/skills and .codex/config.toml and related files read-only
// to the agent, by default.
for subdir in &[".agents", ".codex"] {
#[allow(clippy::expect_used)]
let top_level_codex = writable_root.join(subdir).expect("valid relative path");
if top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
}
dedup_absolute_paths(subpaths)
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
@@ -3121,6 +3785,7 @@ mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::NamedTempFile;
use tempfile::TempDir;
#[test]
fn external_sandbox_reports_full_access_flags() {
@@ -3201,6 +3866,193 @@ mod tests {
}
}
#[test]
fn restricted_file_system_policy_reports_full_access_from_root_entries() {
let read_only = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
}]);
assert!(read_only.has_full_disk_read_access());
assert!(!read_only.has_full_disk_write_access());
assert!(!read_only.include_platform_defaults());
let writable = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
}]);
assert!(writable.has_full_disk_read_access());
assert!(writable.has_full_disk_write_access());
}
#[test]
fn restricted_file_system_policy_treats_root_with_carveouts_as_scoped_access() {
let cwd = TempDir::new().expect("tempdir");
let cwd_absolute =
AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute tempdir");
let root = absolute_root_path_for_cwd(&cwd_absolute);
let blocked = AbsolutePathBuf::resolve_path_against_base("blocked", cwd.path())
.expect("resolve blocked");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Root,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: Some(PathBuf::from("blocked")),
},
},
access: FileSystemAccessMode::None,
},
]);
assert!(!policy.has_full_disk_read_access());
assert!(!policy.has_full_disk_write_access());
assert_eq!(
policy.get_readable_roots_with_cwd(cwd.path()),
vec![root.clone()]
);
assert_eq!(
policy.get_unreadable_roots_with_cwd(cwd.path()),
vec![blocked.clone()]
);
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
assert_eq!(writable_roots.len(), 1);
assert_eq!(writable_roots[0].root, root);
assert!(
writable_roots[0]
.read_only_subpaths
.iter()
.any(|path| path.as_path() == blocked.as_path())
);
}
#[test]
fn restricted_file_system_policy_derives_effective_paths() {
let cwd = TempDir::new().expect("tempdir");
std::fs::create_dir_all(cwd.path().join(".agents")).expect("create .agents");
std::fs::create_dir_all(cwd.path().join(".codex")).expect("create .codex");
let cwd_absolute =
AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute tempdir");
let secret = AbsolutePathBuf::resolve_path_against_base("secret", cwd.path())
.expect("resolve unreadable path");
let agents = AbsolutePathBuf::resolve_path_against_base(".agents", cwd.path())
.expect("resolve .agents");
let codex = AbsolutePathBuf::resolve_path_against_base(".codex", cwd.path())
.expect("resolve .codex");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::Minimal,
subpath: None,
},
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: None,
},
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath {
kind: FileSystemSpecialPathKind::CurrentWorkingDirectory,
subpath: Some(PathBuf::from("secret")),
},
},
access: FileSystemAccessMode::None,
},
]);
assert!(!policy.has_full_disk_read_access());
assert!(!policy.has_full_disk_write_access());
assert!(policy.include_platform_defaults());
assert_eq!(
policy.get_readable_roots_with_cwd(cwd.path()),
vec![cwd_absolute]
);
assert_eq!(
policy.get_unreadable_roots_with_cwd(cwd.path()),
vec![secret.clone()]
);
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
assert_eq!(writable_roots.len(), 1);
assert_eq!(writable_roots[0].root.as_path(), cwd.path());
assert!(
writable_roots[0]
.read_only_subpaths
.iter()
.any(|path| path.as_path() == secret.as_path())
);
assert!(
writable_roots[0]
.read_only_subpaths
.iter()
.any(|path| path.as_path() == agents.as_path())
);
assert!(
writable_roots[0]
.read_only_subpaths
.iter()
.any(|path| path.as_path() == codex.as_path())
);
}
#[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 {