mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
ff9744fd66
commit
6862b9c745
Generated
+1
@@ -2614,6 +2614,7 @@ dependencies = [
|
||||
"codex-utils-string",
|
||||
"codex-utils-template",
|
||||
"encoding_rs",
|
||||
"globset",
|
||||
"http 1.4.0",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user