Make permission transforms aware of executor path context (#41909)

## What changed

- Add context-aware normalization, materialization, and intersection helpers for additional filesystem permissions.
- Resolve project roots, home-relative deny globs, temporary directories, and filesystem roots from the executor's `FileSystemSandboxPolicyContext` while preserving URI path conventions and symlinked paths.
- Keep deny constraints when intersecting grants across POSIX, Windows, and UNC paths, and reject incompatible path conventions when executor context is available.

## Testing

- Cover cross-convention validation, empty temporary-directory contexts, home-relative deny globs, and rooted deny globs for POSIX, Windows, and UNC paths.

GitOrigin-RevId: 14fd866a17a0ac4a595aa4f0e2fb82bfae1a8838
This commit is contained in:
iceweasel-oai authored and copyberry committed 2026-08-31 20:50:44 +00:00
1 parent 2f0a5d5516
commit b51b07785b
6 files changed
+534 -246

No files matched your search

@@ -303,8 +303,7 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() {
); );
let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd_abs); let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd_abs);
let expected_outside = let expected_outside = outside.abs();
dunce::simplified(&outside.canonicalize().expect("canonicalize outside dir")).abs();
assert_eq!( assert_eq!(
permissions permissions
@@ -287,16 +287,6 @@ fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfil
} }
} }
fn normalized_directory_write_permissions(path: &Path) -> Result<RequestPermissionProfile> {
Ok(RequestPermissionProfile {
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![]),
Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]),
)),
..RequestPermissionProfile::default()
})
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn with_additional_permissions_requires_approval_under_on_request() -> Result<()> { async fn with_additional_permissions_requires_approval_under_on_request() -> Result<()> {
skip_if_no_network!(Ok(())); skip_if_no_network!(Ok(()));
@@ -508,7 +498,7 @@ async fn request_permissions_auto_review_applies_guardian_decision(outcome: &str
let requested_dir = test.workspace_path("guardian-requested-permissions"); let requested_dir = test.workspace_path("guardian-requested-permissions");
fs::create_dir_all(&requested_dir)?; fs::create_dir_all(&requested_dir)?;
let requested_permissions = requested_directory_write_permissions(&requested_dir); let requested_permissions = requested_directory_write_permissions(&requested_dir);
let normalized_permissions = normalized_directory_write_permissions(&requested_dir)?; let normalized_permissions = requested_directory_write_permissions(&requested_dir);
let call_id = "guardian-request-permissions"; let call_id = "guardian-request-permissions";
let reason = "Guardian should review access to the requested directory"; let reason = "Guardian should review access to the requested directory";
let responses = mount_sse_sequence( let responses = mount_sse_sequence(
@@ -576,9 +566,7 @@ async fn request_permissions_auto_review_applies_guardian_decision(outcome: &str
}) })
.context("expected Guardian review request")?; .context("expected Guardian review request")?;
assert!(guardian_request.body_contains_text(reason)); assert!(guardian_request.body_contains_text(reason));
assert!( assert!(guardian_request.body_contains_text(&requested_dir.display().to_string()));
guardian_request.body_contains_text(&requested_dir.canonicalize()?.display().to_string())
);
let output = requests let output = requests
.iter() .iter()
@@ -765,7 +753,6 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul
let nested_dir = test.workspace_path("nested"); let nested_dir = test.workspace_path("nested");
fs::create_dir_all(&nested_dir)?; fs::create_dir_all(&nested_dir)?;
let nested_dir_canonical = nested_dir.canonicalize()?;
let requested_write = nested_dir.join("relative-write.txt"); let requested_write = nested_dir.join("relative-write.txt");
let _ = fs::remove_file(&requested_write); let _ = fs::remove_file(&requested_write);
@@ -780,7 +767,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul
let expected_permissions = PermissionProfile { let expected_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions::from_read_write_roots( file_system: Some(FileSystemPermissions::from_read_write_roots(
/*read*/ None, /*read*/ None,
Some(vec![absolute_path(&nested_dir_canonical)]), Some(vec![absolute_path(&nested_dir)]),
)), )),
..Default::default() ..Default::default()
}; };
@@ -1096,15 +1083,7 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() ->
)), )),
..RequestPermissionProfile::default() ..RequestPermissionProfile::default()
}; };
let normalized_requested_permissions = RequestPermissionProfile { let normalized_requested_permissions = requested_permissions.clone();
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![]),
Some(vec![AbsolutePathBuf::try_from(
outside_dir.path().canonicalize()?,
)?]),
)),
..RequestPermissionProfile::default()
};
let event = let event =
exec_command_event_with_request_permissions(call_id, &command, &requested_permissions)?; exec_command_event_with_request_permissions(call_id, &command, &requested_permissions)?;
@@ -1202,15 +1181,7 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul
)), )),
..Default::default() ..Default::default()
}; };
let normalized_requested_permissions = PermissionProfile { let normalized_requested_permissions = requested_permissions.clone();
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![]),
Some(vec![AbsolutePathBuf::try_from(
outside_dir.path().canonicalize()?,
)?]),
)),
..Default::default()
};
let event = let event =
exec_command_event_with_request_permissions(call_id, &command, &requested_permissions)?; exec_command_event_with_request_permissions(call_id, &command, &requested_permissions)?;
@@ -1308,15 +1279,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul
)), )),
..Default::default() ..Default::default()
}; };
let normalized_requested_permissions = RequestPermissionProfile { let normalized_requested_permissions = requested_permissions.clone();
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![]),
Some(vec![AbsolutePathBuf::try_from(
outside_dir.path().canonicalize()?,
)?]),
)),
..Default::default()
};
let responses = mount_sse_sequence( let responses = mount_sse_sequence(
&server, &server,
vec![ vec![
@@ -1430,7 +1393,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req
); );
let requested_permissions = requested_directory_write_permissions(outside_dir.path()); let requested_permissions = requested_directory_write_permissions(outside_dir.path());
let normalized_requested_permissions = let normalized_requested_permissions =
normalized_directory_write_permissions(outside_dir.path())?; requested_directory_write_permissions(outside_dir.path());
let responses = mount_sse_sequence( let responses = mount_sse_sequence(
&server, &server,
vec![ vec![
@@ -1550,7 +1513,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls_without_in
); );
let requested_permissions = requested_directory_write_permissions(outside_dir.path()); let requested_permissions = requested_directory_write_permissions(outside_dir.path());
let normalized_requested_permissions = let normalized_requested_permissions =
normalized_directory_write_permissions(outside_dir.path())?; requested_directory_write_permissions(outside_dir.path());
let responses = mount_sse_sequence( let responses = mount_sse_sequence(
&server, &server,
vec![ vec![
@@ -1677,28 +1640,10 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions()
)), )),
..RequestPermissionProfile::default() ..RequestPermissionProfile::default()
}; };
let normalized_requested_permissions = RequestPermissionProfile { let normalized_requested_permissions = requested_permissions.clone();
file_system: Some(FileSystemPermissions::from_read_write_roots( let granted_permissions = requested_directory_write_permissions(first_dir.path());
Some(vec![]),
Some(vec![
AbsolutePathBuf::try_from(first_dir.path().canonicalize()?)?,
AbsolutePathBuf::try_from(second_dir.path().canonicalize()?)?,
]),
)),
..RequestPermissionProfile::default()
};
let granted_permissions = normalized_directory_write_permissions(first_dir.path())?;
let second_dir_permissions = requested_directory_write_permissions(second_dir.path()); let second_dir_permissions = requested_directory_write_permissions(second_dir.path());
let merged_permissions = PermissionProfile { let merged_permissions = requested_permissions.clone();
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![]),
Some(vec![
AbsolutePathBuf::try_from(first_dir.path().canonicalize()?)?,
AbsolutePathBuf::try_from(second_dir.path().canonicalize()?)?,
]),
)),
..Default::default()
};
let responses = mount_sse_sequence( let responses = mount_sse_sequence(
&server, &server,
@@ -1835,7 +1780,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> {
let outside_dir = tempfile::tempdir()?; let outside_dir = tempfile::tempdir()?;
let requested_permissions = requested_directory_write_permissions(outside_dir.path()); let requested_permissions = requested_directory_write_permissions(outside_dir.path());
let normalized_requested_permissions = let normalized_requested_permissions =
normalized_directory_write_permissions(outside_dir.path())?; requested_directory_write_permissions(outside_dir.path());
let _first_turn = mount_sse_sequence( let _first_turn = mount_sse_sequence(
&server, &server,
@@ -1952,7 +1897,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> {
let outside_write = outside_dir.path().join("session-sticky-write.txt"); let outside_write = outside_dir.path().join("session-sticky-write.txt");
let requested_permissions = requested_directory_write_permissions(outside_dir.path()); let requested_permissions = requested_directory_write_permissions(outside_dir.path());
let normalized_requested_permissions = let normalized_requested_permissions =
normalized_directory_write_permissions(outside_dir.path())?; requested_directory_write_permissions(outside_dir.path());
let command = format!( let command = format!(
"printf {:?} > {:?} && cat {:?}", "printf {:?} > {:?} && cat {:?}",
"session-sticky-ok", outside_write, outside_write "session-sticky-ok", outside_write, outside_write
@@ -98,16 +98,6 @@ fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfil
} }
} }
fn normalized_directory_write_permissions(path: &Path) -> Result<RequestPermissionProfile> {
Ok(RequestPermissionProfile {
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![]),
Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]),
)),
..RequestPermissionProfile::default()
})
}
fn parse_result(item: &Value) -> (Option<i64>, String) { fn parse_result(item: &Value) -> (Option<i64>, String) {
let output_str = item let output_str = item
.get("output") .get("output")
@@ -241,7 +231,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s
); );
let requested_permissions = requested_directory_write_permissions(requested_dir.path()); let requested_permissions = requested_directory_write_permissions(requested_dir.path());
let normalized_requested_permissions = let normalized_requested_permissions =
normalized_directory_write_permissions(requested_dir.path())?; requested_directory_write_permissions(requested_dir.path());
let responses = mount_sse_sequence( let responses = mount_sse_sequence(
&server, &server,
@@ -378,13 +368,10 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu
} else { } else {
"patched-via-request-permissions" "patched-via-request-permissions"
}; };
let requested_file = requested_dir let requested_file = requested_dir.path().join(requested_file_name);
.path()
.canonicalize()?
.join(requested_file_name);
let requested_permissions = requested_directory_write_permissions(requested_dir.path()); let requested_permissions = requested_directory_write_permissions(requested_dir.path());
let normalized_requested_permissions = let normalized_requested_permissions =
normalized_directory_write_permissions(requested_dir.path())?; requested_directory_write_permissions(requested_dir.path());
let patch = build_add_file_patch(&requested_file, patch_content); let patch = build_add_file_patch(&requested_file, patch_content);
let response_prefix = if strict_auto_review { let response_prefix = if strict_auto_review {
+80 -39
View File
@@ -288,15 +288,43 @@ struct FileSystemSemanticSignature {
/// Runtime matcher for read-deny entries in a filesystem sandbox policy. /// Runtime matcher for read-deny entries in a filesystem sandbox policy.
pub struct ReadDenyMatcher { pub struct ReadDenyMatcher {
cwd: AbsolutePathBuf, native_cwd: Option<AbsolutePathBuf>,
user_home_dir: Option<PathUri>, user_home_dir: Option<PathUri>,
temporary_directories: Vec<PathUri>, temporary_directories: Vec<PathUri>,
prepared: PreparedReadDenyMatcher,
}
/// Prepared PathUri deny roots and globs for repeated executor-owned read checks.
struct PreparedReadDenyMatcher {
denied_roots: Vec<PathUri>, denied_roots: Vec<PathUri>,
deny_read_matchers: Vec<GlobMatcher>, deny_read_matchers: Vec<GlobMatcher>,
invalid_pattern: bool, invalid_pattern: bool,
} }
impl ReadDenyMatcher { impl ReadDenyMatcher {
/// Builds a matcher for executor-owned URI paths without host projection.
pub fn from_context(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Option<Self> {
file_system_sandbox_policy
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::Deny)
.then(|| {
file_system_sandbox_policy
.prepare_deny_read_matcher(context, InvalidDenyReadGlobBehavior::FailClosed)
.map(|prepared| Self {
native_cwd: None,
user_home_dir: None,
temporary_directories: Vec::new(),
prepared,
})
.ok()
})
.flatten()
}
/// Builds a matcher from exact deny-read roots and deny-read glob entries. /// 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 /// Returns `None` when the policy has no deny-read restrictions, so callers
@@ -348,38 +376,42 @@ impl ReadDenyMatcher {
user_home_dir: user_home_dir.as_ref(), user_home_dir: user_home_dir.as_ref(),
temporary_directories: Some(&temporary_directories), temporary_directories: Some(&temporary_directories),
}; };
let (denied_roots, deny_read_matchers, invalid_pattern) = file_system_sandbox_policy let prepared = file_system_sandbox_policy
.prepare_deny_read_matcher(&context, invalid_glob_behavior)?; .prepare_deny_read_matcher(&context, invalid_glob_behavior)?;
Ok(Some(Self { Ok(Some(Self {
cwd, native_cwd: Some(cwd),
user_home_dir, user_home_dir,
temporary_directories, temporary_directories,
denied_roots, prepared,
deny_read_matchers,
invalid_pattern,
})) }))
} }
/// Returns whether `path` is denied by the policy used to build this matcher. /// Returns whether `path` is denied by the policy used to build this matcher.
pub fn is_read_denied(&self, path: &Path) -> bool { pub fn is_read_denied(&self, path: &Path) -> bool {
let Some(path) = resolve_candidate_path(path, self.cwd.as_path()) else { let Some(cwd) = self.native_cwd.as_ref() else {
return true;
};
let Some(path) = resolve_candidate_path(path, cwd.as_path()) else {
return true; return true;
}; };
let path = PathUri::from(path); let path = PathUri::from(path);
let cwd = PathUri::from_abs_path(&self.cwd); let cwd = PathUri::from_abs_path(cwd);
let context = FileSystemSandboxPolicyContext { let context = FileSystemSandboxPolicyContext {
cwd: &cwd, cwd: &cwd,
workspace_roots: std::slice::from_ref(&cwd), workspace_roots: std::slice::from_ref(&cwd),
user_home_dir: self.user_home_dir.as_ref(), user_home_dir: self.user_home_dir.as_ref(),
temporary_directories: Some(&self.temporary_directories), temporary_directories: Some(&self.temporary_directories),
}; };
FileSystemSandboxPolicy::matches_prepared_read_deny( self.is_read_denied_uri(&path, &context)
&path, }
&context,
&self.denied_roots, /// Returns whether an executor-owned URI is denied under its matching path context.
&self.deny_read_matchers, pub fn is_read_denied_uri(
self.invalid_pattern, &self,
) path: &PathUri,
context: &FileSystemSandboxPolicyContext<'_>,
) -> bool {
FileSystemSandboxPolicy::matches_prepared_read_deny(path, context, &self.prepared)
} }
/// Checks an enumerated path using a canonical location already resolved by /// Checks an enumerated path using a canonical location already resolved by
@@ -891,7 +923,7 @@ impl FileSystemSandboxPolicy {
.unwrap_or(false) .unwrap_or(false)
} }
fn resolve_access( pub fn resolve_access(
&self, &self,
path: &PathUri, path: &PathUri,
context: &FileSystemSandboxPolicyContext<'_>, context: &FileSystemSandboxPolicyContext<'_>,
@@ -973,7 +1005,7 @@ impl FileSystemSandboxPolicy {
&self, &self,
context: &FileSystemSandboxPolicyContext<'_>, context: &FileSystemSandboxPolicyContext<'_>,
invalid_glob_behavior: InvalidDenyReadGlobBehavior, invalid_glob_behavior: InvalidDenyReadGlobBehavior,
) -> Result<(Vec<PathUri>, Vec<GlobMatcher>, bool), String> { ) -> Result<PreparedReadDenyMatcher, String> {
let file_system_root = file_system_root(context); let file_system_root = file_system_root(context);
let denied_roots = self let denied_roots = self
.resolved_entries(context) .resolved_entries(context)
@@ -987,7 +1019,11 @@ impl FileSystemSandboxPolicy {
.map(|(root, _)| root) .map(|(root, _)| root)
.collect(); .collect();
let Some(convention) = context.cwd.infer_path_convention() else { let Some(convention) = context.cwd.infer_path_convention() else {
return Ok((denied_roots, Vec::new(), true)); return Ok(PreparedReadDenyMatcher {
denied_roots,
deny_read_matchers: Vec::new(),
invalid_pattern: true,
});
}; };
let mut deny_read_matchers = Vec::new(); let mut deny_read_matchers = Vec::new();
let mut invalid_pattern = false; let mut invalid_pattern = false;
@@ -995,7 +1031,11 @@ impl FileSystemSandboxPolicy {
Ok(patterns) => patterns, Ok(patterns) => patterns,
Err(err) => match invalid_glob_behavior { Err(err) => match invalid_glob_behavior {
InvalidDenyReadGlobBehavior::FailClosed => { InvalidDenyReadGlobBehavior::FailClosed => {
return Ok((denied_roots, Vec::new(), true)); return Ok(PreparedReadDenyMatcher {
denied_roots,
deny_read_matchers: Vec::new(),
invalid_pattern: true,
});
} }
InvalidDenyReadGlobBehavior::ReturnError => return Err(err), InvalidDenyReadGlobBehavior::ReturnError => return Err(err),
}, },
@@ -1011,15 +1051,17 @@ impl FileSystemSandboxPolicy {
}, },
} }
} }
Ok((denied_roots, deny_read_matchers, invalid_pattern)) Ok(PreparedReadDenyMatcher {
denied_roots,
deny_read_matchers,
invalid_pattern,
})
} }
fn matches_prepared_read_deny( fn matches_prepared_read_deny(
path: &PathUri, path: &PathUri,
context: &FileSystemSandboxPolicyContext<'_>, context: &FileSystemSandboxPolicyContext<'_>,
denied_roots: &[PathUri], prepared: &PreparedReadDenyMatcher,
deny_read_matchers: &[GlobMatcher],
invalid_pattern: bool,
) -> bool { ) -> bool {
let Some(convention) = context.cwd.infer_path_convention() else { let Some(convention) = context.cwd.infer_path_convention() else {
return true; return true;
@@ -1030,11 +1072,14 @@ impl FileSystemSandboxPolicy {
{ {
return true; return true;
} }
if invalid_pattern { if prepared.invalid_pattern {
return true; return true;
} }
denied_roots.iter().any(|root| path.starts_with(root)) prepared
|| deny_read_matchers.iter().any(|matcher| { .denied_roots
.iter()
.any(|root| path.starts_with(root))
|| prepared.deny_read_matchers.iter().any(|matcher| {
let path = match convention { let path = match convention {
PathConvention::Posix => path.decoded_path_bytes(), PathConvention::Posix => path.decoded_path_bytes(),
PathConvention::Windows => Cow::Owned( PathConvention::Windows => Cow::Owned(
@@ -1897,7 +1942,7 @@ fn with_local_policy_context<T>(
Some(evaluate(&path, &context)) Some(evaluate(&path, &context))
} }
fn file_system_root(context: &FileSystemSandboxPolicyContext<'_>) -> Option<PathUri> { pub fn file_system_root(context: &FileSystemSandboxPolicyContext<'_>) -> Option<PathUri> {
context.cwd.lexical_depth()?; context.cwd.lexical_depth()?;
context.cwd.ancestors().last() context.cwd.ancestors().last()
} }
@@ -2725,25 +2770,21 @@ mod tests {
), ),
unreadable_glob_entry(r"C:\workspace\**\*.env".to_string()), unreadable_glob_entry(r"C:\workspace\**\*.env".to_string()),
]); ]);
let (roots, globs, invalid) = policy let prepared = policy
.prepare_deny_read_matcher(&context, InvalidDenyReadGlobBehavior::ReturnError) .prepare_deny_read_matcher(&context, InvalidDenyReadGlobBehavior::ReturnError)
.expect("remote deny matcher"); .expect("remote deny matcher");
assert!(roots.is_empty()); assert!(prepared.denied_roots.is_empty());
assert!(FileSystemSandboxPolicy::matches_prepared_read_deny( assert!(FileSystemSandboxPolicy::matches_prepared_read_deny(
&path("file:///c:/WORKSPACE/app/.ENV"), &path("file:///c:/WORKSPACE/app/.ENV"),
&context, &context,
&roots, &prepared,
&globs,
invalid,
)); ));
for candidate in ["file:///%00/bad/path/YQ", "file:///C:/workspace/%2Fsecret"] { for candidate in ["file:///%00/bad/path/YQ", "file:///C:/workspace/%2Fsecret"] {
assert!(FileSystemSandboxPolicy::matches_prepared_read_deny( assert!(FileSystemSandboxPolicy::matches_prepared_read_deny(
&path(candidate), &path(candidate),
&context, &context,
&roots, &prepared,
&globs,
invalid,
)); ));
} }
assert!(!policy.can_write_path(&path("file:///C:/workspace/.codex/config"), &context)); assert!(!policy.can_write_path(&path("file:///C:/workspace/.codex/config"), &context));
@@ -2793,12 +2834,12 @@ mod tests {
let policy = FileSystemSandboxPolicy::restricted(vec![unreadable_glob_entry( let policy = FileSystemSandboxPolicy::restricted(vec![unreadable_glob_entry(
pattern.to_string(), pattern.to_string(),
)]); )]);
let (roots, globs, invalid) = policy let prepared = policy
.prepare_deny_read_matcher(&context, InvalidDenyReadGlobBehavior::ReturnError) .prepare_deny_read_matcher(&context, InvalidDenyReadGlobBehavior::ReturnError)
.expect("home-relative deny glob"); .expect("home-relative deny glob");
assert!(FileSystemSandboxPolicy::matches_prepared_read_deny( assert!(FileSystemSandboxPolicy::matches_prepared_read_deny(
&candidate, &context, &roots, &globs, invalid, &candidate, &context, &prepared,
)); ));
let without_home = FileSystemSandboxPolicyContext { let without_home = FileSystemSandboxPolicyContext {
@@ -2813,10 +2854,10 @@ mod tests {
) )
.is_err() .is_err()
); );
let (_, _, invalid) = policy let prepared = policy
.prepare_deny_read_matcher(&without_home, InvalidDenyReadGlobBehavior::FailClosed) .prepare_deny_read_matcher(&without_home, InvalidDenyReadGlobBehavior::FailClosed)
.expect("missing executor home fails closed"); .expect("missing executor home fails closed");
assert!(invalid); assert!(prepared.invalid_pattern);
} }
} }
+294 -121
View File
@@ -7,15 +7,16 @@ use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::permissions::ReadDenyMatcher; use codex_protocol::permissions::ReadDenyMatcher;
use codex_protocol::permissions::file_system_root;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_preserving_symlinks; use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri; use codex_utils_path_uri::PathUri;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::path::Path; use std::path::Path;
use std::path::PathBuf;
pub fn normalize_additional_permissions( pub fn normalize_additional_permissions(
additional_permissions: AdditionalPermissionProfile, additional_permissions: AdditionalPermissionProfile,
@@ -35,28 +36,8 @@ pub fn normalize_additional_permissions(
"glob file system permissions only support deny-read entries".to_string(), "glob file system permissions only support deny-read entries".to_string(),
); );
} }
let path = match entry.path { if !entries.contains(&entry) {
FileSystemPath::Path { path } => FileSystemPath::Path { entries.push(entry);
path: path
.to_abs_path()
.ok()
.and_then(|path| canonicalize_preserving_symlinks(path.as_path()).ok())
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
.map(Into::into)
.unwrap_or(path),
},
FileSystemPath::GlobPattern { pattern } => {
FileSystemPath::GlobPattern { pattern }
}
FileSystemPath::Special { value } => FileSystemPath::Special { value },
};
let normalized_entry = FileSystemSandboxEntry {
path,
access: entry.access,
missing_path_behavior: entry.missing_path_behavior,
};
if !entries.contains(&normalized_entry) {
entries.push(normalized_entry);
} }
} }
let file_system = FileSystemPermissions { let file_system = FileSystemPermissions {
@@ -73,19 +54,55 @@ pub fn normalize_additional_permissions(
}) })
} }
pub fn normalize_additional_permissions_with_context(
additional_permissions: AdditionalPermissionProfile,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<AdditionalPermissionProfile, String> {
let normalized = normalize_additional_permissions(additional_permissions)?;
if let Some(file_system) = normalized.file_system.as_ref() {
for entry in &file_system.entries {
let FileSystemPath::Path { path } = &entry.path else {
continue;
};
if path.infer_path_convention().is_none()
|| path.infer_path_convention() != context.cwd.infer_path_convention()
|| path.join(".").is_err()
|| context.cwd.join(".").is_err()
{
return Err(format!(
"permission path `{path}` does not match executor cwd `{}`",
context.cwd
));
}
}
}
Ok(normalized)
}
/// Resolves cwd-dependent permission entries without filtering their authority. /// Resolves cwd-dependent permission entries without filtering their authority.
/// ///
/// Unlike intersection, this preserves narrower grants beneath denied paths. /// Unlike intersection, this preserves narrower grants beneath denied paths.
pub fn materialize_additional_permissions( pub fn materialize_additional_permissions(
mut additional_permissions: AdditionalPermissionProfile, additional_permissions: AdditionalPermissionProfile,
cwd: &Path, cwd: &Path,
) -> Result<AdditionalPermissionProfile, String> {
let cwd = AbsolutePathBuf::from_absolute_path(cwd)
.map_err(|err| format!("invalid permission cwd: {err}"))?;
let cwd = PathUri::from(cwd);
with_local_context_for_cwd(&cwd, |context| {
materialize_additional_permissions_with_context(additional_permissions, context)
})
}
pub fn materialize_additional_permissions_with_context(
mut additional_permissions: AdditionalPermissionProfile,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<AdditionalPermissionProfile, String> { ) -> Result<AdditionalPermissionProfile, String> {
if let Some(file_system) = additional_permissions.file_system.as_mut() { if let Some(file_system) = additional_permissions.file_system.as_mut() {
for entry in &mut file_system.entries { file_system.entries = materialize_context_dependent_entries(&file_system.entries, context)
*entry = materialize_cwd_dependent_entry(entry, cwd); .ok_or_else(|| format!("unable to resolve permission path in `{}`", context.cwd))?;
}
} }
normalize_additional_permissions(additional_permissions) normalize_additional_permissions_with_context(additional_permissions, context)
} }
pub fn merge_permission_profiles( pub fn merge_permission_profiles(
@@ -146,43 +163,61 @@ pub fn intersect_permission_profiles(
requested: AdditionalPermissionProfile, requested: AdditionalPermissionProfile,
granted: AdditionalPermissionProfile, granted: AdditionalPermissionProfile,
cwd: &Path, cwd: &Path,
) -> AdditionalPermissionProfile {
AbsolutePathBuf::from_absolute_path(cwd)
.ok()
.map(PathUri::from)
.map_or_else(AdditionalPermissionProfile::default, |cwd| {
with_local_context_for_cwd(&cwd, |context| {
intersect_permission_profiles_with_context(requested, granted, context)
})
})
}
pub fn intersect_permission_profiles_with_context(
requested: AdditionalPermissionProfile,
granted: AdditionalPermissionProfile,
context: &FileSystemSandboxPolicyContext<'_>,
) -> AdditionalPermissionProfile { ) -> AdditionalPermissionProfile {
let file_system = requested let file_system = requested
.file_system .file_system
.map(|requested_file_system| { .and_then(|requested_file_system| {
let granted_file_system = granted.file_system.unwrap_or_default(); let granted_file_system = granted.file_system.unwrap_or_default();
let requested_policy = let requested_entries =
FileSystemSandboxPolicy::restricted(requested_file_system.entries.clone()); materialize_context_dependent_entries(&requested_file_system.entries, context)?;
let requested_read_deny_matcher = ReadDenyMatcher::new(&requested_policy, cwd); let granted_entries =
materialize_context_dependent_entries(&granted_file_system.entries, context)?;
let requested_policy = FileSystemSandboxPolicy::restricted(requested_entries.clone());
let requested_read_deny_matcher =
ReadDenyMatcher::from_context(&requested_policy, context);
let mut accepted_entries = Vec::new(); let mut accepted_entries = Vec::new();
for entry in granted_file_system.entries.iter().filter(|entry| { for entry in granted_entries.iter().filter(|entry| {
granted_file_system_entry_within_request( granted_file_system_entry_within_request(
&requested_file_system, &requested_file_system,
&requested_policy, &requested_policy,
requested_read_deny_matcher.as_ref(), requested_read_deny_matcher.as_ref(),
entry, entry,
cwd, context,
) )
}) { }) {
let entry = materialize_cwd_dependent_entry(entry, cwd); if !accepted_entries.contains(entry) {
if !accepted_entries.contains(&entry) { accepted_entries.push(entry.clone());
accepted_entries.push(entry);
} }
} }
let mut entries = accepted_entries.clone(); let mut entries = accepted_entries.clone();
let requested_retained_deny_entries = retain_constraining_deny_entries( let requested_retained_deny_entries = retain_constraining_deny_entries(
&requested_file_system.entries, &requested_entries,
&accepted_entries, &accepted_entries,
cwd, context,
&mut entries, &mut entries,
); );
let granted_retained_deny_entries = retain_constraining_deny_entries( let granted_retained_deny_entries = retain_constraining_deny_entries(
&granted_file_system.entries, &granted_entries,
&accepted_entries, &accepted_entries,
cwd, context,
&mut entries, &mut entries,
); );
FileSystemPermissions { Some(FileSystemPermissions {
glob_scan_max_depth: merge_glob_scan_max_depth( glob_scan_max_depth: merge_glob_scan_max_depth(
&requested_retained_deny_entries, &requested_retained_deny_entries,
requested_file_system.glob_scan_max_depth.map(usize::from), requested_file_system.glob_scan_max_depth.map(usize::from),
@@ -191,7 +226,7 @@ pub fn intersect_permission_profiles(
) )
.and_then(NonZeroUsize::new), .and_then(NonZeroUsize::new),
entries, entries,
} })
}) })
.filter(|file_system| !file_system.is_empty()); .filter(|file_system| !file_system.is_empty());
let network = match (requested.network, granted.network) { let network = match (requested.network, granted.network) {
@@ -214,6 +249,46 @@ pub fn intersect_permission_profiles(
} }
} }
fn with_local_context_for_cwd<T>(
cwd: &PathUri,
evaluate: impl FnOnce(&FileSystemSandboxPolicyContext<'_>) -> T,
) -> T {
let user_home_dir = PathUri::from_host_native_path("~").ok();
let local_cwd = std::env::current_dir().ok();
let temporary_directory_env_vars: &[&str] = if cfg!(windows) {
&["TEMP", "TMP"]
} else {
&["TMPDIR"]
};
let normalize_temp_path = |path: std::ffi::OsString| {
PathUri::from_host_native_path(&path).ok().or_else(|| {
if cfg!(unix) {
PathUri::from_host_native_path(local_cwd.as_ref()?.join(path)).ok()
} else {
None
}
})
};
let mut temporary_directories = Vec::new();
for name in temporary_directory_env_vars {
if let Some(path) = std::env::var_os(name)
.filter(|path| !path.is_empty())
.filter(|path| cfg!(unix) || Path::new(path).is_absolute())
.and_then(&normalize_temp_path)
&& !temporary_directories.contains(&path)
{
temporary_directories.push(path);
}
}
let context = FileSystemSandboxPolicyContext {
cwd,
workspace_roots: std::slice::from_ref(cwd),
user_home_dir: user_home_dir.as_ref(),
temporary_directories: Some(&temporary_directories),
};
evaluate(&context)
}
fn merge_glob_scan_max_depth( fn merge_glob_scan_max_depth(
left_entries: &[FileSystemSandboxEntry], left_entries: &[FileSystemSandboxEntry],
left_depth: Option<usize>, left_depth: Option<usize>,
@@ -261,26 +336,37 @@ fn granted_file_system_entry_within_request(
requested_policy: &FileSystemSandboxPolicy, requested_policy: &FileSystemSandboxPolicy,
requested_read_deny_matcher: Option<&ReadDenyMatcher>, requested_read_deny_matcher: Option<&ReadDenyMatcher>,
granted_entry: &FileSystemSandboxEntry, granted_entry: &FileSystemSandboxEntry,
cwd: &Path, context: &FileSystemSandboxPolicyContext<'_>,
) -> bool { ) -> bool {
if !granted_entry.access.can_read() if !granted_entry.access.can_read()
|| matches!( || matches!(
&granted_entry.path, &granted_entry.path,
FileSystemPath::Special { FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp, value: FileSystemSpecialPath::SlashTmp,
} if !cfg!(unix) } if context.cwd.infer_path_convention() != Some(PathConvention::Posix)
) )
{ {
return false; return false;
} }
if context.cwd.infer_path_convention() == Some(PathConvention::Windows)
&& is_root_entry(granted_entry)
&& !requested.entries.iter().any(|requested_entry| {
is_root_entry(requested_entry)
&& access_covers(requested_entry.access, granted_entry.access)
})
{
return false;
}
if let Some(path) = resolve_permission_path(&granted_entry.path, cwd) { if let Some(path) = resolve_permission_path(&granted_entry.path, context) {
if requested_read_deny_matcher.is_some_and(|matcher| matcher.is_read_denied(path.as_path())) if path.infer_path_convention() != context.cwd.infer_path_convention()
|| requested_read_deny_matcher
.is_some_and(|matcher| matcher.is_read_denied_uri(&path, context))
{ {
return false; return false;
} }
return access_covers( return access_covers(
requested_policy.resolve_access_with_cwd(path.as_path(), cwd), requested_policy.resolve_access(&path, context),
granted_entry.access, granted_entry.access,
); );
} }
@@ -294,7 +380,7 @@ fn granted_file_system_entry_within_request(
fn retain_constraining_deny_entries( fn retain_constraining_deny_entries(
source_entries: &[FileSystemSandboxEntry], source_entries: &[FileSystemSandboxEntry],
accepted_entries: &[FileSystemSandboxEntry], accepted_entries: &[FileSystemSandboxEntry],
cwd: &Path, context: &FileSystemSandboxPolicyContext<'_>,
output_entries: &mut Vec<FileSystemSandboxEntry>, output_entries: &mut Vec<FileSystemSandboxEntry>,
) -> Vec<FileSystemSandboxEntry> { ) -> Vec<FileSystemSandboxEntry> {
let mut retained_entries = Vec::new(); let mut retained_entries = Vec::new();
@@ -302,14 +388,13 @@ fn retain_constraining_deny_entries(
.iter() .iter()
.filter(|entry| entry.access == FileSystemAccessMode::Deny) .filter(|entry| entry.access == FileSystemAccessMode::Deny)
{ {
if !deny_entry_constrains_accepted_grant(entry, accepted_entries, cwd) { if !deny_entry_constrains_accepted_grant(entry, accepted_entries, context) {
continue; continue;
} }
let entry = materialize_cwd_dependent_entry(entry, cwd); if !output_entries.contains(entry) {
if !output_entries.contains(&entry) {
output_entries.push(entry.clone()); output_entries.push(entry.clone());
} }
retained_entries.push(entry); retained_entries.push(entry.clone());
} }
retained_entries retained_entries
} }
@@ -317,51 +402,57 @@ fn retain_constraining_deny_entries(
fn deny_entry_constrains_accepted_grant( fn deny_entry_constrains_accepted_grant(
deny_entry: &FileSystemSandboxEntry, deny_entry: &FileSystemSandboxEntry,
accepted_entries: &[FileSystemSandboxEntry], accepted_entries: &[FileSystemSandboxEntry],
cwd: &Path, context: &FileSystemSandboxPolicyContext<'_>,
) -> bool { ) -> bool {
accepted_entries accepted_entries
.iter() .iter()
.filter(|entry| entry.access.can_read()) .filter(|entry| entry.access.can_read())
.any(|entry| { .any(|entry| {
let Some(grant_path) = resolve_permission_path(&entry.path, cwd) else { if is_root_entry(entry) {
return true;
}
let Some(grant_path) = resolve_permission_path(&entry.path, context) else {
return false; return false;
}; };
match &deny_entry.path { match &deny_entry.path {
FileSystemPath::GlobPattern { pattern } => glob_static_prefix_path(pattern, cwd) FileSystemPath::GlobPattern { pattern } => {
.is_some_and(|prefix| paths_may_overlap(&prefix, &grant_path)), glob_static_prefix_path(pattern, context)
.is_none_or(|prefix| paths_overlap(&prefix, &grant_path))
}
FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => { FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => {
resolve_permission_path(&deny_entry.path, cwd) resolve_permission_path(&deny_entry.path, context)
.is_some_and(|deny_path| paths_may_overlap(&deny_path, &grant_path)) .is_none_or(|deny_path| paths_overlap(&deny_path, &grant_path))
} }
} }
}) })
} }
fn glob_static_prefix_path(pattern: &str, cwd: &Path) -> Option<AbsolutePathBuf> { fn glob_static_prefix_path(
let resolved_pattern = AbsolutePathBuf::resolve_path_against_base(pattern, cwd); pattern: &str,
let resolved_pattern = resolved_pattern.as_path().to_string_lossy(); context: &FileSystemSandboxPolicyContext<'_>,
let prefix = match resolved_pattern.find(['*', '?', '[', ']']) { ) -> Option<PathUri> {
let is_windows = context.cwd.infer_path_convention() == Some(PathConvention::Windows);
let (prefix, wildcard_in_segment) = match pattern.find(['*', '?', '[', ']']) {
Some(0) => return None, Some(0) => return None,
Some(index) => { Some(index) => {
let prefix = &resolved_pattern[..index]; let prefix = &pattern[..index];
if prefix.ends_with(std::path::MAIN_SEPARATOR) (
|| prefix.ends_with('/') prefix,
|| prefix.ends_with('\\') !(prefix.ends_with('/') || is_windows && prefix.ends_with('\\')),
{ )
Path::new(prefix)
} else {
Path::new(prefix).parent()?
}
} }
None => Path::new(resolved_pattern.as_ref()), None => (pattern, false),
}; };
AbsolutePathBuf::from_absolute_path(prefix).ok() let prefix = context.cwd.join(prefix).ok()?;
if wildcard_in_segment {
prefix.parent()
} else {
Some(prefix)
}
} }
fn paths_may_overlap(left: &AbsolutePathBuf, right: &AbsolutePathBuf) -> bool { fn paths_overlap(left: &PathUri, right: &PathUri) -> bool {
let left = PathUri::from_abs_path(left); left.overlaps(right).unwrap_or(true)
let right = PathUri::from_abs_path(right);
left.overlaps(&right).unwrap_or(true)
} }
fn access_covers(requested: FileSystemAccessMode, granted: FileSystemAccessMode) -> bool { fn access_covers(requested: FileSystemAccessMode, granted: FileSystemAccessMode) -> bool {
@@ -372,63 +463,70 @@ fn access_covers(requested: FileSystemAccessMode, granted: FileSystemAccessMode)
} }
} }
fn is_root_entry(entry: &FileSystemSandboxEntry) -> bool {
matches!(
&entry.path,
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
}
)
}
fn materialize_cwd_dependent_entry( fn materialize_cwd_dependent_entry(
entry: &FileSystemSandboxEntry, entry: &FileSystemSandboxEntry,
cwd: &Path, context: &FileSystemSandboxPolicyContext<'_>,
) -> FileSystemSandboxEntry { ) -> Option<FileSystemSandboxEntry> {
match &entry.path { match &entry.path {
FileSystemPath::Special { FileSystemPath::GlobPattern { pattern } => {
value: FileSystemSpecialPath::ProjectRoots { .. }, let is_windows = context.cwd.infer_path_convention() == Some(PathConvention::Windows);
} => resolve_permission_path(&entry.path, cwd) let home_relative = pattern
.map(|path| FileSystemSandboxEntry { .strip_prefix("~/")
path: path.into(), .or_else(|| (pattern == "~").then_some(""))
.or_else(|| is_windows.then(|| pattern.strip_prefix("~\\")).flatten());
let (root, pattern) = match home_relative {
Some(suffix) => (
context.user_home_dir?,
suffix.trim_start_matches(|separator| {
separator == '/' || is_windows && separator == '\\'
}),
),
None => (context.cwd, pattern.as_str()),
};
let path = root.join(pattern).ok()?;
let path = FileSystemPath::GlobPattern {
pattern: path.inferred_native_path_string(),
};
Some(FileSystemSandboxEntry {
path,
access: entry.access, access: entry.access,
missing_path_behavior: entry.missing_path_behavior, missing_path_behavior: entry.missing_path_behavior,
}) })
.unwrap_or_else(|| entry.clone()), }
FileSystemPath::GlobPattern { pattern } => FileSystemSandboxEntry { FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => Some(entry.clone()),
path: FileSystemPath::GlobPattern {
pattern: AbsolutePathBuf::resolve_path_against_base(pattern, cwd)
.to_string_lossy()
.into_owned(),
},
access: entry.access,
missing_path_behavior: entry.missing_path_behavior,
},
FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => entry.clone(),
} }
} }
fn resolve_permission_path(path: &FileSystemPath, cwd: &Path) -> Option<AbsolutePathBuf> { fn resolve_permission_path(
path: &FileSystemPath,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Option<PathUri> {
match path { match path {
FileSystemPath::Path { path } => path.to_abs_path().ok(), FileSystemPath::Path { path } => Some(path.clone()),
FileSystemPath::GlobPattern { .. } => None, FileSystemPath::GlobPattern { .. } => None,
FileSystemPath::Special { value } => match value { FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => { FileSystemSpecialPath::Root => file_system_root(context),
let root = cwd.ancestors().last()?;
AbsolutePathBuf::from_absolute_path(root).ok()
}
FileSystemSpecialPath::ProjectRoots { subpath } => { FileSystemSpecialPath::ProjectRoots { subpath } => {
let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok()?; let root = context.workspace_roots.first()?;
Some(match subpath { match subpath {
Some(subpath) => { Some(subpath) => root.join(subpath).ok(),
AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path()) None => Some(root.clone()),
}
None => cwd,
})
}
FileSystemSpecialPath::Tmpdir => {
let tmpdir = std::env::var_os("TMPDIR")?;
if tmpdir.is_empty() {
None
} else {
AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()
} }
} }
FileSystemSpecialPath::SlashTmp if cfg!(unix) => { FileSystemSpecialPath::Tmpdir => context.temporary_directories?.first().cloned(),
AbsolutePathBuf::from_absolute_path("/tmp") FileSystemSpecialPath::SlashTmp
.ok() if context.cwd.infer_path_convention() == Some(PathConvention::Posix) =>
.filter(|path| path.as_path().is_dir()) {
context.cwd.join("/tmp").ok()
} }
FileSystemSpecialPath::SlashTmp FileSystemSpecialPath::SlashTmp
| FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Minimal
@@ -437,6 +535,81 @@ fn resolve_permission_path(path: &FileSystemPath, cwd: &Path) -> Option<Absolute
} }
} }
fn materialize_context_dependent_entries(
entries: &[FileSystemSandboxEntry],
context: &FileSystemSandboxPolicyContext<'_>,
) -> Option<Vec<FileSystemSandboxEntry>> {
let mut materialized = Vec::new();
for entry in entries {
match &entry.path {
FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { .. },
} => {
let mut resolved = Vec::new();
for root in context.workspace_roots {
let mut root_context = *context;
root_context.workspace_roots = std::slice::from_ref(root);
let Some(path) = resolve_permission_path(&entry.path, &root_context) else {
if entry.access == FileSystemAccessMode::Deny {
return None;
}
continue;
};
resolved.push(materialized_path_entry(entry, path));
}
if entry.access == FileSystemAccessMode::Deny && resolved.is_empty() {
return None;
}
materialized.extend(resolved);
}
FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
} => {
let Some(temporary_directories) = context.temporary_directories else {
if entry.access == FileSystemAccessMode::Deny {
return None;
}
materialized.push(entry.clone());
continue;
};
materialized.extend(
temporary_directories
.iter()
.cloned()
.map(|path| materialized_path_entry(entry, path)),
);
}
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
} => {
resolve_permission_path(&entry.path, context)?;
materialized.push(entry.clone());
}
_ => {
let Some(entry) = materialize_cwd_dependent_entry(entry, context) else {
if entry.access == FileSystemAccessMode::Deny {
return None;
}
continue;
};
materialized.push(entry);
}
}
}
Some(materialized)
}
fn materialized_path_entry(
entry: &FileSystemSandboxEntry,
path: PathUri,
) -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: entry.access,
missing_path_behavior: entry.missing_path_behavior,
}
}
fn merge_permission_entries( fn merge_permission_entries(
base: &[FileSystemSandboxEntry], base: &[FileSystemSandboxEntry],
permissions: &[FileSystemSandboxEntry], permissions: &[FileSystemSandboxEntry],
@@ -1,8 +1,11 @@
use super::effective_file_system_sandbox_policy; use super::effective_file_system_sandbox_policy;
use super::intersect_permission_profiles; use super::intersect_permission_profiles;
use super::intersect_permission_profiles_with_context;
use super::materialize_additional_permissions; use super::materialize_additional_permissions;
use super::materialize_additional_permissions_with_context;
use super::merge_file_system_policy_with_additional_permissions; use super::merge_file_system_policy_with_additional_permissions;
use super::normalize_additional_permissions; use super::normalize_additional_permissions;
use super::normalize_additional_permissions_with_context;
use super::should_require_platform_sandbox; use super::should_require_platform_sandbox;
use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile; use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile;
use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FileSystemPermissions;
@@ -11,9 +14,11 @@ use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use dunce::canonicalize; use dunce::canonicalize;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[cfg(unix)] #[cfg(unix)]
@@ -129,6 +134,39 @@ fn normalize_additional_permissions_preserves_network() {
); );
} }
#[test]
fn normalize_additional_permissions_only_checks_convention_with_context() {
let windows_path = PathUri::parse("file:///C:/workspace/out").expect("Windows path URI");
let permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![FileSystemSandboxEntry::new(
FileSystemPath::Path { path: windows_path },
FileSystemAccessMode::Write,
)],
glob_scan_max_depth: None,
}),
..Default::default()
};
assert_eq!(
normalize_additional_permissions(permissions.clone()).expect("context-free permissions"),
permissions
);
let cwd = PathUri::parse("file:///workspace").expect("POSIX cwd URI");
let workspace_roots = [cwd.clone()];
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: &workspace_roots,
user_home_dir: None,
temporary_directories: None,
};
assert!(
normalize_additional_permissions_with_context(permissions, &context).is_err(),
"context-aware normalization should reject mismatched conventions"
);
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn normalize_additional_permissions_preserves_symlinked_write_paths() { fn normalize_additional_permissions_preserves_symlinked_write_paths() {
@@ -242,6 +280,7 @@ fn materialize_additional_permissions_preserves_authority_and_constraints() {
canonicalize(temp_dir.path()).expect("canonicalize temp dir"), canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
) )
.expect("absolute temp dir"); .expect("absolute temp dir");
let home = PathUri::from_host_native_path("~").expect("host home");
let project_path = |subpath: &str| FileSystemPath::Special { let project_path = |subpath: &str| FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(subpath.to_owned())), value: FileSystemSpecialPath::project_roots(Some(subpath.to_owned())),
}; };
@@ -263,6 +302,8 @@ fn materialize_additional_permissions_preserves_authority_and_constraints() {
FileSystemSandboxEntry::new(project_path("private/reopened"), Write), FileSystemSandboxEntry::new(project_path("private/reopened"), Write),
reopened.clone(), reopened.clone(),
deny_glob("**/*.env".to_owned()), deny_glob("**/*.env".to_owned()),
deny_glob("~".to_owned()),
deny_glob("~/private/*.env".to_owned()),
]); ]);
let expected = profile(vec![ let expected = profile(vec![
FileSystemSandboxEntry::new(cwd.clone().into(), Write), FileSystemSandboxEntry::new(cwd.clone().into(), Write),
@@ -270,6 +311,12 @@ fn materialize_additional_permissions_preserves_authority_and_constraints() {
FileSystemSandboxEntry::skip_missing_path(cwd.join("readonly").into(), Read), FileSystemSandboxEntry::skip_missing_path(cwd.join("readonly").into(), Read),
reopened, reopened,
deny_glob(cwd.join("**/*.env").to_string_lossy().into_owned()), deny_glob(cwd.join("**/*.env").to_string_lossy().into_owned()),
deny_glob(home.inferred_native_path_string()),
deny_glob(
home.join("private/*.env")
.expect("home-relative deny glob")
.inferred_native_path_string(),
),
]); ]);
assert_eq!( assert_eq!(
@@ -279,6 +326,50 @@ fn materialize_additional_permissions_preserves_authority_and_constraints() {
); );
} }
#[test]
fn materialize_additional_permissions_ignores_empty_tmpdir_deny() {
let cwd = PathUri::parse("file:///workspace").expect("cwd URI");
let workspace_roots = [cwd.clone()];
let temporary_directories = [];
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: &workspace_roots,
user_home_dir: None,
temporary_directories: Some(&temporary_directories),
};
let write = FileSystemSandboxEntry::new(
FileSystemPath::Path { path: cwd.clone() },
FileSystemAccessMode::Write,
);
let permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![
write.clone(),
FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
FileSystemAccessMode::Deny,
),
],
glob_scan_max_depth: None,
}),
..Default::default()
};
assert_eq!(
materialize_additional_permissions_with_context(permissions, &context)
.expect("permissions"),
PermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![write],
glob_scan_max_depth: None,
}),
..Default::default()
}
);
}
#[test] #[test]
fn intersect_permission_profiles_preserves_explicit_empty_requested_reads() { fn intersect_permission_profiles_preserves_explicit_empty_requested_reads() {
let temp_dir = TempDir::new().expect("create temp dir"); let temp_dir = TempDir::new().expect("create temp dir");
@@ -449,6 +540,58 @@ fn intersect_permission_profiles_preserves_deny_across_case_variant_grant() {
); );
} }
#[test]
fn intersect_permission_profiles_preserves_rooted_first_segment_glob_deny() {
use FileSystemAccessMode::Deny;
use FileSystemAccessMode::Write;
for (cwd_uri, grant_uri, pattern) in [
("file:///workspace", "file:///fooX", "/foo*/*"),
("file:///workspace", "file:///foo%5Cbar", r"/foo\\*/*"),
("file:///C:/workspace", "file:///C:/fooX", r"C:\foo*\*"),
(
"file://server/share/workspace",
"file://server/share/fooX",
r"\\server\share\foo*\*",
),
] {
let cwd = PathUri::parse(cwd_uri).expect("cwd URI");
let grant_path = PathUri::parse(grant_uri).expect("grant URI");
let workspace_roots = [cwd.clone()];
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: &workspace_roots,
user_home_dir: None,
temporary_directories: None,
};
let grant_entry =
FileSystemSandboxEntry::new(FileSystemPath::Path { path: grant_path }, Write);
let deny_entry = FileSystemSandboxEntry::new(
FileSystemPath::GlobPattern {
pattern: pattern.to_string(),
},
Deny,
);
let profile = |entries| PermissionProfile {
file_system: Some(FileSystemPermissions {
entries,
glob_scan_max_depth: None,
}),
..Default::default()
};
assert_eq!(
intersect_permission_profiles_with_context(
profile(vec![grant_entry.clone(), deny_entry.clone()]),
profile(vec![grant_entry.clone()]),
&context,
),
profile(vec![grant_entry, deny_entry]),
"deny glob should survive for {pattern}",
);
}
}
#[test] #[test]
fn intersect_permission_profiles_preserves_opaque_child_deny() { fn intersect_permission_profiles_preserves_opaque_child_deny() {
use std::ffi::OsString; use std::ffi::OsString;