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
Unverified
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::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path::normalize_for_path_comparison;
use schemars::JsonSchema;
@@ -641,15 +641,19 @@ pub struct GhostSnapshotToml {
}
impl ConfigToml {
/// Derive the effective sandbox policy from the configuration.
pub async fn derive_sandbox_policy(
/// Derive the effective permission profile from legacy sandbox config.
///
/// 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,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
windows_sandbox_level: WindowsSandboxLevel,
active_project: Option<&ProjectConfig>,
permission_profile_constraint: Option<&crate::Constrained<PermissionProfile>>,
) -> SandboxPolicy {
) -> PermissionProfile {
let sandbox_mode_was_explicit = sandbox_mode_override.is_some()
|| profile_sandbox_mode.is_some()
|| self.sandbox_mode.is_some();
@@ -677,50 +681,53 @@ impl ConfigToml {
})
})
.unwrap_or_default();
let mut sandbox_policy = match resolved_sandbox_mode {
SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(),
let effective_sandbox_mode = if cfg!(target_os = "windows")
// 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() {
Some(SandboxWorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
}) => SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.clone(),
network_access: *network_access,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
},
None => SandboxPolicy::new_workspace_write_policy(),
}) => {
let network_policy = if *network_access {
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
};
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
&& let Some(constraint) = permission_profile_constraint
&& let Err(err) = constraint.can_set(&PermissionProfile::from_legacy_sandbox_policy(
&sandbox_policy,
))
&& let Err(err) = constraint.can_set(&permission_profile)
{
tracing::warn!(
error = %err,
"default sandbox policy is disallowed by requirements; falling back to required default"
);
sandbox_policy = SandboxPolicy::new_read_only_policy();
downgrade_workspace_write_if_unsupported(&mut sandbox_policy);
PermissionProfile::read_only()
} else {
permission_profile
}
sandbox_policy
}
/// 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]
async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> {
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)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
let resolution = sandbox_full_access_cfg
.derive_sandbox_policy(
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
)
.await;
let resolution = derive_legacy_sandbox_policy_for_test(
&sandbox_full_access_cfg,
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
)
.await;
assert_eq!(resolution, SandboxPolicy::DangerFullAccess);
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)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
let resolution = sandbox_read_only_cfg
.derive_sandbox_policy(
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
)
.await;
let resolution = derive_legacy_sandbox_policy_for_test(
&sandbox_read_only_cfg,
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
)
.await;
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
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)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
let resolution = sandbox_workspace_write_cfg
.derive_sandbox_policy(
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
)
.await;
let resolution = derive_legacy_sandbox_policy_for_test(
&sandbox_workspace_write_cfg,
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
)
.await;
if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
} else {
@@ -1723,15 +1751,15 @@ exclude_slash_tmp = true
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(&sandbox_workspace_write)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
let resolution = sandbox_workspace_write_cfg
.derive_sandbox_policy(
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
)
.await;
let resolution = derive_legacy_sandbox_policy_for_test(
&sandbox_workspace_write_cfg,
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
)
.await;
if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
} else {
@@ -1748,7 +1776,7 @@ exclude_slash_tmp = true
}
#[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 cwd = TempDir::new()?;
let extra_root = test_absolute_path("/tmp/legacy-extra-root");
@@ -1793,26 +1821,91 @@ exclude_slash_tmp = true
)
.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!(
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd.path()),
"case `{name}` should preserve filesystem semantics from legacy config"
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::from(sandbox_policy),
network_policy,
NetworkSandboxPolicy::from(&sandbox_policy),
"case `{name}` should preserve network semantics from legacy config"
);
assert_eq!(
config
.permissions
.file_system_sandbox_policy()
.to_legacy_sandbox_policy(config.permissions.network_sandbox_policy(), cwd.path())
file_system_policy
.to_legacy_sandbox_policy(network_policy, cwd.path())
.unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")),
sandbox_policy.clone(),
"case `{name}` should round-trip through split policies without drift"
sandbox_policy,
"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(())
@@ -6310,15 +6403,15 @@ trust_level = "untrusted"
trust_level: Some(TrustLevel::Untrusted),
};
let resolution = cfg
.derive_sandbox_policy(
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
/*permission_profile_constraint*/ None,
)
.await;
let resolution = derive_legacy_sandbox_policy_for_test(
&cfg,
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
/*permission_profile_constraint*/ None,
)
.await;
// Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade)
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
.derive_sandbox_policy(
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
Some(&constrained),
)
.await;
let resolution = derive_legacy_sandbox_policy_for_test(
&cfg,
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
Some(&constrained),
)
.await;
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
Ok(())
@@ -6423,15 +6516,15 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb
},
)?;
let resolution = cfg
.derive_sandbox_policy(
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
Some(&constrained),
)
.await;
let resolution = derive_legacy_sandbox_policy_for_test(
&cfg,
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
Some(&constrained),
)
.await;
if cfg!(target_os = "windows") {
assert_eq!(resolution, SandboxPolicy::new_read_only_policy());
+46 -19
View File
@@ -1976,8 +1976,13 @@ impl Config {
)
} else {
let configured_network_proxy_config = NetworkProxyConfig::default();
let mut sandbox_policy = cfg
.derive_sandbox_policy(
// No named `[permissions]` profile is active, but permissions
// 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,
config_profile.sandbox_mode,
windows_sandbox_level,
@@ -1985,24 +1990,46 @@ impl Config {
Some(&constrained_permission_profile),
)
.await;
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());
}
}
// The legacy-derived profiles above are expected to be
// representable as `SandboxPolicy`. This guard keeps the old safe
// fallback behavior if future changes make this branch derive a
// 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,
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 {
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 {
+134 -1
View File
@@ -24,7 +24,10 @@ use std::process::Command;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::WritableRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
use globset::GlobBuilder;
@@ -258,6 +261,35 @@ fn create_filesystem_args(
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
.get_unreadable_roots_with_cwd(cwd)
.into_iter()
@@ -410,6 +442,7 @@ fn create_filesystem_args(
.iter()
.map(|path| path.as_path().to_path_buf())
.filter(|path| !unreadable_paths.contains(path))
.filter(|path| !missing_auto_metadata_read_only_project_root_subpaths.contains(path))
.collect();
if let Some(target) = &symlink_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]
fn mounts_dev_before_writable_dev_binds() {
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
@@ -1427,7 +1560,7 @@ mod tests {
"/".to_string(),
// Mask the default protected .codex subpath under that writable
// root. Because the root is `/` in this test, the carveout path
// appears as `/.codex`.
// appears at the filesystem root.
"--ro-bind".to_string(),
"/dev/null".to_string(),
"/.codex".to_string(),
+35 -49
View File
@@ -408,55 +408,33 @@ impl PermissionProfile {
/// The returned profile contains symbolic `:project_roots` entries that
/// must be resolved against the active permission root before enforcement.
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 {
file_system: ManagedFileSystemPermissions::Restricted {
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,
},
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,
file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
network,
}
}
@@ -503,7 +481,15 @@ impl PermissionProfile {
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
Self::from_runtime_permissions_with_enforcement(
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),
)
}
+262 -144
View File
@@ -412,57 +412,65 @@ impl FileSystemSandboxPolicy {
})
}
/// Converts a legacy sandbox policy into a cwd-independent filesystem policy.
///
/// `WorkspaceWrite` uses symbolic project-root entries so callers can keep
/// the profile independent of the concrete root until it is resolved for a
/// turn or command.
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
let mut file_system_policy = Self::from(sandbox_policy);
let SandboxPolicy::WorkspaceWrite {
writable_roots,
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
} = sandbox_policy
else {
return file_system_policy;
};
/// Filesystem policy matching `WorkspaceWrite` semantics without requiring
/// callers to construct a legacy [`SandboxPolicy`] first.
pub fn workspace_write(
writable_roots: &[AbsolutePathBuf],
exclude_tmpdir_env_var: bool,
exclude_slash_tmp: bool,
) -> Self {
let mut entries = vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}];
prune_read_entries_under_writable_roots(
&mut file_system_policy.entries,
&legacy_non_cwd_writable_roots(
writable_roots,
*exclude_tmpdir_env_var,
*exclude_slash_tmp,
),
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,
}),
);
append_default_read_only_project_root_subpath_if_no_explicit_rule(
&mut file_system_policy.entries,
".git",
);
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",
);
append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".git");
append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".agents");
append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".codex");
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 file_system_policy.entries,
protected_path,
);
append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
}
}
file_system_policy
FileSystemSandboxPolicy::restricted(entries)
}
/// 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 {
let mut file_system_policy = Self::from(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) {
for protected_path in default_read_only_subpaths_for_writable_root(
&cwd_root, /*protect_missing_dot_codex*/ true,
@@ -635,6 +637,44 @@ impl FileSystemSandboxPolicy {
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(
&self,
network_policy: NetworkSandboxPolicy,
@@ -649,7 +689,7 @@ impl FileSystemSandboxPolicy {
};
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)
}
@@ -1008,47 +1048,11 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
} => {
let mut entries = vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
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)
}
} => FileSystemSandboxPolicy::workspace_write(
writable_roots,
*exclude_tmpdir_env_var,
*exclude_slash_tmp,
),
}
}
}
@@ -1337,6 +1341,87 @@ fn default_read_only_subpaths_for_writable_root(
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(
entries: &mut Vec<FileSystemSandboxEntry>,
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(
entries: &[ResolvedFileSystemEntry],
path: &AbsolutePathBuf,
@@ -1576,7 +1609,7 @@ mod tests {
};
assert_eq!(
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy),
FileSystemSandboxPolicy::from(&policy),
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -1729,6 +1762,24 @@ mod tests {
},
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 {
path: FileSystemPath::Path {
path: expected_dot_codex,
@@ -2177,7 +2228,7 @@ mod tests {
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(),
cwd.path(),
);
@@ -2196,8 +2247,7 @@ mod tests {
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let legacy_order =
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&legacy_policy, cwd.path());
let legacy_order = legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path());
let mut reordered_entries = legacy_order.entries.clone();
reordered_entries.reverse();
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]
fn root_write_with_read_only_child_is_not_full_disk_write() {
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]
fn file_system_access_mode_orders_by_conflict_precedence() {
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!(
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!(
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")
.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!(
"-DWRITABLE_ROOT_1={}",
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());
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 {
""
};
@@ -1203,13 +1222,13 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
// Note that the policy includes:
// - the base policy,
// - 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!(
r#"{MACOS_SEATBELT_BASE_POLICY}
; allow read-only file operations
(allow file-read*)
(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={}",
dot_codex_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_0_EXCLUDED_2={}",
vulnerable_root_canonical.join(".agents").to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1={}",
PathBuf::from("/tmp")
@@ -1247,6 +1270,10 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
));
expected_args.push(format!(
"-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()
));
}