feat(permissions): add glob deny-read policy support (#15979)

## Summary
- adds first-class filesystem policy entries for deny-read glob patterns
- parses config such as :project_roots { "**/*.env" = "none" } into
pattern entries
- enforces deny-read patterns in direct read/list helpers
- fails closed for sandbox execution until platform backends enforce
glob patterns in #18096
- preserves split filesystem policy in turn context only when it cannot
be reconstructed from legacy sandbox policy

## Stack
1. This PR - glob deny-read policy/config/direct-tool support
2. #18096 - macOS and Linux sandbox enforcement
3. #17740 - managed deny-read requirements

## Verification
- just fmt
- cargo check -p codex-core -p codex-sandboxing --tests

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
viyatb-oai
2026-04-16 10:31:51 -07:00
committed by GitHub
Unverified
parent ff9744fd66
commit 6862b9c745
23 changed files with 1081 additions and 79 deletions
+1
View File
@@ -2614,6 +2614,7 @@ dependencies = [
"codex-utils-string",
"codex-utils-template",
"encoding_rs",
"globset",
"http 1.4.0",
"icu_decimal",
"icu_locale_core",
+21 -1
View File
@@ -1083,6 +1083,18 @@ impl TurnContext {
}
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
self.sandbox_policy.get(),
&self.cwd,
);
// Omit the derived split filesystem policy when it is equivalent to
// the legacy sandbox policy. This keeps turn-context payloads stable
// while both fields exist; once callers consume only the split policy,
// this comparison and the legacy projection should go away.
let file_system_sandbox_policy = (self.file_system_sandbox_policy
!= legacy_file_system_sandbox_policy)
.then(|| self.file_system_sandbox_policy.clone());
TurnContextItem {
turn_id: Some(self.sub_id.clone()),
trace_id: self.trace_id.clone(),
@@ -1092,6 +1104,7 @@ impl TurnContext {
approval_policy: self.approval_policy.value(),
sandbox_policy: self.sandbox_policy.get().clone(),
network: self.turn_context_network_item(),
file_system_sandbox_policy,
model: self.model_info.slug.clone(),
personality: self.personality,
collaboration_mode: Some(self.collaboration_mode.clone()),
@@ -1287,7 +1300,14 @@ impl SessionConfiguration {
let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path();
next_configuration.cwd = absolute_cwd;
if sandbox_policy_changed || (cwd_changed && file_system_policy_matches_legacy) {
if sandbox_policy_changed {
next_configuration.file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
next_configuration.sandbox_policy.get(),
&next_configuration.cwd,
&self.file_system_sandbox_policy,
);
} else if cwd_changed && file_system_policy_matches_legacy {
// Preserve richer split policies across cwd-only updates; only
// rederive when the session is already using the legacy bridge.
next_configuration.file_system_sandbox_policy =
@@ -68,6 +68,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -107,6 +108,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -901,6 +903,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -976,6 +979,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -1005,6 +1009,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -1117,6 +1122,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: current_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -1227,6 +1233,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -1376,6 +1383,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
+92
View File
@@ -1639,6 +1639,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -4714,6 +4715,47 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is
);
}
fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSystemSandboxPolicy {
let mut policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
turn_context.sandbox_policy.get(),
&turn_context.cwd,
);
policy.entries.push(FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: format!("{}/**/*.env", turn_context.cwd.as_path().display()),
},
access: FileSystemAccessMode::None,
});
policy
}
#[tokio::test]
async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() {
let (_session, mut turn_context) = make_session_and_context().await;
turn_context.file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
turn_context.sandbox_policy.get(),
&turn_context.cwd,
);
let item = turn_context.to_turn_context_item();
assert_eq!(item.file_system_sandbox_policy, None);
}
#[tokio::test]
async fn turn_context_item_stores_split_file_system_sandbox_policy_when_different() {
let (_session, mut turn_context) = make_session_and_context().await;
let file_system_sandbox_policy = file_system_policy_with_unreadable_glob(&turn_context);
turn_context.file_system_sandbox_policy = file_system_sandbox_policy.clone();
let item = turn_context.to_turn_context_item();
assert_eq!(
item.file_system_sandbox_policy,
Some(file_system_sandbox_policy)
);
}
#[tokio::test]
async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing()
{
@@ -4852,6 +4894,56 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
);
}
#[tokio::test]
async fn record_context_updates_and_set_reference_context_item_persists_split_file_system_policy_to_rollout()
{
let (session, mut turn_context) = make_session_and_context().await;
let file_system_sandbox_policy = file_system_policy_with_unreadable_glob(&turn_context);
turn_context.file_system_sandbox_policy = file_system_sandbox_policy.clone();
let config = session.get_config().await;
let recorder = RolloutRecorder::new(
config.as_ref(),
RolloutRecorderParams::new(
ThreadId::default(),
/*forked_from_id*/ None,
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
),
/*state_db_ctx*/ None,
/*state_builder*/ None,
)
.await
.expect("create rollout recorder");
let rollout_path = recorder.rollout_path().to_path_buf();
{
let mut rollout = session.services.rollout.lock().await;
*rollout = Some(recorder);
}
session
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
session.ensure_rollout_materialized().await;
session.flush_rollout().await.expect("rollout should flush");
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
.await
.expect("read rollout history")
else {
panic!("expected resumed rollout history");
};
let persisted_file_system_sandbox_policy = resumed.history.iter().find_map(|item| match item {
RolloutItem::TurnContext(ctx) => ctx.file_system_sandbox_policy.clone(),
_ => None,
});
assert_eq!(
persisted_file_system_sandbox_policy,
Some(file_system_sandbox_policy)
);
}
#[tokio::test]
async fn build_initial_context_prepends_model_switch_message() {
let (session, turn_context) = make_session_and_context().await;
+68
View File
@@ -659,6 +659,74 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::
Ok(())
}
#[tokio::test]
async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
tokio::fs::write(cwd.path().join(".git"), "gitdir: nowhere").await?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
(".".to_string(), FileSystemAccessMode::Write),
("**/*.env".to_string(), FileSystemAccessMode::None),
])),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path())
.to_string_lossy()
.into_owned();
assert!(
config
.permissions
.file_system_sandbox_policy
.entries
.contains(&FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: expected_pattern,
},
access: FileSystemAccessMode::None,
})
);
assert!(
!config
.permissions
.file_system_sandbox_policy
.entries
.iter()
.any(|entry| matches!(
&entry.path,
FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { subpath: Some(subpath) },
} if subpath == std::path::Path::new("**/*.env")
)),
"glob should compile to a filesystem pattern entry, not a literal filesystem entry"
);
Ok(())
}
#[tokio::test]
async fn permissions_profiles_require_default_permissions() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+3 -1
View File
@@ -1622,6 +1622,7 @@ impl Config {
compile_permission_profile(
permissions,
default_permissions,
resolved_cwd.as_path(),
&mut startup_warnings,
)?;
let mut sandbox_policy = file_system_sandbox_policy
@@ -2006,9 +2007,10 @@ impl Config {
if effective_sandbox_policy == original_sandbox_policy {
file_system_sandbox_policy
} else {
FileSystemSandboxPolicy::from_legacy_sandbox_policy(
FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
&effective_sandbox_policy,
resolved_cwd.as_path(),
&file_system_sandbox_policy,
)
};
let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy
+168 -13
View File
@@ -5,12 +5,14 @@ use std::path::Path;
use std::path::PathBuf;
use codex_config::permissions_toml::FilesystemPermissionToml;
use codex_config::permissions_toml::FilesystemPermissionsToml;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_network_proxy::NetworkProxyConfig;
#[cfg(test)]
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
@@ -42,6 +44,7 @@ pub(crate) fn resolve_permission_profile<'a>(
pub(crate) fn compile_permission_profile(
permissions: &PermissionsToml,
profile_name: &str,
policy_cwd: &Path,
startup_warnings: &mut Vec<String>,
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
let profile = resolve_permission_profile(permissions, profile_name)?;
@@ -54,8 +57,23 @@ pub(crate) fn compile_permission_profile(
missing_filesystem_entries_warning(profile_name),
);
} else {
if cfg!(not(target_os = "macos")) {
for pattern in unsupported_read_write_glob_paths(filesystem) {
push_warning(
startup_warnings,
format!(
"Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `none` deny-read globs are supported."
),
);
}
}
for (path, permission) in &filesystem.entries {
compile_filesystem_permission(path, permission, &mut entries, startup_warnings)?;
entries.extend(compile_filesystem_permission(
path,
permission,
policy_cwd,
startup_warnings,
)?);
}
}
} else {
@@ -66,7 +84,6 @@ pub(crate) fn compile_permission_profile(
}
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
Ok((
FileSystemSandboxPolicy::restricted(entries),
network_sandbox_policy,
@@ -118,24 +135,71 @@ fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandb
fn compile_filesystem_permission(
path: &str,
permission: &FilesystemPermissionToml,
entries: &mut Vec<FileSystemSandboxEntry>,
policy_cwd: &Path,
startup_warnings: &mut Vec<String>,
) -> io::Result<()> {
) -> io::Result<Vec<FileSystemSandboxEntry>> {
let mut entries = Vec::new();
match permission {
FilesystemPermissionToml::Access(access) => entries.push(FileSystemSandboxEntry {
path: compile_filesystem_path(path, startup_warnings)?,
access: *access,
}),
FilesystemPermissionToml::Access(access) => {
entries.push(FileSystemSandboxEntry {
path: compile_filesystem_access_path(path, *access, startup_warnings)?,
access: *access,
});
}
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
entries.push(FileSystemSandboxEntry {
path: compile_scoped_filesystem_path(path, subpath, startup_warnings)?,
access: *access,
});
let has_glob = contains_glob_chars(subpath);
let can_compile_as_pattern = match parse_special_path(path) {
Some(FileSystemSpecialPath::ProjectRoots { .. }) | None => true,
Some(_) => false,
};
if has_glob && *access == FileSystemAccessMode::None && can_compile_as_pattern {
// Scoped glob syntax is a first-class filesystem policy
// pattern entry. Literal scoped paths continue through the
// exact-path parser so existing path semantics stay intact.
let entry = FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: compile_scoped_filesystem_pattern(
path, subpath, *access, policy_cwd,
)?,
},
access: *access,
};
entries.push(entry);
} else {
let subpath = compile_read_write_glob_path(subpath, *access)?;
entries.push(FileSystemSandboxEntry {
path: compile_scoped_filesystem_path(path, subpath, startup_warnings)?,
access: *access,
});
}
}
}
}
Ok(())
Ok(entries)
}
fn compile_filesystem_access_path(
path: &str,
access: FileSystemAccessMode,
startup_warnings: &mut Vec<String>,
) -> io::Result<FileSystemPath> {
if !contains_glob_chars(path) {
return compile_filesystem_path(path, startup_warnings);
}
if access == FileSystemAccessMode::None {
// At this point `path` is an unscoped filesystem table key. Top-level
// glob deny entries still go through the absolute-path parser before
// becoming policy patterns; relative project-root glob syntax is
// handled by `compile_scoped_filesystem_pattern`.
return Ok(FileSystemPath::GlobPattern {
pattern: parse_absolute_path(path)?.to_string_lossy().into_owned(),
});
}
let path = compile_read_write_glob_path(path, access)?;
compile_filesystem_path(path, startup_warnings)
}
fn compile_filesystem_path(
@@ -186,6 +250,97 @@ fn compile_scoped_filesystem_path(
Ok(FileSystemPath::Path { path })
}
fn compile_scoped_filesystem_pattern(
path: &str,
subpath: &str,
access: FileSystemAccessMode,
policy_cwd: &Path,
) -> io::Result<String> {
// Pattern entries currently mean deny-read only. Supporting broader access
// modes here would imply glob-based read/write allow semantics that the
// sandbox policy does not express yet.
if access != FileSystemAccessMode::None {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem glob subpath `{subpath}` only supports `none` access"),
));
}
let subpath = parse_relative_subpath(subpath)?;
match parse_special_path(path) {
Some(FileSystemSpecialPath::ProjectRoots { .. }) => {
// `:project_roots` is represented as a special path, but current
// filesystem-policy resolution defines it relative to the session
// cwd. Use the same policy cwd here so glob entries and exact
// scoped entries resolve consistently.
Ok(
AbsolutePathBuf::resolve_path_against_base(&subpath, policy_cwd)
.to_string_lossy()
.to_string(),
)
}
Some(_) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem path `{path}` does not support nested entries"),
)),
None => {
let base = parse_absolute_path(path)?;
Ok(base.join(&subpath).to_string_lossy().to_string())
}
}
}
fn compile_read_write_glob_path(path: &str, access: FileSystemAccessMode) -> io::Result<&str> {
if !contains_glob_chars(path) {
return Ok(path);
}
let path_without_trailing_glob = remove_trailing_glob_suffix(path);
if !contains_glob_chars(path_without_trailing_glob) {
return Ok(path_without_trailing_glob);
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem glob path `{path}` only supports `none` access; use an exact path or trailing `/**` for `{access}` subtree access"
),
))
}
fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) -> Vec<String> {
let mut patterns = Vec::new();
for (path, permission) in &filesystem.entries {
match permission {
FilesystemPermissionToml::Access(access) => {
if *access != FileSystemAccessMode::None
&& contains_glob_chars(remove_trailing_glob_suffix(path))
{
patterns.push(path.clone());
}
}
FilesystemPermissionToml::Scoped(scoped_entries) => {
for (subpath, access) in scoped_entries {
if *access != FileSystemAccessMode::None
&& contains_glob_chars(remove_trailing_glob_suffix(subpath))
{
patterns.push(format!("{path}/{subpath}"));
}
}
}
}
}
patterns
}
fn contains_glob_chars(path: &str) -> bool {
path.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
}
fn remove_trailing_glob_suffix(path: &str) -> &str {
path.strip_suffix("/**").unwrap_or(path)
}
// WARNING: keep this parser forward-compatible.
// Adding a new `:special_path` must not make older Codex versions reject the
// config. Unknown values intentionally round-trip through
@@ -2,6 +2,7 @@ use super::*;
use crate::config::Config;
use crate::config::ConfigOverrides;
use codex_config::config_toml::ConfigToml;
use codex_config::permissions_toml::FilesystemPermissionToml;
use codex_config::permissions_toml::FilesystemPermissionsToml;
use codex_config::permissions_toml::NetworkDomainPermissionToml;
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
@@ -10,6 +11,11 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -216,3 +222,89 @@ fn network_toml_overlays_unix_socket_permissions_by_path() {
})
);
}
#[test]
fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths() {
let filesystem = FilesystemPermissionsToml {
entries: BTreeMap::from([
(
"/tmp/**/*.log".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
),
(
"/tmp/cache/**".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
),
(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([
("**/*.env".to_string(), FileSystemAccessMode::None),
("docs/**".to_string(), FileSystemAccessMode::Read),
("src/**/*.rs".to_string(), FileSystemAccessMode::Write),
])),
),
]),
};
assert_eq!(
unsupported_read_write_glob_paths(&filesystem),
vec![
"/tmp/**/*.log".to_string(),
":project_roots/src/**/*.rs".to_string()
],
"`none` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn"
);
}
#[test]
fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> {
let cwd = TempDir::new()?;
let mut startup_warnings = Vec::new();
let (file_system_policy, _) = compile_permission_profile(
&PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":project_roots".to_string(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
"docs/**".to_string(),
FileSystemAccessMode::Read,
)])),
)]),
}),
network: None,
},
)]),
},
"workspace",
cwd.path(),
&mut startup_warnings,
)?;
assert_eq!(
file_system_policy,
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some("docs".into())),
},
access: FileSystemAccessMode::Read,
}]),
"trailing /** should compile as a subtree path instead of a glob pattern"
);
Ok(())
}
#[test]
fn read_write_glob_patterns_still_reject_non_subpath_globs() {
let err = compile_read_write_glob_path("src/**/*.rs", FileSystemAccessMode::Read)
.expect_err("non-subpath read/write glob should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("filesystem glob path `src/**/*.rs` only supports `none` access"),
"{err}"
);
}
@@ -133,6 +133,7 @@ fn reference_context_item() -> TurnContextItem {
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
network: None,
file_system_sandbox_policy: None,
model: "gpt-test".to_string(),
personality: None,
collaboration_mode: None,
+6
View File
@@ -981,6 +981,9 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
if !file_system_sandbox_policy
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
.is_empty()
|| !file_system_sandbox_policy
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
.is_empty()
{
return Err(
"windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
@@ -1096,6 +1099,9 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
if !file_system_sandbox_policy
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
.is_empty()
|| !file_system_sandbox_policy
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
.is_empty()
{
return Err(
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
+47
View File
@@ -806,6 +806,53 @@ fn windows_elevated_rejects_unreadable_split_carveouts() {
);
}
#[test]
fn windows_elevated_rejects_unreadable_globs() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: codex_protocol::permissions::FileSystemAccessMode::None,
},
]);
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
&temp_dir.path().abs(),
WindowsSandboxLevel::Elevated,
),
Some(
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
.to_string()
)
);
}
#[test]
fn windows_elevated_rejects_reopened_writable_descendants() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
+29 -5
View File
@@ -4,6 +4,7 @@ use std::fs::FileType;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::permissions::ReadDenyMatcher;
use codex_utils_string::take_bytes_at_char_boundary;
use serde::Deserialize;
use tokio::fs;
@@ -18,6 +19,8 @@ use crate::tools::registry::ToolKind;
pub struct ListDirHandler;
const DENY_READ_POLICY_MESSAGE: &str =
"access denied: reading this path is blocked by filesystem deny_read policy";
const MAX_ENTRY_LENGTH: usize = 500;
const INDENTATION_SPACES: usize = 2;
@@ -52,7 +55,7 @@ impl ToolHandler for ListDirHandler {
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation { payload, .. } = invocation;
let ToolInvocation { payload, turn, .. } = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
@@ -96,8 +99,20 @@ impl ToolHandler for ListDirHandler {
"dir_path must be an absolute path".to_string(),
));
}
let read_deny_matcher = ReadDenyMatcher::new(&turn.file_system_sandbox_policy, &turn.cwd);
if read_deny_matcher
.as_ref()
.is_some_and(|matcher| matcher.is_read_denied(&path))
{
return Err(FunctionCallError::RespondToModel(format!(
"{DENY_READ_POLICY_MESSAGE}: `{}`",
path.display()
)));
}
let entries = list_dir_slice(&path, offset, limit, depth).await?;
let entries =
list_dir_slice_with_policy(&path, offset, limit, depth, read_deny_matcher.as_ref())
.await?;
let mut output = Vec::with_capacity(entries.len() + 1);
output.push(format!("Absolute path: {}", path.display()));
output.extend(entries);
@@ -105,14 +120,15 @@ impl ToolHandler for ListDirHandler {
}
}
async fn list_dir_slice(
async fn list_dir_slice_with_policy(
path: &Path,
offset: usize,
limit: usize,
depth: usize,
read_deny_matcher: Option<&ReadDenyMatcher>,
) -> Result<Vec<String>, FunctionCallError> {
let mut entries = Vec::new();
collect_entries(path, Path::new(""), depth, &mut entries).await?;
collect_entries(path, Path::new(""), depth, read_deny_matcher, &mut entries).await?;
if entries.is_empty() {
return Ok(Vec::new());
@@ -148,6 +164,7 @@ async fn collect_entries(
dir_path: &Path,
relative_prefix: &Path,
depth: usize,
read_deny_matcher: Option<&ReadDenyMatcher>,
entries: &mut Vec<DirEntry>,
) -> Result<(), FunctionCallError> {
let mut queue = VecDeque::new();
@@ -163,6 +180,13 @@ async fn collect_entries(
while let Some(entry) = read_dir.next_entry().await.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to read directory: {err}"))
})? {
let entry_path = entry.path();
if let Some(read_deny_matcher) = read_deny_matcher
&& read_deny_matcher.is_read_denied(&entry_path)
{
continue;
}
let file_type = entry.file_type().await.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to inspect entry: {err}"))
})?;
@@ -179,7 +203,7 @@ async fn collect_entries(
let sort_key = format_entry_name(&relative_path);
let kind = DirEntryKind::from(&file_type);
dir_entries.push((
entry.path(),
entry_path,
relative_path,
kind,
DirEntry {
@@ -1,7 +1,21 @@
use super::*;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::ReadDenyMatcher;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
async fn list_dir_slice(
path: &Path,
offset: usize,
limit: usize,
depth: usize,
) -> Result<Vec<String>, FunctionCallError> {
list_dir_slice_with_policy(path, offset, limit, depth, /*read_deny_matcher*/ None).await
}
#[tokio::test]
async fn lists_directory_entries() {
let temp = tempdir().expect("create tempdir");
@@ -258,3 +272,60 @@ async fn truncation_respects_sorted_order() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test]
async fn hides_denied_entries_and_prunes_denied_subtrees() {
let temp = tempdir().expect("create tempdir");
let dir_path = temp.path();
let visible_dir = dir_path.join("visible");
let denied_dir = dir_path.join("private");
tokio::fs::create_dir(&visible_dir)
.await
.expect("create visible dir");
tokio::fs::create_dir(&denied_dir)
.await
.expect("create denied dir");
tokio::fs::write(visible_dir.join("ok.txt"), b"ok")
.await
.expect("write visible file");
tokio::fs::write(denied_dir.join("secret.txt"), b"secret")
.await
.expect("write denied file");
tokio::fs::write(dir_path.join("top_secret.txt"), b"secret")
.await
.expect("write denied top-level file");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_dir.try_into().expect("absolute denied dir"),
},
access: FileSystemAccessMode::None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: dir_path
.join("top_secret.txt")
.try_into()
.expect("absolute denied file"),
},
access: FileSystemAccessMode::None,
},
]);
let read_deny_matcher = ReadDenyMatcher::new(&policy, dir_path);
let entries = list_dir_slice_with_policy(
dir_path,
/*offset*/ 1,
/*limit*/ 20,
/*depth*/ 3,
read_deny_matcher.as_ref(),
)
.await
.expect("list directory");
assert_eq!(
entries,
vec!["visible/".to_string(), " ok.txt".to_string(),]
);
}
@@ -34,6 +34,7 @@ fn resume_history(
approval_policy: config.permissions.approval_policy.value(),
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
personality: None,
collaboration_mode: None,
+1
View File
@@ -23,6 +23,7 @@ codex-utils-image = { workspace = true }
codex-utils-string = { workspace = true }
codex-utils-template = { workspace = true }
encoding_rs = { workspace = true }
globset = { workspace = true }
icu_decimal = { workspace = true }
icu_locale_core = { workspace = true }
icu_provider = { workspace = true, features = ["sync"] }
+425 -59
View File
@@ -6,6 +6,8 @@ use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
use globset::GlobBuilder;
use globset::GlobMatcher;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -154,14 +156,101 @@ struct FileSystemSemanticSignature {
readable_roots: Vec<AbsolutePathBuf>,
writable_roots: Vec<WritableRoot>,
unreadable_roots: Vec<AbsolutePathBuf>,
unreadable_globs: Vec<String>,
}
/// Runtime matcher for read-deny entries in a filesystem sandbox policy.
pub struct ReadDenyMatcher {
denied_candidates: Vec<Vec<PathBuf>>,
deny_read_matchers: Vec<GlobMatcher>,
invalid_pattern: bool,
}
impl ReadDenyMatcher {
/// Builds a matcher from exact deny-read roots and deny-read glob entries.
///
/// Returns `None` when the policy has no deny-read restrictions, so callers
/// can skip read-deny checks without allocating matcher state. The `cwd`
/// resolves cwd-relative policy paths and special paths before matching.
pub fn new(file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path) -> Option<Self> {
if !file_system_sandbox_policy.has_denied_read_restrictions() {
return None;
}
// Exact roots are stored as all meaningful path spellings we can derive
// cheaply. This lets direct tool checks catch both a symlink path and
// its canonical target without changing the policy entries themselves.
let denied_candidates = file_system_sandbox_policy
.get_unreadable_roots_with_cwd(cwd)
.into_iter()
.map(|path| normalized_and_canonical_candidates(path.as_path()))
.collect();
// Pattern entries stay as policy-level globs. They are matched at read
// time here instead of being snapshotted to startup filesystem state.
let mut invalid_pattern = false;
let deny_read_matchers = file_system_sandbox_policy
.get_unreadable_globs_with_cwd(cwd)
.into_iter()
.filter_map(|pattern| match build_glob_matcher(&pattern) {
Some(matcher) => Some(matcher),
None => {
invalid_pattern = true;
None
}
})
.collect();
Some(Self {
denied_candidates,
deny_read_matchers,
invalid_pattern,
})
}
/// Returns whether `path` is denied by the policy used to build this matcher.
pub fn is_read_denied(&self, path: &Path) -> bool {
if self.invalid_pattern {
// Direct tool reads fail closed on malformed deny patterns. Silent
// allow would turn a config typo into a policy bypass.
return true;
}
// Check exact roots against each candidate spelling before evaluating
// glob matchers. Exact entries are subtree denies; glob entries match
// according to the pattern compiler's path-separator rules.
let path_candidates = normalized_and_canonical_candidates(path);
if self.denied_candidates.iter().any(|denied_candidates| {
path_candidates.iter().any(|candidate| {
denied_candidates.iter().any(|denied_candidate| {
candidate == denied_candidate || candidate.starts_with(denied_candidate)
})
})
}) {
return true;
}
self.deny_read_matchers.iter().any(|matcher| {
path_candidates
.iter()
.any(|candidate| matcher.is_match(candidate))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum FileSystemPath {
Path { path: AbsolutePathBuf },
Special { value: FileSystemSpecialPath },
Path {
path: AbsolutePathBuf,
},
/// A git-style glob pattern. Pattern entries currently support
/// FileSystemAccessMode::None only.
GlobPattern {
pattern: String,
},
Special {
value: FileSystemSpecialPath,
},
}
impl Default for FileSystemSandboxPolicy {
@@ -179,62 +268,6 @@ impl Default for FileSystemSandboxPolicy {
}
impl FileSystemSandboxPolicy {
fn has_root_access(&self, predicate: impl Fn(FileSystemAccessMode) -> bool) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Special { value }
if matches!(value, FileSystemSpecialPath::Root) && predicate(entry.access)
)
})
}
fn has_explicit_deny_entries(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::None)
}
/// Returns true when a restricted policy contains any entry that really
/// reduces a broader `:root = write` grant.
///
/// Raw entry presence is not enough here: an equally specific `write`
/// entry for the same target wins under the normal precedence rules, so a
/// shadowed `read` entry must not downgrade the policy out of full-disk
/// write mode.
fn has_write_narrowing_entries(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
if entry.access.can_write() {
return false;
}
match &entry.path {
FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry),
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None,
FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => {
false
}
_ => !self.has_same_target_write_override(entry),
},
}
})
}
/// Returns true when a higher-priority `write` entry targets the same
/// location as `entry`, so `entry` cannot narrow effective write access.
fn has_same_target_write_override(&self, entry: &FileSystemSandboxEntry) -> bool {
self.entries.iter().any(|candidate| {
candidate.access.can_write()
&& candidate.access > entry.access
&& file_system_paths_share_target(&candidate.path, &entry.path)
})
}
pub fn unrestricted() -> Self {
Self {
kind: FileSystemSandboxKind::Unrestricted,
@@ -256,6 +289,86 @@ impl FileSystemSandboxPolicy {
}
}
fn has_root_access(&self, predicate: impl Fn(FileSystemAccessMode) -> bool) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Special { value }
if matches!(value, FileSystemSpecialPath::Root) && predicate(entry.access)
)
})
}
pub fn has_denied_read_restrictions(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::None)
}
pub fn from_legacy_sandbox_policy_preserving_deny_entries(
sandbox_policy: &SandboxPolicy,
cwd: &Path,
existing: &Self,
) -> Self {
let mut rebuilt = Self::from_legacy_sandbox_policy(sandbox_policy, cwd);
if !matches!(rebuilt.kind, FileSystemSandboxKind::Restricted) {
return rebuilt;
}
for deny_entry in existing
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
{
if !rebuilt.entries.iter().any(|entry| entry == deny_entry) {
rebuilt.entries.push(deny_entry.clone());
}
}
rebuilt
}
/// Returns true when a restricted policy contains any entry that really
/// reduces a broader `:root = write` grant.
///
/// Raw entry presence is not enough here: an equally specific `write`
/// entry for the same target wins under the normal precedence rules, so a
/// shadowed `read` entry must not downgrade the policy out of full-disk
/// write mode.
fn has_write_narrowing_entries(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
if entry.access.can_write() {
return false;
}
match &entry.path {
FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry),
FileSystemPath::GlobPattern { .. } => true,
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None,
FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => {
false
}
_ => !self.has_same_target_write_override(entry),
},
}
})
}
/// Returns true when a higher-priority `write` entry targets the same
/// location as `entry`, so `entry` cannot narrow effective write access.
fn has_same_target_write_override(&self, entry: &FileSystemSandboxEntry) -> bool {
self.entries.iter().any(|candidate| {
candidate.access.can_write()
&& candidate.access > entry.access
&& file_system_paths_share_target(&candidate.path, &entry.path)
})
}
/// Converts a legacy sandbox policy into an equivalent filesystem policy
/// for the provided cwd.
///
@@ -276,6 +389,7 @@ impl FileSystemSandboxPolicy {
FileSystemPath::Path { path } => !legacy_writable_roots
.iter()
.any(|root| root.is_path_writable(path.as_path())),
FileSystemPath::GlobPattern { .. } => true,
FileSystemPath::Special { .. } => true,
}
});
@@ -312,7 +426,7 @@ impl FileSystemSandboxPolicy {
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
FileSystemSandboxKind::Restricted => {
self.has_root_access(FileSystemAccessMode::can_read)
&& !self.has_explicit_deny_entries()
&& !self.has_denied_read_restrictions()
}
}
}
@@ -585,6 +699,29 @@ impl FileSystemSandboxPolicy {
)
}
/// Returns unreadable glob patterns resolved against the provided cwd.
pub fn get_unreadable_globs_with_cwd(&self, cwd: &Path) -> Vec<String> {
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
return Vec::new();
}
let mut patterns = self
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
.filter_map(|entry| match &entry.path {
FileSystemPath::GlobPattern { pattern } => {
Some(AbsolutePathBuf::resolve_path_against_base(pattern, cwd))
}
FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => None,
})
.map(|pattern| pattern.to_string_lossy().into_owned())
.collect::<Vec<_>>();
patterns.sort();
patterns.dedup();
patterns
}
pub fn to_legacy_sandbox_policy(
&self,
network_policy: NetworkSandboxPolicy,
@@ -620,6 +757,7 @@ impl FileSystemSandboxPolicy {
for entry in &self.entries {
match &entry.path {
FileSystemPath::GlobPattern { .. } => {}
FileSystemPath::Path { path } => {
if entry.access.can_write() {
if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) {
@@ -770,6 +908,7 @@ impl FileSystemSandboxPolicy {
readable_roots: self.get_readable_roots_with_cwd(cwd),
writable_roots: self.get_writable_roots_with_cwd(cwd),
unreadable_roots: self.get_unreadable_roots_with_cwd(cwd),
unreadable_globs: self.get_unreadable_globs_with_cwd(cwd),
}
}
}
@@ -905,6 +1044,7 @@ fn resolve_file_system_path(
) -> Option<AbsolutePathBuf> {
match path {
FileSystemPath::Path { path } => Some(path.clone()),
FileSystemPath::GlobPattern { .. } => None,
FileSystemPath::Special { value } => resolve_file_system_special_path(value, cwd),
}
}
@@ -947,6 +1087,11 @@ fn file_system_paths_share_target(left: &FileSystemPath, right: &FileSystemPath)
| (FileSystemPath::Special { value }, FileSystemPath::Path { path }) => {
special_path_matches_absolute_path(value, path)
}
(
FileSystemPath::GlobPattern { pattern: left },
FileSystemPath::GlobPattern { pattern: right },
) => left == right,
(FileSystemPath::GlobPattern { .. }, _) | (_, FileSystemPath::GlobPattern { .. }) => false,
}
}
@@ -1021,6 +1166,44 @@ fn absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
.unwrap_or_else(|err| panic!("cwd root must be an absolute path: {err}"))
}
fn normalized_and_canonical_candidates(path: &Path) -> Vec<PathBuf> {
// Compare the lexical absolute form plus the canonical target when it
// exists. Missing paths still need the lexical candidate so future-created
// denied paths remain blocked by direct tool checks.
let mut candidates = Vec::new();
if let Ok(normalized) = AbsolutePathBuf::from_absolute_path(path) {
push_unique(&mut candidates, normalized.to_path_buf());
} else {
push_unique(&mut candidates, path.to_path_buf());
}
if let Ok(canonical) = path.canonicalize()
&& let Ok(canonical_absolute) = AbsolutePathBuf::from_absolute_path(canonical)
{
push_unique(&mut candidates, canonical_absolute.to_path_buf());
}
candidates
}
fn push_unique(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
if !candidates.iter().any(|existing| existing == &candidate) {
candidates.push(candidate);
}
}
fn build_glob_matcher(pattern: &str) -> Option<GlobMatcher> {
// Keep `*` and `?` within a single path component and preserve an unclosed
// `[` as a literal so matcher behavior stays aligned with config parsing.
GlobBuilder::new(pattern)
.literal_separator(true)
.allow_unclosed_class(true)
.build()
.ok()
.map(|glob| glob.compile_matcher())
}
fn resolve_file_system_special_path(
value: &FileSystemSpecialPath,
cwd: Option<&AbsolutePathBuf>,
@@ -2061,4 +2244,187 @@ mod tests {
assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write);
}
#[test]
fn legacy_bridge_preserves_explicit_deny_entries() {
let denied = AbsolutePathBuf::try_from("/tmp/private").expect("absolute path");
let existing = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied.clone(),
},
access: FileSystemAccessMode::None,
}]);
let rebuilt = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
&SandboxPolicy::new_workspace_write_policy(),
Path::new("/tmp/workspace"),
&existing,
);
assert!(
rebuilt.entries.iter().any(|entry| {
entry.path
== FileSystemPath::Path {
path: denied.clone(),
}
&& entry.access == FileSystemAccessMode::None
}),
"expected explicit deny entry to be preserved"
);
}
fn deny_policy(path: &Path) -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(path).expect("absolute deny path"),
},
access: FileSystemAccessMode::None,
}])
}
fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern { pattern },
access: FileSystemAccessMode::None,
}
}
fn default_policy_with_unreadable_glob(pattern: String) -> FileSystemSandboxPolicy {
let mut policy = FileSystemSandboxPolicy::default();
policy.entries.push(unreadable_glob_entry(pattern));
policy
}
fn is_read_denied(
path: &Path,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> bool {
ReadDenyMatcher::new(file_system_sandbox_policy, cwd)
.is_some_and(|matcher| matcher.is_read_denied(path))
}
#[test]
fn exact_path_and_descendants_are_denied() {
let temp = TempDir::new().expect("tempdir");
let denied_dir = temp.path().join("denied");
let nested = denied_dir.join("nested.txt");
std::fs::create_dir_all(&denied_dir).expect("create denied dir");
std::fs::write(&nested, "secret").expect("write secret");
let policy = deny_policy(&denied_dir);
assert!(is_read_denied(&denied_dir, &policy, temp.path()));
assert!(is_read_denied(&nested, &policy, temp.path()));
assert!(!is_read_denied(
&temp.path().join("other.txt"),
&policy,
temp.path()
));
}
#[cfg(unix)]
#[test]
fn canonical_target_matches_denied_symlink_alias() {
let temp = TempDir::new().expect("tempdir");
let real_dir = temp.path().join("real");
let alias_dir = temp.path().join("alias");
std::fs::create_dir_all(&real_dir).expect("create real dir");
symlink_dir(&real_dir, &alias_dir).expect("symlink alias");
let secret = real_dir.join("secret.txt");
std::fs::write(&secret, "secret").expect("write secret");
let alias_secret = alias_dir.join("secret.txt");
let policy = deny_policy(&real_dir);
assert!(is_read_denied(&alias_secret, &policy, temp.path()));
}
#[test]
fn literal_patterns_and_globs_are_denied() {
let temp = TempDir::new().expect("tempdir");
let literal = temp.path().join("private");
let other = temp.path().join("notes.txt");
std::fs::create_dir_all(&literal).expect("create literal dir");
std::fs::write(&other, "notes").expect("write notes");
let mut policy = deny_policy(&literal);
policy.entries.push(unreadable_glob_entry(format!(
"{}/**/*.txt",
temp.path().display()
)));
assert!(is_read_denied(&literal, &policy, temp.path()));
assert!(is_read_denied(&other, &policy, temp.path()));
}
#[test]
fn glob_patterns_deny_matching_paths() {
let temp = TempDir::new().expect("tempdir");
let denied = temp.path().join("private").join("secret1.txt");
std::fs::create_dir_all(denied.parent().expect("parent")).expect("create parent");
std::fs::write(&denied, "secret").expect("write secret");
let policy = default_policy_with_unreadable_glob(format!(
"{}/private/secret?.txt",
temp.path().display()
));
assert!(is_read_denied(&denied, &policy, temp.path()));
}
#[test]
fn glob_patterns_do_not_cross_path_separators() {
let temp = TempDir::new().expect("tempdir");
let matching = temp.path().join("app").join("file42.txt");
let nested = temp.path().join("app").join("nested").join("file42.txt");
let short = temp.path().join("app").join("file4.txt");
let letters = temp.path().join("app").join("fileab.txt");
std::fs::create_dir_all(nested.parent().expect("parent")).expect("create parent");
std::fs::write(&matching, "secret").expect("write matching");
std::fs::write(&nested, "secret").expect("write nested");
std::fs::write(&short, "secret").expect("write short");
std::fs::write(&letters, "secret").expect("write letters");
let policy = default_policy_with_unreadable_glob(format!(
"{}/*/file[0-9]?.txt",
temp.path().display()
));
assert!(is_read_denied(&matching, &policy, temp.path()));
assert!(!is_read_denied(&nested, &policy, temp.path()));
assert!(!is_read_denied(&short, &policy, temp.path()));
assert!(!is_read_denied(&letters, &policy, temp.path()));
}
#[test]
fn globstar_patterns_deny_root_and_nested_matches() {
let temp = TempDir::new().expect("tempdir");
let root_env = temp.path().join(".env");
let nested_env = temp.path().join("app").join(".env");
let other = temp.path().join("app").join("notes.txt");
std::fs::create_dir_all(nested_env.parent().expect("parent")).expect("create parent");
std::fs::write(&root_env, "secret").expect("write root env");
std::fs::write(&nested_env, "secret").expect("write nested env");
std::fs::write(&other, "notes").expect("write notes");
let policy =
default_policy_with_unreadable_glob(format!("{}/**/*.env", temp.path().display()));
assert!(is_read_denied(&root_env, &policy, temp.path()));
assert!(is_read_denied(&nested_env, &policy, temp.path()));
assert!(!is_read_denied(&other, &policy, temp.path()));
}
#[test]
fn unclosed_character_classes_match_literal_brackets() {
let temp = TempDir::new().expect("tempdir");
let bracket_file = temp.path().join("[");
let other = temp.path().join("notes.txt");
std::fs::write(&bracket_file, "secret").expect("write bracket file");
std::fs::write(&other, "notes").expect("write notes");
let policy = default_policy_with_unreadable_glob(format!("{}/[", temp.path().display()));
assert!(is_read_denied(&bracket_file, &policy, temp.path()));
assert!(!is_read_denied(&other, &policy, temp.path()));
}
}
+24
View File
@@ -2844,6 +2844,8 @@ pub struct TurnContextItem {
pub sandbox_policy: SandboxPolicy,
#[serde(skip_serializing_if = "Option::is_none")]
pub network: Option<TurnContextNetworkItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_system_sandbox_policy: Option<FileSystemSandboxPolicy>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
@@ -4974,6 +4976,7 @@ mod tests {
assert_eq!(item.trace_id, None);
assert_eq!(item.network, None);
assert_eq!(item.file_system_sandbox_policy, None);
Ok(())
}
@@ -4991,6 +4994,14 @@ mod tests {
allowed_domains: vec!["api.example.com".to_string()],
denied_domains: vec!["blocked.example.com".to_string()],
}),
file_system_sandbox_policy: Some(FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "/tmp/private/**/*.txt".to_string(),
},
access: FileSystemAccessMode::None,
},
])),
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
@@ -5011,6 +5022,19 @@ mod tests {
"denied_domains": ["blocked.example.com"],
})
);
assert_eq!(
value["file_system_sandbox_policy"],
json!({
"kind": "restricted",
"entries": [{
"path": {
"type": "glob_pattern",
"pattern": "/tmp/private/**/*.txt"
},
"access": "none"
}]
})
);
Ok(())
}
+1
View File
@@ -544,6 +544,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
network: None,
file_system_sandbox_policy: None,
model: "test-model".to_string(),
personality: None,
collaboration_mode: None,
+3
View File
@@ -34,6 +34,9 @@ impl From<SandboxTransformError> for CodexErr {
SandboxTransformError::MissingLinuxSandboxExecutable => {
CodexErr::LandlockSandboxExecutableNotProvided
}
SandboxTransformError::UnreadableGlobPatternsUnsupported => {
CodexErr::UnsupportedOperation(err.to_string())
}
#[cfg(target_os = "linux")]
SandboxTransformError::Wsl1UnsupportedForBubblewrap => {
CodexErr::UnsupportedOperation(crate::bwrap::WSL1_BWRAP_WARNING.to_string())
+14
View File
@@ -109,6 +109,7 @@ pub struct SandboxTransformRequest<'a> {
#[derive(Debug)]
pub enum SandboxTransformError {
MissingLinuxSandboxExecutable,
UnreadableGlobPatternsUnsupported,
#[cfg(target_os = "linux")]
Wsl1UnsupportedForBubblewrap,
#[cfg(not(target_os = "macos"))]
@@ -121,6 +122,10 @@ impl std::fmt::Display for SandboxTransformError {
Self::MissingLinuxSandboxExecutable => {
write!(f, "missing codex-linux-sandbox executable path")
}
Self::UnreadableGlobPatternsUnsupported => write!(
f,
"platform sandbox backend cannot enforce unreadable glob patterns"
),
#[cfg(target_os = "linux")]
Self::Wsl1UnsupportedForBubblewrap => write!(f, "{WSL1_BWRAP_WARNING}"),
#[cfg(not(target_os = "macos"))]
@@ -196,6 +201,15 @@ impl SandboxManager {
);
let effective_network_policy =
effective_network_sandbox_policy(network_policy, additional_permissions.as_ref());
if matches!(
sandbox,
SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp
) && !effective_file_system_policy
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
.is_empty()
{
return Err(SandboxTransformError::UnreadableGlobPatternsUnsupported);
}
let mut argv = Vec::with_capacity(1 + command.args.len());
argv.push(command.program);
argv.extend(command.args.into_iter().map(OsString::from));
+3
View File
@@ -302,6 +302,7 @@ mod tests {
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
network: None,
file_system_sandbox_policy: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
@@ -340,6 +341,7 @@ mod tests {
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
network: None,
file_system_sandbox_policy: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
@@ -372,6 +374,7 @@ mod tests {
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
network: None,
file_system_sandbox_policy: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
+1
View File
@@ -9239,6 +9239,7 @@ guardian_approval = true
approval_policy: primary_session.approval_policy,
sandbox_policy: primary_session.sandbox_policy.clone(),
network: None,
file_system_sandbox_policy: None,
model: "gpt-agent".to_string(),
personality: None,
collaboration_mode: None,
+1
View File
@@ -2249,6 +2249,7 @@ mod tests {
approval_policy: config.permissions.approval_policy.value(),
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
network: None,
file_system_sandbox_policy: None,
model,
personality: None,
collaboration_mode: None,