mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
Merge origin/main into codex/workspace-owner-usage-nudge
Resolve the TUI and config schema conflicts between the workspace owner usage nudge work and the latest main changes without rewriting branch history. Made-with: Cursor
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -2,8 +2,10 @@ pub mod account;
|
||||
mod agent_path;
|
||||
pub mod auth;
|
||||
mod thread_id;
|
||||
mod tool_name;
|
||||
pub use agent_path::AgentPath;
|
||||
pub use thread_id::ThreadId;
|
||||
pub use tool_name::ToolName;
|
||||
pub mod approvals;
|
||||
pub mod config_types;
|
||||
pub mod dynamic_tools;
|
||||
|
||||
@@ -1362,6 +1362,8 @@ impl CallToolResult {
|
||||
fn convert_mcp_content_to_items(
|
||||
contents: &[serde_json::Value],
|
||||
) -> Option<Vec<FunctionCallOutputContentItem>> {
|
||||
const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum McpContent {
|
||||
@@ -1372,6 +1374,8 @@ fn convert_mcp_content_to_items(
|
||||
data: String,
|
||||
#[serde(rename = "mimeType", alias = "mime_type")]
|
||||
mime_type: Option<String>,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
@@ -1383,7 +1387,11 @@ fn convert_mcp_content_to_items(
|
||||
for content in contents {
|
||||
let item = match serde_json::from_value::<McpContent>(content.clone()) {
|
||||
Ok(McpContent::Text { text }) => FunctionCallOutputContentItem::InputText { text },
|
||||
Ok(McpContent::Image { data, mime_type }) => {
|
||||
Ok(McpContent::Image {
|
||||
data,
|
||||
mime_type,
|
||||
meta,
|
||||
}) => {
|
||||
saw_image = true;
|
||||
let image_url = if data.starts_with("data:") {
|
||||
data
|
||||
@@ -1393,7 +1401,15 @@ fn convert_mcp_content_to_items(
|
||||
};
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url,
|
||||
detail: None,
|
||||
detail: meta
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|detail| match detail {
|
||||
"original" => Some(ImageDetail::Original),
|
||||
_ => None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText {
|
||||
@@ -2264,6 +2280,70 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_original_detail_metadata_on_mcp_images() -> Result<()> {
|
||||
let call_tool_result = CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "image",
|
||||
"data": "BASE64",
|
||||
"mimeType": "image/png",
|
||||
"_meta": {
|
||||
"codex/imageDetail": "original",
|
||||
},
|
||||
})],
|
||||
structured_content: None,
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let payload = call_tool_result.into_function_call_output_payload();
|
||||
let Some(items) = payload.content_items() else {
|
||||
panic!("expected content items");
|
||||
};
|
||||
let items = items.to_vec();
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,BASE64".into(),
|
||||
detail: Some(ImageDetail::Original),
|
||||
}]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_mcp_image_detail_metadata() -> Result<()> {
|
||||
let call_tool_result = CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "image",
|
||||
"data": "BASE64",
|
||||
"mimeType": "image/png",
|
||||
"_meta": {
|
||||
"codex/imageDetail": "high",
|
||||
},
|
||||
})],
|
||||
structured_content: None,
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let payload = call_tool_result.into_function_call_output_payload();
|
||||
let Some(items) = payload.content_items() else {
|
||||
panic!("expected content items");
|
||||
};
|
||||
let items = items.to_vec();
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,BASE64".into(),
|
||||
detail: None,
|
||||
}]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_array_payload_into_items() -> Result<()> {
|
||||
let json = r#"[
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1223,11 +1223,15 @@ impl SandboxPolicy {
|
||||
|
||||
// Include /tmp on Unix unless explicitly excluded.
|
||||
if cfg!(unix) && !exclude_slash_tmp {
|
||||
#[allow(clippy::expect_used)]
|
||||
let slash_tmp =
|
||||
AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
|
||||
if slash_tmp.as_path().is_dir() {
|
||||
roots.push(slash_tmp);
|
||||
match AbsolutePathBuf::from_absolute_path("/tmp") {
|
||||
Ok(slash_tmp) => {
|
||||
if slash_tmp.as_path().is_dir() {
|
||||
roots.push(slash_tmp);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Ignoring invalid /tmp for sandbox writable root: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2374,6 +2378,9 @@ pub struct McpToolCallBeginEvent {
|
||||
/// Identifier so this can be paired with the McpToolCallEnd event.
|
||||
pub call_id: String,
|
||||
pub invocation: McpInvocation,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mcp_app_resource_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq)]
|
||||
@@ -2381,6 +2388,9 @@ pub struct McpToolCallEndEvent {
|
||||
/// Identifier for the corresponding McpToolCallBegin that finished.
|
||||
pub call_id: String,
|
||||
pub invocation: McpInvocation,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mcp_app_resource_uri: Option<String>,
|
||||
#[ts(type = "string")]
|
||||
pub duration: Duration,
|
||||
/// Result of the tool call. Note this could be an error.
|
||||
@@ -2846,6 +2856,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>,
|
||||
@@ -4976,6 +4988,7 @@ mod tests {
|
||||
|
||||
assert_eq!(item.trace_id, None);
|
||||
assert_eq!(item.network, None);
|
||||
assert_eq!(item.file_system_sandbox_policy, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4993,6 +5006,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,
|
||||
@@ -5013,6 +5034,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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
|
||||
/// Identifies a callable tool, preserving the namespace split when the model
|
||||
/// provides one.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
pub struct ToolName {
|
||||
pub name: String,
|
||||
pub namespace: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolName {
|
||||
pub fn new(namespace: Option<String>, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
namespace,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plain(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
namespace: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn namespaced(namespace: impl Into<String>, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
namespace: Some(namespace.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> String {
|
||||
match &self.namespace {
|
||||
Some(namespace) => format!("{namespace}{}", self.name),
|
||||
None => self.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ToolName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.namespace {
|
||||
Some(namespace) => write!(f, "{namespace}{}", self.name),
|
||||
None => f.write_str(&self.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolName {
|
||||
fn from(name: String) -> Self {
|
||||
Self::plain(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ToolName {
|
||||
fn from(name: &str) -> Self {
|
||||
Self::plain(name)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user