diff --git a/CHANGELOG.md b/CHANGELOG.md index e01a17aa..6cf32b13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Preserved standard MCP tool metadata and call results end to end, including + output schemas, annotations, icons, `_meta`, `structuredContent`, decoded + images, embedded resources, and bounded content-addressed artifacts. + +### Security + +- Made MCP confirmation annotations escalation-only: tool metadata can require + HITL but cannot weaken a host Allow/Ask/Deny decision. +- Allowed an explicitly scoped delegated worker to see a parent-hidden tool + while keeping both parent and worker execution policies authoritative. + ## [5.3.5] - 2026-07-17 ### Added diff --git a/core/src/agent/tool_invoker.rs b/core/src/agent/tool_invoker.rs index 8b875b0c..e5b9d1bc 100644 --- a/core/src/agent/tool_invoker.rs +++ b/core/src/agent/tool_invoker.rs @@ -83,6 +83,11 @@ impl ScopedToolInvoker { tool_name: &invocation.name, args: &invocation.args, pre_tool_block, + tool_requires_confirmation: self + .agent + .tool_executor + .registry() + .requires_confirmation(&invocation.name, &invocation.args), }) .await } @@ -395,7 +400,11 @@ impl ToolInvoker for ScopedToolInvoker { } fn available_tools(&self) -> Vec { - self.agent.tool_executor.registry().list() + let mut tools = self.agent.tool_executor.registry().list(); + if let Some(permission_checker) = &self.agent.config.permission_checker { + tools.retain(|tool| permission_checker.expose_to_model(tool)); + } + tools } fn capabilities(&self, name: &str, args: &serde_json::Value) -> Option { diff --git a/core/src/child_run.rs b/core/src/child_run.rs index 7357bcef..e52e20a1 100644 --- a/core/src/child_run.rs +++ b/core/src/child_run.rs @@ -30,7 +30,7 @@ use crate::agent::AgentConfig; use crate::hitl::ConfirmationProvider; use crate::hooks::HookExecutor; -use crate::permissions::{PermissionChecker, PermissionPolicy}; +use crate::permissions::{PermissionChecker, PermissionDecision, PermissionPolicy}; use crate::security::SecurityProvider; use crate::skills::SkillRegistry; use std::sync::Arc; @@ -60,6 +60,56 @@ pub struct ChildRunContext { pub budget_guard: Option>, } +struct DelegatedPermissionChecker { + child: Arc, + child_policy: Option, + parent: Arc, + parent_policy: Option, +} + +impl PermissionChecker for DelegatedPermissionChecker { + fn expose_to_model(&self, tool_name: &str) -> bool { + if !self.child.expose_to_model(tool_name) { + return false; + } + + if self + .child_policy + .as_ref() + .is_some_and(|policy| policy.declares_tool_access(tool_name)) + { + // A worker's explicitly declared capability may cross a host's + // ordinary parent-only visibility filter. A serializable parent + // deny remains authoritative and keeps the tool hidden. + return self + .parent_policy + .as_ref() + .map(|policy| policy.expose_to_model(tool_name)) + .unwrap_or_else(|| self.parent.expose_to_model(tool_name)); + } + + self.parent.expose_to_model(tool_name) + } + + fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision { + stricter_decision( + self.child.check(tool_name, args), + self.parent.check(tool_name, args), + ) + } +} + +const fn stricter_decision( + left: PermissionDecision, + right: PermissionDecision, +) -> PermissionDecision { + match (left, right) { + (PermissionDecision::Deny, _) | (_, PermissionDecision::Deny) => PermissionDecision::Deny, + (PermissionDecision::Ask, _) | (_, PermissionDecision::Ask) => PermissionDecision::Ask, + (PermissionDecision::Allow, PermissionDecision::Allow) => PermissionDecision::Allow, + } +} + impl ChildRunContext { /// Apply inherited capabilities to a child AgentConfig. /// @@ -75,10 +125,26 @@ impl ChildRunContext { if config.skill_registry.is_none() { config.skill_registry = self.skill_registry.clone(); } - if config.permission_checker.is_none() { - config.permission_checker = self.permission_checker.clone(); - config.permission_policy = self.permission_policy.clone(); - } else if config.permission_policy.is_none() { + match ( + config.permission_checker.take(), + self.permission_checker.clone(), + ) { + (Some(child), Some(parent)) => { + config.permission_checker = Some(Arc::new(DelegatedPermissionChecker { + child, + child_policy: config.permission_policy.clone(), + parent, + parent_policy: self.permission_policy.clone(), + })); + } + (Some(child), None) => config.permission_checker = Some(child), + (None, Some(parent)) => { + config.permission_checker = Some(parent); + config.permission_policy = self.permission_policy.clone(); + } + (None, None) => {} + } + if config.permission_policy.is_none() { config.permission_policy = self.permission_policy.clone(); } if config.tool_timeout_ms.is_none() { @@ -110,3 +176,77 @@ impl ChildRunContext { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone)] + struct ParentVisibility { + policy: PermissionPolicy, + hide_use_from_primary: bool, + } + + impl PermissionChecker for ParentVisibility { + fn expose_to_model(&self, tool_name: &str) -> bool { + !(self.hide_use_from_primary && tool_name.starts_with("mcp__use_")) + && self.policy.expose_to_model(tool_name) + } + + fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision { + self.policy.check(tool_name, args) + } + } + + fn delegated( + child_policy: PermissionPolicy, + parent_policy: PermissionPolicy, + ) -> DelegatedPermissionChecker { + DelegatedPermissionChecker { + child: Arc::new(child_policy.clone()), + child_policy: Some(child_policy), + parent: Arc::new(ParentVisibility { + policy: parent_policy.clone(), + hide_use_from_primary: true, + }), + parent_policy: Some(parent_policy), + } + } + + #[test] + fn explicitly_scoped_worker_can_see_parent_hidden_tool() { + let mut child = PermissionPolicy::new().allow("mcp__use_*"); + child.default_decision = PermissionDecision::Deny; + let parent = PermissionPolicy::new().allow("mcp__use_*"); + let checker = delegated(child, parent); + + assert!(checker.expose_to_model("mcp__use_browser__browser_snapshot")); + assert_eq!( + checker.check("mcp__use_browser__browser_snapshot", &serde_json::json!({})), + PermissionDecision::Allow + ); + } + + #[test] + fn unrelated_worker_does_not_inherit_parent_hidden_use_tools() { + let child = PermissionPolicy::new().allow("read(*)"); + let parent = PermissionPolicy::new().allow("mcp__use_*"); + let checker = delegated(child, parent); + + assert!(!checker.expose_to_model("mcp__use_browser__browser_snapshot")); + } + + #[test] + fn parent_deny_remains_authoritative_for_explicit_worker_capability() { + let mut child = PermissionPolicy::new().allow("mcp__use_*"); + child.default_decision = PermissionDecision::Deny; + let parent = PermissionPolicy::new().deny("mcp__use_ocr__ocr_extract"); + let checker = delegated(child, parent); + + assert!(!checker.expose_to_model("mcp__use_ocr__ocr_extract")); + assert_eq!( + checker.check("mcp__use_ocr__ocr_extract", &serde_json::json!({})), + PermissionDecision::Deny + ); + } +} diff --git a/core/src/mcp/manager.rs b/core/src/mcp/manager.rs index c8cebe81..a291b24c 100644 --- a/core/src/mcp/manager.rs +++ b/core/src/mcp/manager.rs @@ -5,7 +5,7 @@ use crate::mcp::client::McpClient; use crate::mcp::oauth; use crate::mcp::protocol::{ - CallToolResult, McpServerConfig, McpTool, McpTransportConfig, OAuthConfig, ToolContent, + CallToolResult, McpServerConfig, McpTool, McpTransportConfig, OAuthConfig, }; use crate::mcp::transport::http_sse::HttpSseTransport; use crate::mcp::transport::stdio::StdioTransport; @@ -16,6 +16,8 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; +pub use crate::mcp::result::tool_result_to_string; + /// MCP server status #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct McpServerStatus { @@ -489,33 +491,6 @@ fn now_epoch_ms() -> u64 { .unwrap_or(0) } -/// Convert MCP tool result to string output -pub fn tool_result_to_string(result: &CallToolResult) -> String { - let mut output = String::new(); - - for content in &result.content { - match content { - ToolContent::Text { text } => { - output.push_str(text); - output.push('\n'); - } - ToolContent::Image { data: _, mime_type } => { - output.push_str(&format!("[Image: {}]\n", mime_type)); - } - ToolContent::Resource { resource } => { - if let Some(text) = &resource.text { - output.push_str(text); - output.push('\n'); - } else { - output.push_str(&format!("[Resource: {}]\n", resource.uri)); - } - } - } - } - - output.trim_end().to_string() -} - #[cfg(test)] #[path = "manager/tests.rs"] mod tests; diff --git a/core/src/mcp/manager/tests.rs b/core/src/mcp/manager/tests.rs index adcd34b0..e62003ba 100644 --- a/core/src/mcp/manager/tests.rs +++ b/core/src/mcp/manager/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::mcp::protocol::ToolContent; #[test] fn test_parse_tool_name() { @@ -32,6 +33,7 @@ fn test_tool_result_to_string() { }, ], is_error: false, + ..CallToolResult::default() }; let output = tool_result_to_string(&result); @@ -136,6 +138,7 @@ fn test_tool_result_to_string_single_text() { text: "Hello World".to_string(), }], is_error: false, + ..CallToolResult::default() }; let output = tool_result_to_string(&result); assert_eq!(output, "Hello World"); @@ -153,6 +156,7 @@ fn test_tool_result_to_string_multiple_text() { }, ], is_error: false, + ..CallToolResult::default() }; let output = tool_result_to_string(&result); assert!(output.contains("First line")); @@ -164,6 +168,7 @@ fn test_tool_result_to_string_empty() { let result = CallToolResult { content: vec![], is_error: false, + ..CallToolResult::default() }; let output = tool_result_to_string(&result); assert_eq!(output, ""); @@ -177,6 +182,7 @@ fn test_tool_result_to_string_image() { mime_type: "image/png".to_string(), }], is_error: false, + ..CallToolResult::default() }; let output = tool_result_to_string(&result); assert!(output.contains("[Image: image/png]")); @@ -195,6 +201,7 @@ fn test_tool_result_to_string_resource() { }, }], is_error: false, + ..CallToolResult::default() }; let output = tool_result_to_string(&result); assert!(output.contains("Resource content")); @@ -222,6 +229,7 @@ fn test_tool_result_to_string_mixed_content() { }, ], is_error: false, + ..CallToolResult::default() }; let output = tool_result_to_string(&result); assert!(output.contains("Text content")); diff --git a/core/src/mcp/mod.rs b/core/src/mcp/mod.rs index 83695670..5ffa0b01 100644 --- a/core/src/mcp/mod.rs +++ b/core/src/mcp/mod.rs @@ -61,13 +61,15 @@ pub mod client; pub mod manager; pub mod oauth; pub mod protocol; +mod result; pub mod tools; pub mod transport; pub use client::McpClient; -pub use manager::{tool_result_to_string, McpManager, McpServerStatus}; +pub use manager::{McpManager, McpServerStatus}; pub use protocol::{ - CallToolResult, McpNotification, McpResource, McpServerConfig, McpTool, McpTransportConfig, - OAuthConfig, ServerCapabilities, ToolContent, + CallToolResult, McpNotification, McpResource, McpServerConfig, McpTool, McpToolAnnotations, + McpTransportConfig, OAuthConfig, ServerCapabilities, ToolContent, }; +pub use result::tool_result_to_string; pub use tools::{create_mcp_tools, McpToolWrapper}; diff --git a/core/src/mcp/protocol.rs b/core/src/mcp/protocol.rs index ec1540a2..60d33fc6 100644 --- a/core/src/mcp/protocol.rs +++ b/core/src/mcp/protocol.rs @@ -172,8 +172,39 @@ pub struct InitializeResult { pub struct McpTool { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub input_schema: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub icons: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Standard MCP tool behavior hints. +/// +/// These fields remain untrusted hints. Hosts may use them to request more +/// confirmation, but never to weaken an existing permission policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpToolAnnotations { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_only_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub destructive_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotent_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub open_world_hint: Option, + #[serde(flatten)] + pub additional: HashMap, } /// List tools result @@ -221,12 +252,17 @@ pub struct ResourceContent { } /// Call tool result -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CallToolResult { + #[serde(default)] pub content: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub structured_content: Option, #[serde(default)] pub is_error: bool, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } // ============================================================================ diff --git a/core/src/mcp/protocol/tests.rs b/core/src/mcp/protocol/tests.rs index 94bf0029..985bc93e 100644 --- a/core/src/mcp/protocol/tests.rs +++ b/core/src/mcp/protocol/tests.rs @@ -213,8 +213,13 @@ fn test_json_rpc_notification_serialization() { fn test_mcp_tool_serialize() { let tool = McpTool { name: "test_tool".to_string(), + title: None, description: Some("A test tool".to_string()), input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + annotations: None, + icons: Vec::new(), + meta: None, }; let json = serde_json::to_string(&tool).unwrap(); assert!(json.contains("\"name\":\"test_tool\"")); @@ -229,6 +234,39 @@ fn test_mcp_tool_without_description() { assert!(tool.description.is_none()); } +#[test] +fn test_mcp_tool_preserves_output_schema_annotations_icons_and_meta() { + let json = serde_json::json!({ + "name": "ocr_extract", + "title": "Extract image text", + "description": "Extract OCR text", + "inputSchema": {"type": "object"}, + "outputSchema": { + "type": "object", + "required": ["text"], + "properties": {"text": {"type": "string"}} + }, + "annotations": { + "readOnlyHint": true, + "idempotentHint": true, + "openWorldHint": true, + "x-a3s-risk": "submit" + }, + "icons": [{"src": "data:image/png;base64,AA==", "mimeType": "image/png"}], + "_meta": {"provider": "vision"} + }); + let tool: McpTool = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(tool.title.as_deref(), Some("Extract image text")); + assert_eq!(tool.output_schema.as_ref().unwrap()["required"][0], "text"); + let annotations = tool.annotations.as_ref().unwrap(); + assert_eq!(annotations.read_only_hint, Some(true)); + assert_eq!(annotations.open_world_hint, Some(true)); + assert_eq!(annotations.additional["x-a3s-risk"], "submit"); + assert_eq!(tool.icons.len(), 1); + assert_eq!(tool.meta.as_ref().unwrap()["provider"], "vision"); + assert_eq!(serde_json::to_value(tool).unwrap(), json); +} + #[test] fn test_mcp_resource_serialize() { let resource = McpResource { @@ -310,6 +348,7 @@ fn test_call_tool_result_serialization() { text: "Result".to_string(), }], is_error: false, + ..CallToolResult::default() }; let json = serde_json::to_string(&result).unwrap(); assert!(json.contains("\"content\"")); @@ -323,6 +362,7 @@ fn test_call_tool_result_error_flag() { text: "Error occurred".to_string(), }], is_error: true, + ..CallToolResult::default() }; assert!(result.is_error); } @@ -334,6 +374,26 @@ fn test_call_tool_result_default() { assert!(!result.is_error); } +#[test] +fn test_call_tool_result_preserves_structured_content_and_meta() { + let value = serde_json::json!({ + "content": [{"type": "text", "text": "{\"text\":\"A3S\"}"}], + "structuredContent": { + "text": "A3S", + "source": {"sha256": "abc"} + }, + "isError": false, + "_meta": {"requestId": "ocr-1"} + }); + let result: CallToolResult = serde_json::from_value(value.clone()).unwrap(); + assert_eq!( + result.structured_content.as_ref().unwrap()["source"]["sha256"], + "abc" + ); + assert_eq!(result.meta.as_ref().unwrap()["requestId"], "ocr-1"); + assert_eq!(serde_json::to_value(result).unwrap(), value); +} + #[test] fn test_read_resource_params_serialization() { let params = ReadResourceParams { @@ -363,8 +423,13 @@ fn test_list_tools_result_serialization() { let result = ListToolsResult { tools: vec![McpTool { name: "tool1".to_string(), + title: None, description: None, input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + annotations: None, + icons: Vec::new(), + meta: None, }], }; let json = serde_json::to_string(&result).unwrap(); diff --git a/core/src/mcp/result.rs b/core/src/mcp/result.rs new file mode 100644 index 00000000..dc6d1ac4 --- /dev/null +++ b/core/src/mcp/result.rs @@ -0,0 +1,583 @@ +//! Loss-aware projection of MCP tool results into A3S Code tool output. + +use crate::llm::Attachment; +use crate::mcp::protocol::{CallToolResult, ResourceContent, ToolContent}; +use crate::tools::{ToolContext, ToolOutput}; +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; + +const MAX_CONTENT_ITEMS: usize = 64; +const MAX_ARTIFACT_BYTES: usize = 16 * 1024 * 1024; +const MAX_TOTAL_ARTIFACT_BYTES: usize = 32 * 1024 * 1024; +const MAX_STRUCTURED_CONTENT_BYTES: usize = 4 * 1024 * 1024; +const MAX_PROTOCOL_META_BYTES: usize = 1024 * 1024; +const MAX_CACHED_ARTIFACTS_PER_TOOL: usize = 64; +const MAX_CACHED_ARTIFACT_BYTES_PER_TOOL: u64 = 128 * 1024 * 1024; + +struct ToolResultProjection { + text_parts: Vec, + images: Vec, + artifacts: Vec, + content: Vec, + decoded_bytes: usize, +} + +impl ToolResultProjection { + fn new(content_capacity: usize) -> Self { + Self { + text_parts: Vec::new(), + images: Vec::new(), + artifacts: Vec::new(), + content: Vec::with_capacity(content_capacity), + decoded_bytes: 0, + } + } + + async fn project_resource( + &mut self, + tool_name: &str, + resource: &ResourceContent, + context: &ToolContext, + ) -> Result<()> { + let mut descriptor = json!({ + "type": "resource", + "uri": resource.uri, + "mimeType": resource.mime_type, + "hasText": resource.text.is_some(), + "hasBlob": resource.blob.is_some(), + }); + + if let Some(text) = &resource.text { + self.text_parts.push(text.clone()); + descriptor["textBytes"] = json!(text.len()); + } + if let Some(blob) = &resource.blob { + let bytes = decode_bounded(blob, &mut self.decoded_bytes)?; + let mime_type = resource + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"); + let artifact = + materialize_artifact(tool_name, Some(&resource.uri), mime_type, &bytes, context) + .await?; + self.text_parts.push(format!( + "[Resource: {}, {mime_type}, {} bytes, artifact: {}]", + resource.uri, + bytes.len(), + artifact.path.display() + )); + if model_image_mime_type(mime_type) { + self.images + .push(Attachment::new(bytes.clone(), mime_type.to_string())); + } + descriptor["blobBytes"] = json!(bytes.len()); + descriptor["sha256"] = json!(artifact.sha256.clone()); + descriptor["path"] = json!(artifact.path.clone()); + descriptor["attachedToModel"] = json!(model_image_mime_type(mime_type)); + self.artifacts + .push(artifact.value("resource", Some(&resource.uri))); + } else if resource.text.is_none() { + self.text_parts + .push(format!("[Resource: {}]", resource.uri)); + } + self.content.push(descriptor); + Ok(()) + } +} + +/// Convert an MCP result to model-visible text without discarding structured +/// content, decoded images, embedded resources, or protocol metadata. +pub(crate) async fn project_tool_result( + tool_name: &str, + result: &CallToolResult, + context: &ToolContext, +) -> Result { + if result.content.len() > MAX_CONTENT_ITEMS { + return Err(anyhow!( + "MCP tool returned {} content items; the limit is {}", + result.content.len(), + MAX_CONTENT_ITEMS + )); + } + validate_json_size( + "structuredContent", + result.structured_content.as_ref(), + MAX_STRUCTURED_CONTENT_BYTES, + )?; + validate_json_size("_meta", result.meta.as_ref(), MAX_PROTOCOL_META_BYTES)?; + + let mut projection = ToolResultProjection::new(result.content.len()); + + for item in &result.content { + match item { + ToolContent::Text { text } => { + projection.text_parts.push(text.clone()); + projection.content.push(json!({ + "type": "text", + "bytes": text.len(), + })); + } + ToolContent::Image { data, mime_type } => { + let bytes = decode_bounded(data, &mut projection.decoded_bytes)?; + let artifact = + materialize_artifact(tool_name, None, mime_type, &bytes, context).await?; + projection.text_parts.push(format!( + "[Image: {mime_type}, {} bytes, artifact: {}]", + bytes.len(), + artifact.path.display() + )); + if model_image_mime_type(mime_type) { + projection + .images + .push(Attachment::new(bytes.clone(), mime_type.clone())); + } + projection.content.push(json!({ + "type": "image", + "mimeType": mime_type, + "bytes": bytes.len(), + "sha256": artifact.sha256.clone(), + "path": artifact.path.clone(), + "attachedToModel": model_image_mime_type(mime_type), + })); + projection.artifacts.push(artifact.value("image", None)); + } + ToolContent::Resource { resource } => { + projection + .project_resource(tool_name, resource, context) + .await?; + } + } + } + + let ToolResultProjection { + mut text_parts, + images, + artifacts, + content, + .. + } = projection; + if text_parts.is_empty() { + if let Some(structured) = &result.structured_content { + text_parts.push( + serde_json::to_string_pretty(structured) + .context("failed to format MCP structured content")?, + ); + } + } + + let mut metadata = serde_json::Map::new(); + if let Some(structured) = &result.structured_content { + metadata.insert("structuredContent".to_string(), structured.clone()); + } + if let Some(meta) = &result.meta { + metadata.insert("_meta".to_string(), meta.clone()); + } + if !content.is_empty() { + metadata.insert("content".to_string(), Value::Array(content)); + } + if !artifacts.is_empty() { + metadata.insert("artifacts".to_string(), Value::Array(artifacts)); + } + metadata.insert("isError".to_string(), Value::Bool(result.is_error)); + + let text = text_parts.join("\n"); + let output = if result.is_error { + ToolOutput::error(text) + } else { + ToolOutput::success(text) + } + .with_metadata(json!({ "mcp": Value::Object(metadata) })) + .with_images(images); + Ok(output) +} + +fn decode_bounded(data: &str, decoded_total: &mut usize) -> Result> { + let max_encoded = MAX_ARTIFACT_BYTES.div_ceil(3) * 4 + 4; + if data.len() > max_encoded { + return Err(anyhow!( + "MCP base64 artifact exceeds the {} MiB per-item limit", + MAX_ARTIFACT_BYTES / (1024 * 1024) + )); + } + let bytes = BASE64_STANDARD + .decode(data) + .context("MCP tool returned invalid base64 artifact data")?; + if bytes.len() > MAX_ARTIFACT_BYTES { + return Err(anyhow!( + "MCP decoded artifact exceeds the {} MiB per-item limit", + MAX_ARTIFACT_BYTES / (1024 * 1024) + )); + } + *decoded_total = decoded_total + .checked_add(bytes.len()) + .ok_or_else(|| anyhow!("MCP artifact byte count overflowed"))?; + if *decoded_total > MAX_TOTAL_ARTIFACT_BYTES { + return Err(anyhow!( + "MCP decoded artifacts exceed the {} MiB per-call limit", + MAX_TOTAL_ARTIFACT_BYTES / (1024 * 1024) + )); + } + Ok(bytes) +} + +fn validate_json_size(label: &str, value: Option<&Value>, limit: usize) -> Result<()> { + let Some(value) = value else { + return Ok(()); + }; + let bytes = serde_json::to_vec(value) + .with_context(|| format!("failed to encode MCP {label} for bounded projection"))?; + if bytes.len() > limit { + return Err(anyhow!( + "MCP {label} exceeds the {} MiB projection limit", + limit / (1024 * 1024) + )); + } + Ok(()) +} + +struct MaterializedArtifact { + path: PathBuf, + media_type: String, + size: usize, + sha256: String, +} + +impl MaterializedArtifact { + fn value(&self, kind: &str, source_uri: Option<&str>) -> Value { + json!({ + "kind": kind, + "path": self.path, + "mediaType": self.media_type, + "size": self.size, + "sha256": self.sha256, + "sourceUri": source_uri, + }) + } +} + +async fn materialize_artifact( + tool_name: &str, + _source_uri: Option<&str>, + media_type: &str, + bytes: &[u8], + context: &ToolContext, +) -> Result { + let digest = sha256::digest(bytes); + let session = context.session_id.as_deref().unwrap_or("anonymous"); + let root = artifact_root(context) + .join(sanitize_segment(session)) + .join(sanitize_segment(tool_name)); + tokio::fs::create_dir_all(&root).await.with_context(|| { + format!( + "failed to create MCP artifact directory '{}'", + root.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)) + .await + .with_context(|| { + format!( + "failed to secure MCP artifact directory '{}'", + root.display() + ) + })?; + } + let path = root.join(format!("{digest}.{}", extension_for_media_type(media_type))); + match tokio::fs::symlink_metadata(&path).await { + Ok(_) => verify_existing_artifact(&path, bytes, &digest).await?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + write_private_file(&path, bytes, &digest).await?; + } + Err(error) => { + return Err(error) + .with_context(|| format!("failed to inspect MCP artifact '{}'", path.display())); + } + } + prune_artifact_directory(&root, &path).await; + Ok(MaterializedArtifact { + path, + media_type: media_type.to_string(), + size: bytes.len(), + sha256: digest, + }) +} + +async fn prune_artifact_directory(directory: &Path, current: &Path) { + let Ok(mut entries) = tokio::fs::read_dir(directory).await else { + return; + }; + let mut files = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(metadata) = entry.metadata().await else { + continue; + }; + if !metadata.is_file() { + continue; + } + let modified = metadata + .modified() + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + files.push((entry.path(), metadata.len(), modified)); + } + files.sort_by_key(|(_, _, modified)| *modified); + let mut count = files.len(); + let mut total = files + .iter() + .fold(0_u64, |sum, (_, size, _)| sum.saturating_add(*size)); + for (path, size, _) in files { + if count <= MAX_CACHED_ARTIFACTS_PER_TOOL && total <= MAX_CACHED_ARTIFACT_BYTES_PER_TOOL { + break; + } + if path == current { + continue; + } + if tokio::fs::remove_file(&path).await.is_ok() { + count = count.saturating_sub(1); + total = total.saturating_sub(size); + } + } +} + +fn artifact_root(context: &ToolContext) -> PathBuf { + #[cfg(test)] + { + context.workspace.join(".a3s-test-mcp-artifacts") + } + #[cfg(not(test))] + { + let _ = context; + dirs::cache_dir() + .unwrap_or_else(std::env::temp_dir) + .join("a3s-code") + .join("mcp-artifacts") + } +} + +async fn verify_existing_artifact(path: &Path, bytes: &[u8], digest: &str) -> Result<()> { + let metadata = tokio::fs::symlink_metadata(path) + .await + .with_context(|| format!("failed to inspect MCP artifact '{}'", path.display()))?; + if !metadata.file_type().is_file() { + return Err(anyhow!( + "existing MCP artifact '{}' is not a regular file", + path.display() + )); + } + let existing = tokio::fs::read(path) + .await + .with_context(|| format!("failed to verify MCP artifact '{}'", path.display()))?; + if existing.len() != bytes.len() || sha256::digest(&existing) != digest { + return Err(anyhow!( + "existing MCP artifact '{}' does not match its content digest", + path.display() + )); + } + Ok(()) +} + +async fn write_private_file(path: &Path, bytes: &[u8], digest: &str) -> Result<()> { + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + match options.open(path).await { + Ok(mut file) => { + if let Err(error) = file.write_all(bytes).await { + drop(file); + let _ = tokio::fs::remove_file(path).await; + return Err(error) + .with_context(|| format!("failed to write MCP artifact '{}'", path.display())); + } + if let Err(error) = file.flush().await { + drop(file); + let _ = tokio::fs::remove_file(path).await; + return Err(error) + .with_context(|| format!("failed to flush MCP artifact '{}'", path.display())); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .await + .with_context(|| { + format!("failed to secure MCP artifact '{}'", path.display()) + })?; + } + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + verify_existing_artifact(path, bytes, digest).await + } + Err(error) => Err(error) + .with_context(|| format!("failed to create MCP artifact '{}'", path.display())), + } +} + +fn sanitize_segment(value: &str) -> String { + let sanitized = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .take(96) + .collect::(); + if sanitized.is_empty() { + "unknown".to_string() + } else { + sanitized + } +} + +fn extension_for_media_type(media_type: &str) -> &'static str { + match media_type.split(';').next().unwrap_or(media_type).trim() { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + "application/pdf" => "pdf", + "application/json" => "json", + "text/plain" => "txt", + "text/markdown" => "md", + _ => "bin", + } +} + +fn model_image_mime_type(media_type: &str) -> bool { + matches!( + media_type.split(';').next().unwrap_or(media_type).trim(), + "image/png" | "image/jpeg" | "image/gif" | "image/webp" + ) +} + +/// Convert MCP tool result to a compact text representation. +/// +/// Runtime tool execution uses [`project_tool_result`] so image bytes and +/// structured data are retained. This helper remains for diagnostics and +/// compatibility callers that explicitly need text only. +pub fn tool_result_to_string(result: &CallToolResult) -> String { + let mut output = Vec::new(); + for content in &result.content { + match content { + ToolContent::Text { text } => output.push(text.clone()), + ToolContent::Image { mime_type, .. } => { + output.push(format!("[Image: {mime_type}]")); + } + ToolContent::Resource { resource } => { + if let Some(text) = &resource.text { + output.push(text.clone()); + } + if resource.blob.is_some() || resource.text.is_none() { + output.push(format!("[Resource: {}]", resource.uri)); + } + } + } + } + if output.is_empty() { + if let Some(structured) = &result.structured_content { + return serde_json::to_string_pretty(structured) + .unwrap_or_else(|_| structured.to_string()); + } + } + output.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::protocol::CallToolResult; + + #[tokio::test] + async fn projects_structured_content_and_materializes_an_image() { + let temp = tempfile::tempdir().unwrap(); + let context = + ToolContext::new(temp.path().to_path_buf()).with_session_id("mcp-result-test"); + let png = b"\x89PNG\r\n\x1a\nfixture"; + let result = CallToolResult { + content: vec![ToolContent::Image { + data: BASE64_STANDARD.encode(png), + mime_type: "image/png".to_string(), + }], + structured_content: Some(json!({"text": "A3S"})), + is_error: false, + meta: Some(json!({"requestId": "one"})), + }; + + let output = project_tool_result("mcp__use_ocr__ocr_extract", &result, &context) + .await + .unwrap(); + assert!(output.success); + assert_eq!(output.images.len(), 1); + assert_eq!(output.images[0].data, png); + assert_eq!( + output.metadata.as_ref().unwrap()["mcp"]["structuredContent"]["text"], + "A3S" + ); + assert_eq!( + output.metadata.as_ref().unwrap()["mcp"]["_meta"]["requestId"], + "one" + ); + let path = output.metadata.as_ref().unwrap()["mcp"]["artifacts"][0]["path"] + .as_str() + .unwrap(); + assert_eq!(std::fs::read(path).unwrap(), png); + } + + #[tokio::test] + async fn rejects_oversized_or_invalid_base64_without_losing_error_status() { + let temp = tempfile::tempdir().unwrap(); + let context = ToolContext::new(temp.path().to_path_buf()); + let invalid = CallToolResult { + content: vec![ToolContent::Image { + data: "***".to_string(), + mime_type: "image/png".to_string(), + }], + ..CallToolResult::default() + }; + assert!(project_tool_result("mcp__test__image", &invalid, &context) + .await + .is_err()); + } + + #[tokio::test] + async fn rejects_a_preexisting_artifact_that_does_not_match_its_digest() { + let temp = tempfile::tempdir().unwrap(); + let context = ToolContext::new(temp.path().to_path_buf()); + let png = b"\x89PNG\r\n\x1a\nexpected"; + let digest = sha256::digest(png); + let root = artifact_root(&context) + .join("anonymous") + .join("mcp__test__image"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join(format!("{digest}.png")), b"different") + .await + .unwrap(); + let result = CallToolResult { + content: vec![ToolContent::Image { + data: BASE64_STANDARD.encode(png), + mime_type: "image/png".to_string(), + }], + ..CallToolResult::default() + }; + + let error = project_tool_result("mcp__test__image", &result, &context) + .await + .expect_err("content-addressed artifacts must reject a conflicting file"); + assert!( + error + .to_string() + .contains("does not match its content digest"), + "{error:#}" + ); + } +} diff --git a/core/src/mcp/tools.rs b/core/src/mcp/tools.rs index 8a65bb0d..62f0b7b5 100644 --- a/core/src/mcp/tools.rs +++ b/core/src/mcp/tools.rs @@ -2,8 +2,9 @@ //! //! Integrates MCP tools with the A3S Code tool system. -use crate::mcp::manager::{tool_result_to_string, McpManager}; +use crate::mcp::manager::McpManager; use crate::mcp::protocol::McpTool; +use crate::mcp::result::project_tool_result; use crate::tools::{Tool, ToolContext, ToolOutput}; use anyhow::Result; use async_trait::async_trait; @@ -44,6 +45,54 @@ impl McpToolWrapper { } } +fn annotation_requires_confirmation(tool: &McpTool) -> bool { + let Some(annotations) = tool.annotations.as_ref() else { + // Missing behavior metadata is unknown, not read-only. + return true; + }; + + if annotations.destructive_hint == Some(true) + || annotations.read_only_hint != Some(true) + || annotations.open_world_hint != Some(false) + { + return true; + } + + annotations + .additional + .iter() + .any(|(key, value)| custom_risk_requires_confirmation(key, value)) + || tool.meta.as_ref().is_some_and(|meta| { + meta.as_object().is_some_and(|fields| { + fields + .iter() + .any(|(key, value)| custom_risk_requires_confirmation(key, value)) + }) + }) +} + +fn custom_risk_requires_confirmation(key: &str, value: &serde_json::Value) -> bool { + let key = key.to_ascii_lowercase(); + if !matches!( + key.as_str(), + "x-a3s-risk" | "a3s/risk" | "a3s.risk" | "risk" + ) { + return false; + } + + match value { + serde_json::Value::String(value) => !matches!( + value.trim().to_ascii_lowercase().as_str(), + "read" | "read_only" | "read-only" | "routine" | "closed_world_read" + ), + serde_json::Value::Array(values) => values + .iter() + .any(|value| custom_risk_requires_confirmation(key.as_str(), value)), + // A declared but malformed risk value cannot reduce confirmation. + _ => true, + } +} + #[async_trait] impl Tool for McpToolWrapper { fn name(&self) -> &str { @@ -58,7 +107,11 @@ impl Tool for McpToolWrapper { self.mcp_tool.input_schema.clone() } - async fn execute(&self, args: &serde_json::Value, _ctx: &ToolContext) -> Result { + fn requires_confirmation(&self, _args: &serde_json::Value) -> bool { + annotation_requires_confirmation(&self.mcp_tool) + } + + async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result { // Call the MCP tool through the manager let result = self .manager @@ -66,14 +119,7 @@ impl Tool for McpToolWrapper { .await; match result { - Ok(tool_result) => { - let output = tool_result_to_string(&tool_result); - if tool_result.is_error { - Ok(ToolOutput::error(output)) - } else { - Ok(ToolOutput::success(output)) - } - } + Ok(tool_result) => project_tool_result(&self.full_name, &tool_result, ctx).await, Err(e) => Ok(ToolOutput::error(format!("MCP tool error: {}", e))), } } @@ -100,12 +146,16 @@ pub fn create_mcp_tools( #[cfg(test)] mod tests { use super::*; + use crate::mcp::protocol::McpToolAnnotations; + use crate::tools::Tool; + use std::collections::HashMap; #[test] fn test_mcp_tool_wrapper_name() { let manager = Arc::new(McpManager::new()); let mcp_tool = McpTool { name: "create_issue".to_string(), + title: None, description: Some("Create a GitHub issue".to_string()), input_schema: serde_json::json!({ "type": "object", @@ -113,6 +163,10 @@ mod tests { "title": {"type": "string"} } }), + output_schema: None, + annotations: None, + icons: Vec::new(), + meta: None, }; let wrapper = McpToolWrapper::new("github".to_string(), mcp_tool, manager); @@ -129,13 +183,23 @@ mod tests { let tools = vec![ McpTool { name: "tool1".to_string(), + title: None, description: Some("Tool 1".to_string()), input_schema: serde_json::json!({}), + output_schema: None, + annotations: None, + icons: Vec::new(), + meta: None, }, McpTool { name: "tool2".to_string(), + title: None, description: Some("Tool 2".to_string()), input_schema: serde_json::json!({}), + output_schema: None, + annotations: None, + icons: Vec::new(), + meta: None, }, ]; @@ -145,4 +209,70 @@ mod tests { assert_eq!(wrappers[0].name(), "mcp__test__tool1"); assert_eq!(wrappers[1].name(), "mcp__test__tool2"); } + + fn annotated_tool(annotations: Option) -> McpTool { + McpTool { + name: "fixture".to_string(), + title: None, + description: None, + input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + annotations, + icons: Vec::new(), + meta: None, + } + } + + #[test] + fn closed_world_read_only_annotation_does_not_escalate_confirmation() { + let manager = Arc::new(McpManager::new()); + let wrapper = McpToolWrapper::new( + "use_fixture".to_string(), + annotated_tool(Some(McpToolAnnotations { + read_only_hint: Some(true), + destructive_hint: Some(false), + idempotent_hint: Some(true), + open_world_hint: Some(false), + ..Default::default() + })), + manager, + ); + + assert!(!wrapper.requires_confirmation(&serde_json::json!({}))); + } + + #[test] + fn unknown_open_world_mutating_and_submit_tools_escalate_confirmation() { + let cases = [ + None, + Some(McpToolAnnotations { + read_only_hint: Some(true), + open_world_hint: Some(true), + ..Default::default() + }), + Some(McpToolAnnotations { + read_only_hint: Some(false), + open_world_hint: Some(false), + ..Default::default() + }), + Some(McpToolAnnotations { + read_only_hint: Some(true), + open_world_hint: Some(false), + additional: HashMap::from([( + "x-a3s-risk".to_string(), + serde_json::json!("submit"), + )]), + ..Default::default() + }), + ]; + + for annotations in cases { + let wrapper = McpToolWrapper::new( + "use_fixture".to_string(), + annotated_tool(annotations), + Arc::new(McpManager::new()), + ); + assert!(wrapper.requires_confirmation(&serde_json::json!({}))); + } + } } diff --git a/core/src/permissions/policy.rs b/core/src/permissions/policy.rs index 9f79ff5c..80dd99e1 100644 --- a/core/src/permissions/policy.rs +++ b/core/src/permissions/policy.rs @@ -187,6 +187,20 @@ impl PermissionPolicy { result } + + /// Whether this policy explicitly declares that a tool may be considered + /// through an Allow or Ask rule. + /// + /// This ignores argument patterns and does not authorize execution. It is + /// used when composing parent and delegated-worker visibility: an explicit + /// worker capability can override a parent host's ordinary model hiding, + /// while execution-time checks from both scopes remain authoritative. + pub fn declares_tool_access(&self, tool_name: &str) -> bool { + self.allow + .iter() + .chain(&self.ask) + .any(|rule| rule.matches_tool(tool_name)) + } } impl PermissionChecker for PermissionPolicy { @@ -214,10 +228,7 @@ impl PermissionChecker for PermissionPolicy { // at least one Allow or Ask rule can match. Argument patterns are // intentionally ignored here; execution-time checks remain // authoritative for the actual arguments. - self.allow - .iter() - .chain(&self.ask) - .any(|rule| rule.matches_tool(tool_name)) + self.declares_tool_access(tool_name) } fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision { diff --git a/core/src/safety_gate.rs b/core/src/safety_gate.rs index 79de027a..ddcadfbd 100644 --- a/core/src/safety_gate.rs +++ b/core/src/safety_gate.rs @@ -63,6 +63,8 @@ pub(crate) struct ToolGateInput<'a> { pub(crate) tool_name: &'a str, pub(crate) args: &'a serde_json::Value, pub(crate) pre_tool_block: Option, + /// Escalation-only requirement supplied by the registered tool. + pub(crate) tool_requires_confirmation: bool, } pub(crate) struct ToolSafetyGate<'a> { @@ -96,6 +98,9 @@ impl<'a> ToolSafetyGate<'a> { event_reason: "Blocked by deny rule in permission policy".to_string(), reason: ToolGateDenial::PermissionDeny, }, + PermissionDecision::Allow if input.tool_requires_confirmation => { + self.confirmation_decision(input.tool_name).await + } PermissionDecision::Allow => ToolGateDecision::Execute { reason: ToolGateApproval::PermissionAllow, }, @@ -217,6 +222,7 @@ mod tests { tool_name: "write", args: &json!({"file_path": "x"}), pre_tool_block: None, + tool_requires_confirmation: false, }) .await; @@ -243,6 +249,7 @@ mod tests { tool_name: "write", args: &json!({"file_path": "x"}), pre_tool_block: None, + tool_requires_confirmation: false, }) .await; @@ -269,6 +276,7 @@ mod tests { tool_name: "write", args: &json!({"file_path": "x"}), pre_tool_block: None, + tool_requires_confirmation: false, }) .await; @@ -295,6 +303,7 @@ mod tests { tool_name: "write", args: &json!({"file_path": "x"}), pre_tool_block: None, + tool_requires_confirmation: false, }) .await; @@ -320,6 +329,7 @@ mod tests { tool_name: "bash", args: &json!({"command": "echo ok"}), pre_tool_block: Some("blocked by policy".to_string()), + tool_requires_confirmation: false, }) .await; @@ -347,6 +357,7 @@ mod tests { tool_name: "bash", args: &json!({"command": "echo ok"}), pre_tool_block: None, + tool_requires_confirmation: false, }) .await; @@ -378,6 +389,7 @@ mod tests { tool_name: "bash", args: &json!({"command": "echo ok"}), pre_tool_block: None, + tool_requires_confirmation: false, }) .await; @@ -390,6 +402,91 @@ mod tests { ); } + #[tokio::test] + async fn tool_requirement_escalates_permission_allow_to_confirmation() { + let (event_tx, _) = broadcast::channel(8); + let manager = Arc::new(ConfirmationManager::new( + ConfirmationPolicy::enabled().with_timeout(2468, crate::hitl::TimeoutAction::Reject), + event_tx, + )); + let config = AgentConfig { + permission_checker: Some(Arc::new(StaticPermission(PermissionDecision::Allow))), + confirmation_manager: Some(manager), + ..Default::default() + }; + let gate = ToolSafetyGate::new(&config); + + let decision = gate + .decide(ToolGateInput { + tool_name: "mcp__use_fixture__submit", + args: &json!({}), + pre_tool_block: None, + tool_requires_confirmation: true, + }) + .await; + + assert_eq!( + decision, + ToolGateDecision::Confirm { + timeout_ms: 2468, + timeout_action: crate::hitl::TimeoutAction::Reject, + } + ); + } + + #[tokio::test] + async fn escalated_allow_without_confirmation_manager_fails_closed() { + let config = AgentConfig { + permission_checker: Some(Arc::new(StaticPermission(PermissionDecision::Allow))), + confirmation_manager: None, + ..Default::default() + }; + let gate = ToolSafetyGate::new(&config); + + let decision = gate + .decide(ToolGateInput { + tool_name: "mcp__use_ocr__ocr_extract", + args: &json!({"file": "scan.png"}), + pre_tool_block: None, + tool_requires_confirmation: true, + }) + .await; + + assert!(matches!( + decision, + ToolGateDecision::Deny { + reason: ToolGateDenial::MissingConfirmationManager, + .. + } + )); + } + + #[tokio::test] + async fn tool_requirement_never_weakens_permission_deny() { + let config = AgentConfig { + permission_checker: Some(Arc::new(StaticPermission(PermissionDecision::Deny))), + ..Default::default() + }; + let gate = ToolSafetyGate::new(&config); + + let decision = gate + .decide(ToolGateInput { + tool_name: "mcp__use_fixture__read", + args: &json!({}), + pre_tool_block: None, + tool_requires_confirmation: false, + }) + .await; + + assert!(matches!( + decision, + ToolGateDecision::Deny { + reason: ToolGateDenial::PermissionDeny, + .. + } + )); + } + #[tokio::test] async fn yolo_lane_executes_without_confirmation() { let (event_tx, _) = broadcast::channel(8); @@ -409,6 +506,7 @@ mod tests { tool_name: "read", args: &json!({"file_path": "README.md"}), pre_tool_block: None, + tool_requires_confirmation: false, }) .await; diff --git a/core/src/tools/registry.rs b/core/src/tools/registry.rs index fba62a20..3373dba5 100644 --- a/core/src/tools/registry.rs +++ b/core/src/tools/registry.rs @@ -218,6 +218,11 @@ impl ToolRegistry { self.get(name).map(|tool| tool.capabilities(args)) } + pub(crate) fn requires_confirmation(&self, name: &str, args: &serde_json::Value) -> bool { + self.get(name) + .is_some_and(|tool| tool.requires_confirmation(args)) + } + /// Check if a tool exists pub fn contains(&self, name: &str) -> bool { let tools = self.tools.read().unwrap(); diff --git a/core/src/tools/types.rs b/core/src/tools/types.rs index a3416992..fafe3d51 100644 --- a/core/src/tools/types.rs +++ b/core/src/tools/types.rs @@ -663,6 +663,16 @@ pub trait Tool: Send + Sync { ToolCapabilities::conservative() } + /// Whether tool-owned metadata requires this invocation to pass through + /// HITL even when the host permission policy would otherwise allow it. + /// + /// This is an escalation-only hint. The invocation gateway still applies + /// the host permission policy first, so a tool can never use this hook to + /// weaken an `Ask` or `Deny` decision. + fn requires_confirmation(&self, _args: &serde_json::Value) -> bool { + false + } + /// Execute the tool with given arguments async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result; }