mirror of
https://github.com/openai/codex.git
synced 2026-05-01 09:56:37 +00:00
Merge origin/main into rhan/surface-updates to resolve PR #14374 conflicts
This commit is contained in:
@@ -240,6 +240,13 @@ pub enum ResponseInputItem {
|
||||
call_id: String,
|
||||
output: FunctionCallOutputPayload,
|
||||
},
|
||||
ToolSearchOutput {
|
||||
call_id: String,
|
||||
status: String,
|
||||
execution: String,
|
||||
#[ts(type = "unknown[]")]
|
||||
tools: Vec<serde_json::Value>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
@@ -320,12 +327,27 @@ pub enum ResponseItem {
|
||||
#[ts(skip)]
|
||||
id: Option<String>,
|
||||
name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
namespace: Option<String>,
|
||||
// The Responses API returns the function call arguments as a *string* that contains
|
||||
// JSON, not as an already‑parsed object. We keep it as a raw string here and let
|
||||
// Session::handle_function_call parse it into a Value.
|
||||
arguments: String,
|
||||
call_id: String,
|
||||
},
|
||||
ToolSearchCall {
|
||||
#[serde(default, skip_serializing)]
|
||||
#[ts(skip)]
|
||||
id: Option<String>,
|
||||
call_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
status: Option<String>,
|
||||
execution: String,
|
||||
#[ts(type = "unknown")]
|
||||
arguments: serde_json::Value,
|
||||
},
|
||||
// NOTE: The `output` field for `function_call_output` uses a dedicated payload type with
|
||||
// custom serialization. On the wire it is either:
|
||||
// - a plain string (`content`)
|
||||
@@ -354,6 +376,13 @@ pub enum ResponseItem {
|
||||
call_id: String,
|
||||
output: FunctionCallOutputPayload,
|
||||
},
|
||||
ToolSearchOutput {
|
||||
call_id: Option<String>,
|
||||
status: String,
|
||||
execution: String,
|
||||
#[ts(type = "unknown[]")]
|
||||
tools: Vec<serde_json::Value>,
|
||||
},
|
||||
// Emitted by the Responses API when the agent triggers a web search.
|
||||
// Example payload (from SSE `response.output_item.done`):
|
||||
// {
|
||||
@@ -883,6 +912,17 @@ impl From<ResponseInputItem> for ResponseItem {
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
Self::CustomToolCallOutput { call_id, output }
|
||||
}
|
||||
ResponseInputItem::ToolSearchOutput {
|
||||
call_id,
|
||||
status,
|
||||
execution,
|
||||
tools,
|
||||
} => Self::ToolSearchOutput {
|
||||
call_id: Some(call_id),
|
||||
status,
|
||||
execution,
|
||||
tools,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,6 +1028,13 @@ impl From<Vec<UserInput>> for ResponseInputItem {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
pub struct SearchToolCallParams {
|
||||
pub query: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
|
||||
/// or `shell`, the `arguments` field should deserialize to this struct.
|
||||
@@ -1721,6 +1768,29 @@ mod tests {
|
||||
assert_eq!(text, Some("line 1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_deserializes_optional_namespace() {
|
||||
let item: ResponseItem = serde_json::from_value(serde_json::json!({
|
||||
"type": "function_call",
|
||||
"name": "mcp__codex_apps__gmail_get_recent_emails",
|
||||
"namespace": "mcp__codex_apps__gmail",
|
||||
"arguments": "{\"top_k\":5}",
|
||||
"call_id": "call-1",
|
||||
}))
|
||||
.expect("function_call should deserialize");
|
||||
|
||||
assert_eq!(
|
||||
item,
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "mcp__codex_apps__gmail_get_recent_emails".to_string(),
|
||||
namespace: Some("mcp__codex_apps__gmail".to_string()),
|
||||
arguments: "{\"top_k\":5}".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_sandbox_mode_into_developer_instructions() {
|
||||
let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into();
|
||||
@@ -2193,6 +2263,169 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_call_roundtrips() -> Result<()> {
|
||||
let parsed: ResponseItem = serde_json::from_str(
|
||||
r#"{
|
||||
"type": "tool_search_call",
|
||||
"call_id": "search-1",
|
||||
"execution": "client",
|
||||
"arguments": {
|
||||
"query": "calendar create",
|
||||
"limit": 1
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
ResponseItem::ToolSearchCall {
|
||||
id: None,
|
||||
call_id: Some("search-1".to_string()),
|
||||
status: None,
|
||||
execution: "client".to_string(),
|
||||
arguments: serde_json::json!({
|
||||
"query": "calendar create",
|
||||
"limit": 1,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&parsed)?,
|
||||
serde_json::json!({
|
||||
"type": "tool_search_call",
|
||||
"call_id": "search-1",
|
||||
"execution": "client",
|
||||
"arguments": {
|
||||
"query": "calendar create",
|
||||
"limit": 1,
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_output_roundtrips() -> Result<()> {
|
||||
let input = ResponseInputItem::ToolSearchOutput {
|
||||
call_id: "search-1".to_string(),
|
||||
status: "completed".to_string(),
|
||||
execution: "client".to_string(),
|
||||
tools: vec![serde_json::json!({
|
||||
"type": "function",
|
||||
"name": "mcp__codex_apps__calendar_create_event",
|
||||
"description": "Create a calendar event.",
|
||||
"defer_loading": true,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false,
|
||||
}
|
||||
})],
|
||||
};
|
||||
assert_eq!(
|
||||
ResponseItem::from(input.clone()),
|
||||
ResponseItem::ToolSearchOutput {
|
||||
call_id: Some("search-1".to_string()),
|
||||
status: "completed".to_string(),
|
||||
execution: "client".to_string(),
|
||||
tools: vec![serde_json::json!({
|
||||
"type": "function",
|
||||
"name": "mcp__codex_apps__calendar_create_event",
|
||||
"description": "Create a calendar event.",
|
||||
"defer_loading": true,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false,
|
||||
}
|
||||
})],
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(input)?,
|
||||
serde_json::json!({
|
||||
"type": "tool_search_output",
|
||||
"call_id": "search-1",
|
||||
"status": "completed",
|
||||
"execution": "client",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "mcp__codex_apps__calendar_create_event",
|
||||
"description": "Create a calendar event.",
|
||||
"defer_loading": true,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_server_items_allow_null_call_id() -> Result<()> {
|
||||
let parsed_call: ResponseItem = serde_json::from_str(
|
||||
r#"{
|
||||
"type": "tool_search_call",
|
||||
"execution": "server",
|
||||
"call_id": null,
|
||||
"status": "completed",
|
||||
"arguments": {
|
||||
"paths": ["crm"]
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
assert_eq!(
|
||||
parsed_call,
|
||||
ResponseItem::ToolSearchCall {
|
||||
id: None,
|
||||
call_id: None,
|
||||
status: Some("completed".to_string()),
|
||||
execution: "server".to_string(),
|
||||
arguments: serde_json::json!({
|
||||
"paths": ["crm"],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
let parsed_output: ResponseItem = serde_json::from_str(
|
||||
r#"{
|
||||
"type": "tool_search_output",
|
||||
"execution": "server",
|
||||
"call_id": null,
|
||||
"status": "completed",
|
||||
"tools": []
|
||||
}"#,
|
||||
)?;
|
||||
assert_eq!(
|
||||
parsed_output,
|
||||
ResponseItem::ToolSearchOutput {
|
||||
call_id: None,
|
||||
status: "completed".to_string(),
|
||||
execution: "server".to_string(),
|
||||
tools: vec![],
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_remote_and_local_images_share_label_sequence() -> Result<()> {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
|
||||
@@ -34,13 +34,31 @@ impl NetworkSandboxPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)]
|
||||
/// Access mode for a filesystem entry.
|
||||
///
|
||||
/// When two equally specific entries target the same path, we compare these by
|
||||
/// conflict precedence rather than by capability breadth: `none` beats
|
||||
/// `write`, and `write` beats `read`.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Display,
|
||||
JsonSchema,
|
||||
TS,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum FileSystemAccessMode {
|
||||
None,
|
||||
Read,
|
||||
Write,
|
||||
None,
|
||||
}
|
||||
|
||||
impl FileSystemAccessMode {
|
||||
@@ -121,6 +139,22 @@ pub struct FileSystemSandboxPolicy {
|
||||
pub entries: Vec<FileSystemSandboxEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ResolvedFileSystemEntry {
|
||||
path: AbsolutePathBuf,
|
||||
access: FileSystemAccessMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct FileSystemSemanticSignature {
|
||||
has_full_disk_read_access: bool,
|
||||
has_full_disk_write_access: bool,
|
||||
include_platform_defaults: bool,
|
||||
readable_roots: Vec<AbsolutePathBuf>,
|
||||
writable_roots: Vec<WritableRoot>,
|
||||
unreadable_roots: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[ts(tag = "type")]
|
||||
@@ -163,6 +197,43 @@ impl FileSystemSandboxPolicy {
|
||||
.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,
|
||||
@@ -229,7 +300,7 @@ impl FileSystemSandboxPolicy {
|
||||
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
|
||||
FileSystemSandboxKind::Restricted => {
|
||||
self.has_root_access(FileSystemAccessMode::can_write)
|
||||
&& !self.has_explicit_deny_entries()
|
||||
&& !self.has_write_narrowing_entries()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,31 +319,64 @@ impl FileSystemSandboxPolicy {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_access_with_cwd(&self, path: &Path, cwd: &Path) -> FileSystemAccessMode {
|
||||
match self.kind {
|
||||
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
|
||||
return FileSystemAccessMode::Write;
|
||||
}
|
||||
FileSystemSandboxKind::Restricted => {}
|
||||
}
|
||||
|
||||
let Some(path) = resolve_candidate_path(path, cwd) else {
|
||||
return FileSystemAccessMode::None;
|
||||
};
|
||||
|
||||
self.resolved_entries_with_cwd(cwd)
|
||||
.into_iter()
|
||||
.filter(|entry| path.as_path().starts_with(entry.path.as_path()))
|
||||
.max_by_key(resolved_entry_precedence)
|
||||
.map(|entry| entry.access)
|
||||
.unwrap_or(FileSystemAccessMode::None)
|
||||
}
|
||||
|
||||
pub fn can_read_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
|
||||
self.resolve_access_with_cwd(path, cwd).can_read()
|
||||
}
|
||||
|
||||
pub fn can_write_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
|
||||
self.resolve_access_with_cwd(path, cwd).can_write()
|
||||
}
|
||||
|
||||
pub fn needs_direct_runtime_enforcement(
|
||||
&self,
|
||||
network_policy: NetworkSandboxPolicy,
|
||||
cwd: &Path,
|
||||
) -> bool {
|
||||
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(legacy_policy) = self.to_legacy_sandbox_policy(network_policy, cwd) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
self.semantic_signature(cwd)
|
||||
!= FileSystemSandboxPolicy::from_legacy_sandbox_policy(&legacy_policy, cwd)
|
||||
.semantic_signature(cwd)
|
||||
}
|
||||
|
||||
/// Returns the explicit readable roots resolved against the provided cwd.
|
||||
pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
|
||||
if self.has_full_disk_read_access() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
||||
let mut readable_roots = Vec::new();
|
||||
if self.has_root_access(FileSystemAccessMode::can_read)
|
||||
&& let Some(cwd_absolute) = cwd_absolute.as_ref()
|
||||
{
|
||||
readable_roots.push(absolute_root_path_for_cwd(cwd_absolute));
|
||||
}
|
||||
|
||||
dedup_absolute_paths(
|
||||
readable_roots
|
||||
self.resolved_entries_with_cwd(cwd)
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.access.can_read())
|
||||
.filter_map(|entry| {
|
||||
resolve_file_system_path(&entry.path, cwd_absolute.as_ref())
|
||||
}),
|
||||
)
|
||||
.filter(|entry| entry.access.can_read())
|
||||
.filter(|entry| self.can_read_path_with_cwd(entry.path.as_path(), cwd))
|
||||
.map(|entry| entry.path)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -284,32 +388,22 @@ impl FileSystemSandboxPolicy {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
||||
let resolved_entries = self.resolved_entries_with_cwd(cwd);
|
||||
let read_only_roots = dedup_absolute_paths(
|
||||
self.entries
|
||||
resolved_entries
|
||||
.iter()
|
||||
.filter(|entry| !entry.access.can_write())
|
||||
.filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref()))
|
||||
.filter(|entry| !self.can_write_path_with_cwd(entry.path.as_path(), cwd))
|
||||
.map(|entry| entry.path.clone())
|
||||
.collect(),
|
||||
);
|
||||
let mut writable_roots = Vec::new();
|
||||
if self.has_root_access(FileSystemAccessMode::can_write)
|
||||
&& let Some(cwd_absolute) = cwd_absolute.as_ref()
|
||||
{
|
||||
writable_roots.push(absolute_root_path_for_cwd(cwd_absolute));
|
||||
}
|
||||
|
||||
dedup_absolute_paths(
|
||||
writable_roots
|
||||
resolved_entries
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.access.can_write())
|
||||
.filter_map(|entry| {
|
||||
resolve_file_system_path(&entry.path, cwd_absolute.as_ref())
|
||||
}),
|
||||
)
|
||||
.filter(|entry| entry.access.can_write())
|
||||
.filter(|entry| self.can_write_path_with_cwd(entry.path.as_path(), cwd))
|
||||
.map(|entry| entry.path)
|
||||
.collect(),
|
||||
)
|
||||
.into_iter()
|
||||
@@ -339,12 +433,20 @@ impl FileSystemSandboxPolicy {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
||||
let root = AbsolutePathBuf::from_absolute_path(cwd)
|
||||
.ok()
|
||||
.map(|cwd| absolute_root_path_for_cwd(&cwd));
|
||||
|
||||
dedup_absolute_paths(
|
||||
self.entries
|
||||
self.resolved_entries_with_cwd(cwd)
|
||||
.iter()
|
||||
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
||||
.filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref()))
|
||||
.filter(|entry| !self.can_read_path_with_cwd(entry.path.as_path(), cwd))
|
||||
// Restricted policies already deny reads outside explicit allow roots,
|
||||
// so materializing the filesystem root here would erase narrower
|
||||
// readable carveouts when downstream sandboxes apply deny masks last.
|
||||
.filter(|entry| root.as_ref() != Some(&entry.path))
|
||||
.map(|entry| entry.path.clone())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -504,6 +606,32 @@ impl FileSystemSandboxPolicy {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn resolved_entries_with_cwd(&self, cwd: &Path) -> Vec<ResolvedFileSystemEntry> {
|
||||
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
||||
self.entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
resolve_entry_path(&entry.path, cwd_absolute.as_ref()).map(|path| {
|
||||
ResolvedFileSystemEntry {
|
||||
path,
|
||||
access: entry.access,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn semantic_signature(&self, cwd: &Path) -> FileSystemSemanticSignature {
|
||||
FileSystemSemanticSignature {
|
||||
has_full_disk_read_access: self.has_full_disk_read_access(),
|
||||
has_full_disk_write_access: self.has_full_disk_write_access(),
|
||||
include_platform_defaults: self.include_platform_defaults(),
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SandboxPolicy> for NetworkSandboxPolicy {
|
||||
@@ -641,6 +769,108 @@ fn resolve_file_system_path(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_entry_path(
|
||||
path: &FileSystemPath,
|
||||
cwd: Option<&AbsolutePathBuf>,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
match path {
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
} => cwd.map(absolute_root_path_for_cwd),
|
||||
_ => resolve_file_system_path(path, cwd),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option<AbsolutePathBuf> {
|
||||
if path.is_absolute() {
|
||||
AbsolutePathBuf::from_absolute_path(path).ok()
|
||||
} else {
|
||||
AbsolutePathBuf::resolve_path_against_base(path, cwd).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when two config paths refer to the same exact target before
|
||||
/// any prefix matching is applied.
|
||||
///
|
||||
/// This is intentionally narrower than full path resolution: it only answers
|
||||
/// the "can one entry shadow another at the same specificity?" question used
|
||||
/// by `has_write_narrowing_entries`.
|
||||
fn file_system_paths_share_target(left: &FileSystemPath, right: &FileSystemPath) -> bool {
|
||||
match (left, right) {
|
||||
(FileSystemPath::Path { path: left }, FileSystemPath::Path { path: right }) => {
|
||||
left == right
|
||||
}
|
||||
(FileSystemPath::Special { value: left }, FileSystemPath::Special { value: right }) => {
|
||||
special_paths_share_target(left, right)
|
||||
}
|
||||
(FileSystemPath::Path { path }, FileSystemPath::Special { value })
|
||||
| (FileSystemPath::Special { value }, FileSystemPath::Path { path }) => {
|
||||
special_path_matches_absolute_path(value, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares special-path tokens that resolve to the same concrete target
|
||||
/// without needing a cwd.
|
||||
fn special_paths_share_target(left: &FileSystemSpecialPath, right: &FileSystemSpecialPath) -> bool {
|
||||
match (left, right) {
|
||||
(FileSystemSpecialPath::Root, FileSystemSpecialPath::Root)
|
||||
| (FileSystemSpecialPath::Minimal, FileSystemSpecialPath::Minimal)
|
||||
| (
|
||||
FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
)
|
||||
| (FileSystemSpecialPath::Tmpdir, FileSystemSpecialPath::Tmpdir)
|
||||
| (FileSystemSpecialPath::SlashTmp, FileSystemSpecialPath::SlashTmp) => true,
|
||||
(
|
||||
FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
FileSystemSpecialPath::ProjectRoots { subpath: None },
|
||||
)
|
||||
| (
|
||||
FileSystemSpecialPath::ProjectRoots { subpath: None },
|
||||
FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
) => true,
|
||||
(
|
||||
FileSystemSpecialPath::ProjectRoots { subpath: left },
|
||||
FileSystemSpecialPath::ProjectRoots { subpath: right },
|
||||
) => left == right,
|
||||
(
|
||||
FileSystemSpecialPath::Unknown {
|
||||
path: left,
|
||||
subpath: left_subpath,
|
||||
},
|
||||
FileSystemSpecialPath::Unknown {
|
||||
path: right,
|
||||
subpath: right_subpath,
|
||||
},
|
||||
) => left == right && left_subpath == right_subpath,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches cwd-independent special paths against absolute `Path` entries when
|
||||
/// they name the same location.
|
||||
///
|
||||
/// We intentionally only fold the special paths whose concrete meaning is
|
||||
/// stable without a cwd, such as `/` and `/tmp`.
|
||||
fn special_path_matches_absolute_path(
|
||||
value: &FileSystemSpecialPath,
|
||||
path: &AbsolutePathBuf,
|
||||
) -> bool {
|
||||
match value {
|
||||
FileSystemSpecialPath::Root => path.as_path().parent().is_none(),
|
||||
FileSystemSpecialPath::SlashTmp => path.as_path() == Path::new("/tmp"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Orders resolved entries so the most specific path wins first, then applies
|
||||
/// the access tie-breaker from [`FileSystemAccessMode`].
|
||||
fn resolved_entry_precedence(entry: &ResolvedFileSystemEntry) -> (usize, FileSystemAccessMode) {
|
||||
let specificity = entry.path.as_path().components().count();
|
||||
(specificity, entry.access)
|
||||
}
|
||||
|
||||
fn absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
let root = cwd
|
||||
.as_path()
|
||||
@@ -808,6 +1038,7 @@ fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn unknown_special_paths_are_ignored_by_legacy_bridge() -> std::io::Result<()> {
|
||||
@@ -835,4 +1066,204 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_access_with_cwd_uses_most_specific_entry() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let docs =
|
||||
AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
|
||||
let docs_private = AbsolutePathBuf::resolve_path_against_base("docs/private", cwd.path())
|
||||
.expect("resolve docs/private");
|
||||
let docs_private_public =
|
||||
AbsolutePathBuf::resolve_path_against_base("docs/private/public", cwd.path())
|
||||
.expect("resolve docs/private/public");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: docs.clone() },
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: docs_private.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: docs_private_public.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(cwd.path(), cwd.path()),
|
||||
FileSystemAccessMode::Write
|
||||
);
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
|
||||
FileSystemAccessMode::Read
|
||||
);
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()),
|
||||
FileSystemAccessMode::None
|
||||
);
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()),
|
||||
FileSystemAccessMode::Write
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_only_nested_carveouts_need_direct_runtime_enforcement() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let docs =
|
||||
AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: docs },
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(
|
||||
policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
|
||||
);
|
||||
|
||||
let legacy_workspace_write =
|
||||
FileSystemSandboxPolicy::from(&SandboxPolicy::new_workspace_write_policy());
|
||||
assert!(
|
||||
!legacy_workspace_write
|
||||
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_write_with_read_only_child_is_not_full_disk_write() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let docs =
|
||||
AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: docs.clone() },
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(!policy.has_full_disk_write_access());
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
|
||||
FileSystemAccessMode::Read
|
||||
);
|
||||
assert!(
|
||||
policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_deny_does_not_materialize_as_unreadable_root() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let docs =
|
||||
AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: docs.clone() },
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
|
||||
FileSystemAccessMode::Read
|
||||
);
|
||||
assert_eq!(policy.get_readable_roots_with_cwd(cwd.path()), vec![docs]);
|
||||
assert!(policy.get_unreadable_roots_with_cwd(cwd.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_root_deny_prevents_full_disk_write_access() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let root = AbsolutePathBuf::from_absolute_path(cwd.path())
|
||||
.map(|cwd| absolute_root_path_for_cwd(&cwd))
|
||||
.expect("resolve filesystem root");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(!policy.has_full_disk_write_access());
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(root.as_path(), cwd.path()),
|
||||
FileSystemAccessMode::None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_specificity_write_override_keeps_full_disk_write_access() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let docs =
|
||||
AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: docs.clone() },
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: docs.clone() },
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(policy.has_full_disk_write_access());
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
|
||||
FileSystemAccessMode::Write
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_system_access_mode_orders_by_conflict_precedence() {
|
||||
assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
|
||||
assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3179,6 +3179,10 @@ pub struct CollabAgentSpawnEndEvent {
|
||||
/// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the
|
||||
/// beginning.
|
||||
pub prompt: String,
|
||||
/// Model requested for the spawned agent.
|
||||
pub model: String,
|
||||
/// Reasoning effort requested for the spawned agent.
|
||||
pub reasoning_effort: ReasoningEffortConfig,
|
||||
/// Last known status of the new agent reported to the sender agent.
|
||||
pub status: AgentStatus,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user