permissions: derive config defaults as profiles (#19772)

## Why

This continues the permissions migration by making legacy config default
resolution produce the canonical `PermissionProfile` first. The legacy
`SandboxPolicy` projection should stay available at compatibility
boundaries, but config loading should not create a legacy policy just to
immediately convert it back into a profile.

Specifically, when `default_permissions` is not specified in
`config.toml`, instead of creating a `SandboxPolicy` in
`codex-rs/core/src/config/mod.rs` and then trying to derive a
`PermissionProfile` from it, we use `derive_permission_profile()` to
create a more faithful `PermissionProfile` using the values of
`ConfigToml` directly.

This also keeps the existing behavior of `sandbox_workspace_write` and
extra writable roots after #19841 replaced `:cwd` with `:project_roots`.
Legacy workspace-write defaults are represented as symbolic
`:project_roots` write access plus symbolic project-root metadata
carveouts. Extra absolute writable roots are still added directly and
continue to get concrete metadata protections for paths that exist under
those roots.

The platform sandboxes differ when a symbolic project-root subpath does
not exist yet.

* **Seatbelt** can encode literal/subpath exclusions directly, so macOS
emits project-root metadata subpath policies even if `.git`, `.agents`,
or `.codex` do not exist.
* **bwrap** has to materialize bind-mount targets. Binding `/dev/null`
to a missing `.git` can create a host-visible placeholder that changes
Git repo discovery. Binding missing `.agents` would not affect Git
discovery, but it would still create a host-visible project metadata
placeholder from an automatic compatibility carveout. Linux therefore
skips only missing automatic `.git` and `.agents` read-only metadata
masks; missing `.codex` remains protected so first-time project config
creation goes through the protected-path approval flow. User-authored
`read` and `none` subpath rules keep normal bwrap behavior, and `none`
can still mask the first missing component to prevent creation under
writable roots.

## What Changed

- Adds profile-native helpers for legacy workspace-write semantics,
including `PermissionProfile::workspace_write_with()`,
`FileSystemSandboxPolicy::workspace_write()`, and
`FileSystemSandboxPolicy::with_additional_legacy_workspace_writable_roots()`.
- Makes `FileSystemSandboxPolicy::workspace_write()` the single legacy
workspace-write constructor so both `from_legacy_sandbox_policy()` and
`From<&SandboxPolicy>` include the project-root metadata carveouts.
- Removes the no-carveout `legacy_workspace_write_base_policy()` path
and the `prune_read_entries_under_writable_roots()` cleanup that was
only needed by that split construction.
- Adds `ConfigToml::derive_permission_profile()` for legacy sandbox-mode
fallback resolution; named `default_permissions` profiles continue
through the permissions profile pipeline instead of being reconstructed
from `sandbox_mode`.
- Updates `Config::load()` to start from the derived profile, validate
that it still has a legacy compatibility projection, and apply
additional writable roots directly to managed workspace-write filesystem
policies.
- Updates Linux bwrap argument construction so missing automatic
`.git`/`.agents` symbolic project-root read-only carveouts are skipped
before emitting bind args; missing `.codex`, user-authored `read`/`none`
subpath rules, and existing missing writable-root behavior are
preserved.
- Adds coverage that legacy workspace-write config produces symbolic
project-root metadata carveouts, extra legacy workspace writable roots
still protect existing metadata paths such as `.git`, and bwrap skips
missing `.git`/`.agents` project-root carveouts while preserving missing
`.codex` and user-authored missing subpath rules.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/19772).
* #19776
* #19775
* #19774
* #19773
* __->__ #19772
This commit is contained in:
Michael Bolin
2026-04-27 16:50:10 -07:00
committed by GitHub
parent c5a495c2cd
commit 755880ef9c
8 changed files with 723 additions and 328 deletions
+39 -32
View File
@@ -49,8 +49,8 @@ use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path::normalize_for_path_comparison; use codex_utils_path::normalize_for_path_comparison;
use schemars::JsonSchema; use schemars::JsonSchema;
@@ -641,15 +641,19 @@ pub struct GhostSnapshotToml {
} }
impl ConfigToml { impl ConfigToml {
/// Derive the effective sandbox policy from the configuration. /// Derive the effective permission profile from legacy sandbox config.
pub async fn derive_sandbox_policy( ///
/// Call this only after ruling out `default_permissions`: named
/// `[permissions]` profiles must be compiled through the permissions
/// profile pipeline, not reconstructed from `sandbox_mode`.
pub async fn derive_permission_profile(
&self, &self,
sandbox_mode_override: Option<SandboxMode>, sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>, profile_sandbox_mode: Option<SandboxMode>,
windows_sandbox_level: WindowsSandboxLevel, windows_sandbox_level: WindowsSandboxLevel,
active_project: Option<&ProjectConfig>, active_project: Option<&ProjectConfig>,
permission_profile_constraint: Option<&crate::Constrained<PermissionProfile>>, permission_profile_constraint: Option<&crate::Constrained<PermissionProfile>>,
) -> SandboxPolicy { ) -> PermissionProfile {
let sandbox_mode_was_explicit = sandbox_mode_override.is_some() let sandbox_mode_was_explicit = sandbox_mode_override.is_some()
|| profile_sandbox_mode.is_some() || profile_sandbox_mode.is_some()
|| self.sandbox_mode.is_some(); || self.sandbox_mode.is_some();
@@ -677,50 +681,53 @@ impl ConfigToml {
}) })
}) })
.unwrap_or_default(); .unwrap_or_default();
let mut sandbox_policy = match resolved_sandbox_mode { let effective_sandbox_mode = if cfg!(target_os = "windows")
SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), // If the experimental Windows sandbox is enabled, do not force a downgrade.
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
&& matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite)
{
SandboxMode::ReadOnly
} else {
resolved_sandbox_mode
};
let permission_profile = match effective_sandbox_mode {
SandboxMode::ReadOnly => PermissionProfile::read_only(),
SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() {
Some(SandboxWorkspaceWrite { Some(SandboxWorkspaceWrite {
writable_roots, writable_roots,
network_access, network_access,
exclude_tmpdir_env_var, exclude_tmpdir_env_var,
exclude_slash_tmp, exclude_slash_tmp,
}) => SandboxPolicy::WorkspaceWrite { }) => {
writable_roots: writable_roots.clone(), let network_policy = if *network_access {
network_access: *network_access, NetworkSandboxPolicy::Enabled
exclude_tmpdir_env_var: *exclude_tmpdir_env_var, } else {
exclude_slash_tmp: *exclude_slash_tmp, NetworkSandboxPolicy::Restricted
}, };
None => SandboxPolicy::new_workspace_write_policy(), PermissionProfile::workspace_write_with(
writable_roots,
network_policy,
*exclude_tmpdir_env_var,
*exclude_slash_tmp,
)
}
None => PermissionProfile::workspace_write(),
}, },
SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess, SandboxMode::DangerFullAccess => PermissionProfile::Disabled,
}; };
let downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| {
if cfg!(target_os = "windows")
// If the experimental Windows sandbox is enabled, do not force a downgrade.
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
&& matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. })
{
*policy = SandboxPolicy::new_read_only_policy();
}
};
if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) {
downgrade_workspace_write_if_unsupported(&mut sandbox_policy);
}
if !sandbox_mode_was_explicit if !sandbox_mode_was_explicit
&& let Some(constraint) = permission_profile_constraint && let Some(constraint) = permission_profile_constraint
&& let Err(err) = constraint.can_set(&PermissionProfile::from_legacy_sandbox_policy( && let Err(err) = constraint.can_set(&permission_profile)
&sandbox_policy,
))
{ {
tracing::warn!( tracing::warn!(
error = %err, error = %err,
"default sandbox policy is disallowed by requirements; falling back to required default" "default sandbox policy is disallowed by requirements; falling back to required default"
); );
sandbox_policy = SandboxPolicy::new_read_only_policy(); PermissionProfile::read_only()
downgrade_workspace_write_if_unsupported(&mut sandbox_policy); } else {
permission_profile
} }
sandbox_policy
} }
/// Resolves the cwd to an existing project, or returns None if ConfigToml /// Resolves the cwd to an existing project, or returns None if ConfigToml
+171 -78
View File
@@ -133,6 +133,34 @@ fn http_mcp(url: &str) -> McpServerConfig {
} }
} }
async fn derive_legacy_sandbox_policy_for_test(
cfg: &ConfigToml,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
windows_sandbox_level: WindowsSandboxLevel,
active_project: Option<&ProjectConfig>,
permission_profile_constraint: Option<&Constrained<PermissionProfile>>,
) -> SandboxPolicy {
let permission_profile = cfg
.derive_permission_profile(
sandbox_mode_override,
profile_sandbox_mode,
windows_sandbox_level,
active_project,
permission_profile_constraint,
)
.await;
permission_profile
.to_legacy_sandbox_policy(Path::new("/"))
.unwrap_or_else(|err| {
tracing::warn!(
error = %err,
"derived permission profile cannot be represented as a legacy sandbox policy; falling back to read-only"
);
SandboxPolicy::new_read_only_policy()
})
}
#[tokio::test] #[tokio::test]
async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> {
let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?; let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?;
@@ -1630,15 +1658,15 @@ network_access = false # This should be ignored.
let sandbox_full_access_cfg = toml::from_str::<ConfigToml>(sandbox_full_access) let sandbox_full_access_cfg = toml::from_str::<ConfigToml>(sandbox_full_access)
.expect("TOML deserialization should succeed"); .expect("TOML deserialization should succeed");
let sandbox_mode_override = None; let sandbox_mode_override = None;
let resolution = sandbox_full_access_cfg let resolution = derive_legacy_sandbox_policy_for_test(
.derive_sandbox_policy( &sandbox_full_access_cfg,
sandbox_mode_override, sandbox_mode_override,
/*profile_sandbox_mode*/ None, /*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled, WindowsSandboxLevel::Disabled,
/*active_project*/ None, /*active_project*/ None,
/*permission_profile_constraint*/ None, /*permission_profile_constraint*/ None,
) )
.await; .await;
assert_eq!(resolution, SandboxPolicy::DangerFullAccess); assert_eq!(resolution, SandboxPolicy::DangerFullAccess);
let sandbox_read_only = r#" let sandbox_read_only = r#"
@@ -1651,15 +1679,15 @@ network_access = true # This should be ignored.
let sandbox_read_only_cfg = toml::from_str::<ConfigToml>(sandbox_read_only) let sandbox_read_only_cfg = toml::from_str::<ConfigToml>(sandbox_read_only)
.expect("TOML deserialization should succeed"); .expect("TOML deserialization should succeed");
let sandbox_mode_override = None; let sandbox_mode_override = None;
let resolution = sandbox_read_only_cfg let resolution = derive_legacy_sandbox_policy_for_test(
.derive_sandbox_policy( &sandbox_read_only_cfg,
sandbox_mode_override, sandbox_mode_override,
/*profile_sandbox_mode*/ None, /*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled, WindowsSandboxLevel::Disabled,
/*active_project*/ None, /*active_project*/ None,
/*permission_profile_constraint*/ None, /*permission_profile_constraint*/ None,
) )
.await; .await;
assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
let writable_root = test_absolute_path("/my/workspace"); let writable_root = test_absolute_path("/my/workspace");
@@ -1683,15 +1711,15 @@ trust_level = "trusted"
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(&sandbox_workspace_write) let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(&sandbox_workspace_write)
.expect("TOML deserialization should succeed"); .expect("TOML deserialization should succeed");
let sandbox_mode_override = None; let sandbox_mode_override = None;
let resolution = sandbox_workspace_write_cfg let resolution = derive_legacy_sandbox_policy_for_test(
.derive_sandbox_policy( &sandbox_workspace_write_cfg,
sandbox_mode_override, sandbox_mode_override,
/*profile_sandbox_mode*/ None, /*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled, WindowsSandboxLevel::Disabled,
/*active_project*/ None, /*active_project*/ None,
/*permission_profile_constraint*/ None, /*permission_profile_constraint*/ None,
) )
.await; .await;
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
} else { } else {
@@ -1723,15 +1751,15 @@ exclude_slash_tmp = true
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(&sandbox_workspace_write) let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(&sandbox_workspace_write)
.expect("TOML deserialization should succeed"); .expect("TOML deserialization should succeed");
let sandbox_mode_override = None; let sandbox_mode_override = None;
let resolution = sandbox_workspace_write_cfg let resolution = derive_legacy_sandbox_policy_for_test(
.derive_sandbox_policy( &sandbox_workspace_write_cfg,
sandbox_mode_override, sandbox_mode_override,
/*profile_sandbox_mode*/ None, /*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled, WindowsSandboxLevel::Disabled,
/*active_project*/ None, /*active_project*/ None,
/*permission_profile_constraint*/ None, /*permission_profile_constraint*/ None,
) )
.await; .await;
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
} else { } else {
@@ -1748,7 +1776,7 @@ exclude_slash_tmp = true
} }
#[tokio::test] #[tokio::test]
async fn legacy_sandbox_mode_config_builds_split_policies_without_drift() -> std::io::Result<()> { async fn legacy_sandbox_mode_builds_profiles_with_compatible_projection() -> std::io::Result<()> {
let codex_home = TempDir::new()?; let codex_home = TempDir::new()?;
let cwd = TempDir::new()?; let cwd = TempDir::new()?;
let extra_root = test_absolute_path("/tmp/legacy-extra-root"); let extra_root = test_absolute_path("/tmp/legacy-extra-root");
@@ -1793,26 +1821,91 @@ exclude_slash_tmp = true
) )
.await?; .await?;
let sandbox_policy = &config.legacy_sandbox_policy(); let sandbox_policy = config.legacy_sandbox_policy();
let file_system_policy = config.permissions.file_system_sandbox_policy();
let network_policy = config.permissions.network_sandbox_policy();
assert_eq!( assert_eq!(
config.permissions.file_system_sandbox_policy(), network_policy,
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd.path()), NetworkSandboxPolicy::from(&sandbox_policy),
"case `{name}` should preserve filesystem semantics from legacy config"
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::from(sandbox_policy),
"case `{name}` should preserve network semantics from legacy config" "case `{name}` should preserve network semantics from legacy config"
); );
assert_eq!( assert_eq!(
config file_system_policy
.permissions .to_legacy_sandbox_policy(network_policy, cwd.path())
.file_system_sandbox_policy()
.to_legacy_sandbox_policy(config.permissions.network_sandbox_policy(), cwd.path())
.unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")),
sandbox_policy.clone(), sandbox_policy,
"case `{name}` should round-trip through split policies without drift" "case `{name}` should preserve its legacy compatibility projection"
); );
match name.as_str() {
"danger-full-access" | "read-only" => {
assert_eq!(
file_system_policy,
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
&sandbox_policy,
cwd.path()
),
"case `{name}` should match the legacy filesystem projection exactly"
);
}
"workspace-write" => {
if cfg!(target_os = "windows") {
assert_eq!(
sandbox_policy,
SandboxPolicy::new_read_only_policy(),
"legacy workspace-write should keep the existing Windows downgrade when \
the experimental Windows sandbox is disabled"
);
assert_eq!(
file_system_policy,
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
&sandbox_policy,
cwd.path()
),
"downgraded workspace-write should match the legacy read-only projection"
);
continue;
}
assert!(
file_system_policy
.entries
.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
})
);
assert!(
file_system_policy
.entries
.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: extra_root.clone(),
},
access: FileSystemAccessMode::Write,
})
);
for subpath in [".git", ".agents", ".codex"] {
assert!(
file_system_policy
.entries
.contains(&FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(
subpath.into()
)),
},
access: FileSystemAccessMode::Read,
}),
"case `{name}` should preserve `{subpath}` as a symbolic project-root \
metadata carveout"
);
}
}
_ => unreachable!("unexpected test case `{name}`"),
}
} }
Ok(()) Ok(())
@@ -6310,15 +6403,15 @@ trust_level = "untrusted"
trust_level: Some(TrustLevel::Untrusted), trust_level: Some(TrustLevel::Untrusted),
}; };
let resolution = cfg let resolution = derive_legacy_sandbox_policy_for_test(
.derive_sandbox_policy( &cfg,
/*sandbox_mode_override*/ None, /*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None, /*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled, WindowsSandboxLevel::Disabled,
Some(&active_project), Some(&active_project),
/*permission_profile_constraint*/ None, /*permission_profile_constraint*/ None,
) )
.await; .await;
// Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade) // Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade)
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
@@ -6367,15 +6460,15 @@ async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() -
} }
})?; })?;
let resolution = cfg let resolution = derive_legacy_sandbox_policy_for_test(
.derive_sandbox_policy( &cfg,
/*sandbox_mode_override*/ None, /*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None, /*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled, WindowsSandboxLevel::Disabled,
Some(&active_project), Some(&active_project),
Some(&constrained), Some(&constrained),
) )
.await; .await;
assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
Ok(()) Ok(())
@@ -6423,15 +6516,15 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb
}, },
)?; )?;
let resolution = cfg let resolution = derive_legacy_sandbox_policy_for_test(
.derive_sandbox_policy( &cfg,
/*sandbox_mode_override*/ None, /*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None, /*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled, WindowsSandboxLevel::Disabled,
Some(&active_project), Some(&active_project),
Some(&constrained), Some(&constrained),
) )
.await; .await;
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
+46 -19
View File
@@ -1976,8 +1976,13 @@ impl Config {
) )
} else { } else {
let configured_network_proxy_config = NetworkProxyConfig::default(); let configured_network_proxy_config = NetworkProxyConfig::default();
let mut sandbox_policy = cfg // No named `[permissions]` profile is active, but permissions
.derive_sandbox_policy( // should still flow through the canonical profile representation.
// Derive the old `sandbox_mode` defaults as a profile first, then
// keep a legacy-compatible projection only for the remaining code
// paths that still speak `SandboxPolicy`.
let mut permission_profile = cfg
.derive_permission_profile(
sandbox_mode, sandbox_mode,
config_profile.sandbox_mode, config_profile.sandbox_mode,
windows_sandbox_level, windows_sandbox_level,
@@ -1985,24 +1990,46 @@ impl Config {
Some(&constrained_permission_profile), Some(&constrained_permission_profile),
) )
.await; .await;
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { // The legacy-derived profiles above are expected to be
for path in &additional_writable_roots { // representable as `SandboxPolicy`. This guard keeps the old safe
if !writable_roots.iter().any(|existing| existing == path) { // fallback behavior if future changes make this branch derive a
writable_roots.push(path.clone()); // profile with split-only filesystem semantics, such as root write
} // with carveouts or writes that are not expressible as
} // workspace-write roots.
if let Err(err) = permission_profile.to_legacy_sandbox_policy(resolved_cwd.as_path()) {
tracing::warn!(
error = %err,
"derived permission profile cannot be represented as a legacy sandbox policy; falling back to read-only"
);
permission_profile = PermissionProfile::read_only();
}
let (mut file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
// `additional_writable_roots` is a legacy workspace-write knob. It
// only applies when the derived managed profile has workspace-style
// write access to the project roots; read-only, disabled, external,
// and future non-workspace profiles must not silently grow extra
// write access.
if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed)
&& file_system_sandbox_policy.can_write_path_with_cwd(
resolved_cwd.as_path(),
resolved_cwd.as_path(),
)
&& !file_system_sandbox_policy.has_full_disk_write_access()
{
// Keep legacy behavior for extra writable roots while storing
// the result as the canonical permission profile. Explicit
// extra roots are concrete paths, so their metadata carveouts
// are also concrete rather than symbolic `:project_roots`
// entries.
file_system_sandbox_policy = file_system_sandbox_policy
.with_additional_legacy_workspace_writable_roots(&additional_writable_roots);
permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
permission_profile.enforcement(),
&file_system_sandbox_policy,
network_sandbox_policy,
);
} }
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
&sandbox_policy,
resolved_cwd.as_path(),
);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy),
&file_system_sandbox_policy,
network_sandbox_policy,
);
( (
configured_network_proxy_config, configured_network_proxy_config,
permission_profile, permission_profile,
+5 -1
View File
@@ -111,7 +111,11 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
} }
fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy { fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()) FileSystemSandboxPolicy::workspace_write(
&[],
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
)
} }
fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
+134 -1
View File
@@ -24,7 +24,10 @@ use std::process::Command;
use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErr;
use codex_protocol::error::Result; use codex_protocol::error::Result;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::WritableRoot; use codex_protocol::protocol::WritableRoot;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use globset::GlobBuilder; use globset::GlobBuilder;
@@ -258,6 +261,35 @@ fn create_filesystem_args(
read_only_subpaths: Vec::new(), read_only_subpaths: Vec::new(),
}); });
} }
let missing_auto_metadata_read_only_project_root_subpaths: HashSet<PathBuf> =
file_system_sandbox_policy
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::Read)
.filter_map(|entry| {
let FileSystemPath::Special {
value:
FileSystemSpecialPath::ProjectRoots {
subpath: Some(subpath),
},
} = &entry.path
else {
return None;
};
// Missing `.codex` remains protected so first-time project config
// creation still goes through the protected-path approval flow.
// Only the automatic repo-metadata read masks are skipped here:
// user-authored `read` rules for other subpaths and `none` rules
// should keep their normal bwrap behavior, which can mask the
// first missing component to prevent creation under writable roots.
let project_subpath = subpath.as_path();
if project_subpath != Path::new(".git") && project_subpath != Path::new(".agents") {
return None;
}
let resolved = AbsolutePathBuf::resolve_path_against_base(subpath, cwd);
(!resolved.as_path().exists()).then(|| resolved.into_path_buf())
})
.collect();
let mut unreadable_roots = file_system_sandbox_policy let mut unreadable_roots = file_system_sandbox_policy
.get_unreadable_roots_with_cwd(cwd) .get_unreadable_roots_with_cwd(cwd)
.into_iter() .into_iter()
@@ -410,6 +442,7 @@ fn create_filesystem_args(
.iter() .iter()
.map(|path| path.as_path().to_path_buf()) .map(|path| path.as_path().to_path_buf())
.filter(|path| !unreadable_paths.contains(path)) .filter(|path| !unreadable_paths.contains(path))
.filter(|path| !missing_auto_metadata_read_only_project_root_subpaths.contains(path))
.collect(); .collect();
if let Some(target) = &symlink_target { if let Some(target) = &symlink_target {
read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target); read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target);
@@ -1396,6 +1429,106 @@ mod tests {
); );
} }
#[test]
fn skips_missing_project_root_metadata_carveouts_except_codex() {
let temp_dir = TempDir::new().expect("temp dir");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".git".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".agents".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".codex".into())),
},
access: FileSystemAccessMode::Read,
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let dot_git = path_to_string(&temp_dir.path().join(".git"));
let dot_agents = path_to_string(&temp_dir.path().join(".agents"));
let dot_codex = path_to_string(&temp_dir.path().join(".codex"));
assert!(!args.args.iter().any(|arg| arg == &dot_git));
assert!(!args.args.iter().any(|arg| arg == &dot_agents));
assert!(
args.args
.windows(3)
.any(|window| { window == ["--ro-bind", "/dev/null", dot_codex.as_str()] })
);
}
#[test]
fn missing_user_project_root_subpath_rules_are_still_enforced() {
let temp_dir = TempDir::new().expect("temp dir");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".vscode".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".secrets".into())),
},
access: FileSystemAccessMode::None,
},
]);
let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let dot_vscode = path_to_string(&temp_dir.path().join(".vscode"));
let dot_secrets = path_to_string(&temp_dir.path().join(".secrets"));
assert!(
args.args
.windows(3)
.any(|window| { window == ["--ro-bind", "/dev/null", dot_vscode.as_str()] })
);
assert!(
args.args
.windows(3)
.any(|window| { window == ["--ro-bind", "/dev/null", dot_secrets.as_str()] })
);
}
#[test] #[test]
fn mounts_dev_before_writable_dev_binds() { fn mounts_dev_before_writable_dev_binds() {
let sandbox_policy = SandboxPolicy::WorkspaceWrite { let sandbox_policy = SandboxPolicy::WorkspaceWrite {
@@ -1427,7 +1560,7 @@ mod tests {
"/".to_string(), "/".to_string(),
// Mask the default protected .codex subpath under that writable // Mask the default protected .codex subpath under that writable
// root. Because the root is `/` in this test, the carveout path // root. Because the root is `/` in this test, the carveout path
// appears as `/.codex`. // appears at the filesystem root.
"--ro-bind".to_string(), "--ro-bind".to_string(),
"/dev/null".to_string(), "/dev/null".to_string(),
"/.codex".to_string(), "/.codex".to_string(),
+35 -49
View File
@@ -408,55 +408,33 @@ impl PermissionProfile {
/// The returned profile contains symbolic `:project_roots` entries that /// The returned profile contains symbolic `:project_roots` entries that
/// must be resolved against the active permission root before enforcement. /// must be resolved against the active permission root before enforcement.
pub fn workspace_write() -> Self { pub fn workspace_write() -> Self {
Self::workspace_write_with(
&[],
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
)
}
/// Managed workspace-write filesystem access with the legacy
/// `sandbox_workspace_write` knobs applied directly to the profile.
///
/// The returned profile contains symbolic `:project_roots` entries that
/// must be resolved against the active permission root before enforcement.
pub fn workspace_write_with(
writable_roots: &[AbsolutePathBuf],
network: NetworkSandboxPolicy,
exclude_tmpdir_env_var: bool,
exclude_slash_tmp: bool,
) -> Self {
let file_system = FileSystemSandboxPolicy::workspace_write(
writable_roots,
exclude_tmpdir_env_var,
exclude_slash_tmp,
);
Self::Managed { Self::Managed {
file_system: ManagedFileSystemPermissions::Restricted { file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
entries: vec![ network,
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".git".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".agents".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".codex".into())),
},
access: FileSystemAccessMode::Read,
},
],
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
} }
} }
@@ -503,7 +481,15 @@ impl PermissionProfile {
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self { pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
Self::from_runtime_permissions_with_enforcement( Self::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy), SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy), &FileSystemSandboxPolicy::from(sandbox_policy),
NetworkSandboxPolicy::from(sandbox_policy),
)
}
pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
Self::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
&FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd),
NetworkSandboxPolicy::from(sandbox_policy), NetworkSandboxPolicy::from(sandbox_policy),
) )
} }
+262 -144
View File
@@ -412,57 +412,65 @@ impl FileSystemSandboxPolicy {
}) })
} }
/// Converts a legacy sandbox policy into a cwd-independent filesystem policy. /// Filesystem policy matching `WorkspaceWrite` semantics without requiring
/// /// callers to construct a legacy [`SandboxPolicy`] first.
/// `WorkspaceWrite` uses symbolic project-root entries so callers can keep pub fn workspace_write(
/// the profile independent of the concrete root until it is resolved for a writable_roots: &[AbsolutePathBuf],
/// turn or command. exclude_tmpdir_env_var: bool,
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self { exclude_slash_tmp: bool,
let mut file_system_policy = Self::from(sandbox_policy); ) -> Self {
let SandboxPolicy::WorkspaceWrite { let mut entries = vec![FileSystemSandboxEntry {
writable_roots, path: FileSystemPath::Special {
exclude_tmpdir_env_var, value: FileSystemSpecialPath::Root,
exclude_slash_tmp, },
.. access: FileSystemAccessMode::Read,
} = sandbox_policy }];
else {
return file_system_policy;
};
prune_read_entries_under_writable_roots( entries.push(FileSystemSandboxEntry {
&mut file_system_policy.entries, path: FileSystemPath::Special {
&legacy_non_cwd_writable_roots( value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
writable_roots, },
*exclude_tmpdir_env_var, access: FileSystemAccessMode::Write,
*exclude_slash_tmp, });
), if !exclude_slash_tmp {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
access: FileSystemAccessMode::Write,
});
}
if !exclude_tmpdir_env_var {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
});
}
entries.extend(
writable_roots
.iter()
.cloned()
.map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
}),
); );
append_default_read_only_project_root_subpath_if_no_explicit_rule( append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".git");
&mut file_system_policy.entries, append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".agents");
".git", append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".codex");
);
append_default_read_only_project_root_subpath_if_no_explicit_rule(
&mut file_system_policy.entries,
".agents",
);
append_default_read_only_project_root_subpath_if_no_explicit_rule(
&mut file_system_policy.entries,
".codex",
);
for writable_root in writable_roots { for writable_root in writable_roots {
for protected_path in default_read_only_subpaths_for_writable_root( for protected_path in default_read_only_subpaths_for_writable_root(
writable_root, writable_root,
/*protect_missing_dot_codex*/ false, /*protect_missing_dot_codex*/ false,
) { ) {
append_default_read_only_path_if_no_explicit_rule( append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
&mut file_system_policy.entries,
protected_path,
);
} }
} }
file_system_policy FileSystemSandboxPolicy::restricted(entries)
} }
/// Converts a legacy sandbox policy into an equivalent filesystem policy /// Converts a legacy sandbox policy into an equivalent filesystem policy
@@ -475,12 +483,6 @@ impl FileSystemSandboxPolicy {
pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self { pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
let mut file_system_policy = Self::from(sandbox_policy); let mut file_system_policy = Self::from(sandbox_policy);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy { if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy {
let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
prune_read_entries_under_writable_roots(
&mut file_system_policy.entries,
&legacy_writable_roots,
);
if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) { if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
for protected_path in default_read_only_subpaths_for_writable_root( for protected_path in default_read_only_subpaths_for_writable_root(
&cwd_root, /*protect_missing_dot_codex*/ true, &cwd_root, /*protect_missing_dot_codex*/ true,
@@ -635,6 +637,44 @@ impl FileSystemSandboxPolicy {
self self
} }
/// Add roots using legacy `WorkspaceWrite` behavior.
///
/// Unlike [`Self::with_additional_writable_roots`], this mirrors legacy
/// writable-roots semantics by adding exact roots even when they are
/// already writable through `:project_roots`, and by adding the default
/// read-only protected subpaths for each new root.
pub fn with_additional_legacy_workspace_writable_roots(
mut self,
additional_writable_roots: &[AbsolutePathBuf],
) -> Self {
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
return self;
}
for path in additional_writable_roots {
if !self.entries.iter().any(|entry| {
entry.access.can_write()
&& matches!(&entry.path, FileSystemPath::Path { path: existing } if existing == path)
}) {
self.entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Path { path: path.clone() },
access: FileSystemAccessMode::Write,
});
}
for protected_path in default_read_only_subpaths_for_writable_root(
path, /*protect_missing_dot_codex*/ false,
) {
append_default_read_only_path_if_no_explicit_rule(
&mut self.entries,
protected_path,
);
}
}
self
}
pub fn needs_direct_runtime_enforcement( pub fn needs_direct_runtime_enforcement(
&self, &self,
network_policy: NetworkSandboxPolicy, network_policy: NetworkSandboxPolicy,
@@ -649,7 +689,7 @@ impl FileSystemSandboxPolicy {
}; };
self.semantic_signature(cwd) self.semantic_signature(cwd)
!= FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&legacy_policy, cwd) != legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd)
.semantic_signature(cwd) .semantic_signature(cwd)
} }
@@ -1008,47 +1048,11 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
exclude_tmpdir_env_var, exclude_tmpdir_env_var,
exclude_slash_tmp, exclude_slash_tmp,
.. ..
} => { } => FileSystemSandboxPolicy::workspace_write(
let mut entries = vec![FileSystemSandboxEntry { writable_roots,
path: FileSystemPath::Special { *exclude_tmpdir_env_var,
value: FileSystemSpecialPath::Root, *exclude_slash_tmp,
}, ),
access: FileSystemAccessMode::Read,
}];
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
});
if !exclude_slash_tmp {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
access: FileSystemAccessMode::Write,
});
}
if !exclude_tmpdir_env_var {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
});
}
entries.extend(
writable_roots
.iter()
.cloned()
.map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
}),
);
FileSystemSandboxPolicy::restricted(entries)
}
} }
} }
} }
@@ -1337,6 +1341,87 @@ fn default_read_only_subpaths_for_writable_root(
dedup_absolute_paths(subpaths, /*normalize_effective_paths*/ false) dedup_absolute_paths(subpaths, /*normalize_effective_paths*/ false)
} }
/// Rebuilds the filesystem policy that legacy sandbox runtimes enforce for a
/// concrete cwd.
///
/// Unlike [`FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd`], this
/// intentionally does not add symbolic project-root metadata carveouts. Legacy
/// runtime expansion only protected `.git`/`.agents` when those paths already
/// existed, so missing-path carveouts still require direct profile enforcement.
fn legacy_runtime_file_system_policy_for_cwd(
sandbox_policy: &SandboxPolicy,
cwd: &Path,
) -> FileSystemSandboxPolicy {
let SandboxPolicy::WorkspaceWrite {
writable_roots,
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
} = sandbox_policy
else {
return FileSystemSandboxPolicy::from(sandbox_policy);
};
let mut entries = vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
];
if !*exclude_slash_tmp {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
access: FileSystemAccessMode::Write,
});
}
if !*exclude_tmpdir_env_var {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
});
}
entries.extend(
writable_roots
.iter()
.cloned()
.map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
}),
);
if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
for protected_path in default_read_only_subpaths_for_writable_root(
&cwd_root, /*protect_missing_dot_codex*/ true,
) {
append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
}
}
for writable_root in writable_roots {
for protected_path in default_read_only_subpaths_for_writable_root(
writable_root,
/*protect_missing_dot_codex*/ false,
) {
append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
}
}
FileSystemSandboxPolicy::restricted(entries)
}
fn append_default_read_only_project_root_subpath_if_no_explicit_rule( fn append_default_read_only_project_root_subpath_if_no_explicit_rule(
entries: &mut Vec<FileSystemSandboxEntry>, entries: &mut Vec<FileSystemSandboxEntry>,
subpath: impl Into<PathBuf>, subpath: impl Into<PathBuf>,
@@ -1373,58 +1458,6 @@ fn append_default_read_only_entry_if_no_explicit_rule(
}); });
} }
fn prune_read_entries_under_writable_roots(
entries: &mut Vec<FileSystemSandboxEntry>,
legacy_writable_roots: &[WritableRoot],
) {
entries.retain(|entry| {
if entry.access != FileSystemAccessMode::Read {
return true;
}
match &entry.path {
FileSystemPath::Path { path } => !legacy_writable_roots
.iter()
.any(|root| root.is_path_writable(path.as_path())),
FileSystemPath::GlobPattern { .. } | FileSystemPath::Special { .. } => true,
}
});
}
fn legacy_non_cwd_writable_roots(
writable_roots: &[AbsolutePathBuf],
exclude_tmpdir_env_var: bool,
exclude_slash_tmp: bool,
) -> Vec<WritableRoot> {
let mut roots: Vec<AbsolutePathBuf> = writable_roots.to_vec();
if cfg!(unix)
&& !exclude_slash_tmp
&& let Ok(slash_tmp) = AbsolutePathBuf::from_absolute_path("/tmp")
&& slash_tmp.as_path().is_dir()
{
roots.push(slash_tmp);
}
if !exclude_tmpdir_env_var
&& let Some(tmpdir) = std::env::var_os("TMPDIR")
&& !tmpdir.is_empty()
&& let Ok(tmpdir_path) = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir))
{
roots.push(tmpdir_path);
}
dedup_absolute_paths(roots, /*normalize_effective_paths*/ true)
.into_iter()
.map(|root| WritableRoot {
read_only_subpaths: default_read_only_subpaths_for_writable_root(
&root, /*protect_missing_dot_codex*/ false,
),
root,
})
.collect()
}
fn has_explicit_resolved_path_entry( fn has_explicit_resolved_path_entry(
entries: &[ResolvedFileSystemEntry], entries: &[ResolvedFileSystemEntry],
path: &AbsolutePathBuf, path: &AbsolutePathBuf,
@@ -1576,7 +1609,7 @@ mod tests {
}; };
assert_eq!( assert_eq!(
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy), FileSystemSandboxPolicy::from(&policy),
FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry { FileSystemSandboxEntry {
path: FileSystemPath::Special { path: FileSystemPath::Special {
@@ -1729,6 +1762,24 @@ mod tests {
}, },
access: FileSystemAccessMode::Write, access: FileSystemAccessMode::Write,
}, },
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".git".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".agents".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".codex".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry { FileSystemSandboxEntry {
path: FileSystemPath::Path { path: FileSystemPath::Path {
path: expected_dot_codex, path: expected_dot_codex,
@@ -2177,7 +2228,7 @@ mod tests {
policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),) policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
); );
let legacy_workspace_write = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( let legacy_workspace_write = legacy_runtime_file_system_policy_for_cwd(
&SandboxPolicy::new_workspace_write_policy(), &SandboxPolicy::new_workspace_write_policy(),
cwd.path(), cwd.path(),
); );
@@ -2196,8 +2247,7 @@ mod tests {
exclude_tmpdir_env_var: true, exclude_tmpdir_env_var: true,
exclude_slash_tmp: true, exclude_slash_tmp: true,
}; };
let legacy_order = let legacy_order = legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path());
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&legacy_policy, cwd.path());
let mut reordered_entries = legacy_order.entries.clone(); let mut reordered_entries = legacy_order.entries.clone();
reordered_entries.reverse(); reordered_entries.reverse();
let reordered = FileSystemSandboxPolicy::restricted(reordered_entries); let reordered = FileSystemSandboxPolicy::restricted(reordered_entries);
@@ -2212,6 +2262,33 @@ mod tests {
); );
} }
#[test]
fn missing_symbolic_metadata_carveouts_need_direct_runtime_enforcement() {
let cwd = TempDir::new().expect("tempdir");
let legacy_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let profile_projection =
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&legacy_policy, cwd.path());
assert!(
profile_projection
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
"symbolic .git/.agents carveouts protect missing paths that legacy sandboxes cannot represent"
);
let legacy_runtime_projection =
legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path());
assert!(
!legacy_runtime_projection
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
"true legacy runtime expansion should still classify as legacy-compatible"
);
}
#[test] #[test]
fn root_write_with_read_only_child_is_not_full_disk_write() { fn root_write_with_read_only_child_is_not_full_disk_write() {
let cwd = TempDir::new().expect("tempdir"); let cwd = TempDir::new().expect("tempdir");
@@ -2402,6 +2479,47 @@ mod tests {
); );
} }
#[test]
fn with_additional_legacy_workspace_writable_roots_protects_metadata() {
let temp_dir = TempDir::new().expect("tempdir");
let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra"))
.expect("resolve extra root");
std::fs::create_dir_all(extra.join(".git")).expect("create .git dir");
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}]);
let actual =
policy.with_additional_legacy_workspace_writable_roots(std::slice::from_ref(&extra));
assert_eq!(
actual,
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: extra.clone()
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: extra.join(".git")
},
access: FileSystemAccessMode::Read,
},
])
);
}
#[test] #[test]
fn file_system_access_mode_orders_by_conflict_precedence() { fn file_system_access_mode_orders_by_conflict_precedence() {
assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read); assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
+31 -4
View File
@@ -828,7 +828,12 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
); );
assert!( assert!(
policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_0"), policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_0"),
"expected cwd .codex carveout in policy:\n{policy_text}", "expected cwd metadata carveouts in policy:\n{policy_text}",
);
assert!(
policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_1")
&& policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_2"),
"expected symbolic cwd .git/.agents carveouts in policy:\n{policy_text}",
); );
assert!( assert!(
policy_text.contains("WRITABLE_ROOT_1_EXCLUDED_0") policy_text.contains("WRITABLE_ROOT_1_EXCLUDED_0")
@@ -854,6 +859,20 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
.join(".codex") .join(".codex")
.display() .display()
), ),
format!(
"-DWRITABLE_ROOT_0_EXCLUDED_1={}",
cwd.canonicalize()
.expect("canonicalize cwd")
.join(".git")
.display()
),
format!(
"-DWRITABLE_ROOT_0_EXCLUDED_2={}",
cwd.canonicalize()
.expect("canonicalize cwd")
.join(".agents")
.display()
),
format!( format!(
"-DWRITABLE_ROOT_1={}", "-DWRITABLE_ROOT_1={}",
vulnerable_root_canonical.to_string_lossy() vulnerable_root_canonical.to_string_lossy()
@@ -1194,7 +1213,7 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
.map(|p| p.to_string_lossy().to_string()); .map(|p| p.to_string_lossy().to_string());
let tempdir_policy_entry = if tmpdir_env_var.is_some() { let tempdir_policy_entry = if tmpdir_env_var.is_some() {
r#" (require-all (subpath (param "WRITABLE_ROOT_2")) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_1"))) )"# r#" (require-all (subpath (param "WRITABLE_ROOT_2")) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_2"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_2"))) )"#
} else { } else {
"" ""
}; };
@@ -1203,13 +1222,13 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
// Note that the policy includes: // Note that the policy includes:
// - the base policy, // - the base policy,
// - read-only access to the filesystem, // - read-only access to the filesystem,
// - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2. // - write access to WRITABLE_ROOT_0 (but not its metadata subpaths), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2.
let expected_policy = format!( let expected_policy = format!(
r#"{MACOS_SEATBELT_BASE_POLICY} r#"{MACOS_SEATBELT_BASE_POLICY}
; allow read-only file operations ; allow read-only file operations
(allow file-read*) (allow file-read*)
(allow file-write* (allow file-write*
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry} (require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_2"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_2"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
) )
"#, "#,
@@ -1230,6 +1249,10 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
"-DWRITABLE_ROOT_0_EXCLUDED_1={}", "-DWRITABLE_ROOT_0_EXCLUDED_1={}",
dot_codex_canonical.to_string_lossy() dot_codex_canonical.to_string_lossy()
), ),
format!(
"-DWRITABLE_ROOT_0_EXCLUDED_2={}",
vulnerable_root_canonical.join(".agents").to_string_lossy()
),
format!( format!(
"-DWRITABLE_ROOT_1={}", "-DWRITABLE_ROOT_1={}",
PathBuf::from("/tmp") PathBuf::from("/tmp")
@@ -1247,6 +1270,10 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
)); ));
expected_args.push(format!( expected_args.push(format!(
"-DWRITABLE_ROOT_2_EXCLUDED_1={}", "-DWRITABLE_ROOT_2_EXCLUDED_1={}",
vulnerable_root_canonical.join(".agents").to_string_lossy()
));
expected_args.push(format!(
"-DWRITABLE_ROOT_2_EXCLUDED_2={}",
dot_codex_canonical.to_string_lossy() dot_codex_canonical.to_string_lossy()
)); ));
} }