diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e29880b0..4e55db9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,10 +55,10 @@ jobs: test -n "$VERSION" bash check-version.sh "$VERSION" - - name: Check public API compatibility with v5.3.4 + - name: Check public API compatibility with v5.3.5 run: | cargo install cargo-semver-checks --version 0.48.0 --locked - bash scripts/check_semver.sh 5.3.4 + bash scripts/check_semver.sh 5.3.5 - name: Check SDK protocol and API alignment run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e01a17aa..881ffbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.3.6] - 2026-07-19 + +### Added + +- Expanded TypeScript code-intelligence discovery for nested monorepos, + hoisted and Yarn SDKs, classic `tsserver`, and the TypeScript 7 native LSP. +- Added bounded PDF text extraction to `web_fetch`, including media/signature + detection, normalized metadata, malformed-document errors, and image-only + document handling. +- Added an invariant-checked session snapshot fork operation that rebinds the + session, workspace, run ownership, and subagent parent ownership while + preserving the complete persisted generation. +- 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. + +### Fixed + +- Kept standalone conversational greetings tool-free and prevented them from + triggering synthetic continuation turns, while retaining the normal tool + surface for greetings that also contain an action request. + +### 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/Cargo.lock b/Cargo.lock index b53abff7..222bb06c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ source = "git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ac [[package]] name = "a3s-code-core" -version = "5.3.5" +version = "5.3.6" dependencies = [ "a3s-acl 0.2.1 (git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22)", "a3s-common", @@ -234,6 +234,17 @@ dependencies = [ "pom", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -1030,6 +1041,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -1117,6 +1137,15 @@ dependencies = [ "either", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.62" @@ -1138,6 +1167,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "cff-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5810ca1a2b5870df2aab1c03e11c40c361ba51d6e3e361e56310f1cb3b4e087" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1150,6 +1185,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chromiumoxide" version = "0.7.0" @@ -1232,6 +1278,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -1550,6 +1606,37 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1711,6 +1798,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "ego-tree" version = "0.10.0" @@ -2103,10 +2199,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2713,6 +2812,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2740,6 +2849,48 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -2882,21 +3033,32 @@ dependencies = [ [[package]] name = "lopdf" -version = "0.34.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c" dependencies = [ + "aes", + "bitflags 2.11.1", + "cbc", "chrono", + "ecb", "encoding_rs", "flate2", + "getrandom 0.4.2", "indexmap 2.14.0", "itoa", + "jiff", "log", "md-5 0.10.6", - "nom 7.1.3", + "nom 8.0.0", + "rand 0.10.2", "rangemap", "rayon", + "sha2 0.10.9", + "stringprep", + "thiserror 2.0.18", "time", + "ttf-parser", "weezl", ] @@ -3403,13 +3565,15 @@ dependencies = [ [[package]] name = "pdf-extract" -version = "0.7.12" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" +checksum = "417e8fdc940f1d5bc62c5f89864c3a2255f74f69aa353c98509213d67df61e73" dependencies = [ "adobe-cmap-parser", + "cff-parser", "encoding_rs", "euclid", + "log", "lopdf", "postscript", "type1-encoding-parser", @@ -3600,6 +3764,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postscript" version = "0.14.1" @@ -3785,6 +3964,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3823,6 +4013,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rangemap" version = "1.7.1" @@ -4641,6 +4837,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5184,6 +5391,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.23.0" @@ -5217,6 +5430,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -5238,6 +5457,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/README.md b/README.md index 892452ad..382a5396 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,10 @@ object-only backend that cannot execute it. | MCP | `mcp____` | Namespaced tools owned by their source manager | | Dynamic workflows | `dynamic_workflow` | Explicitly registered A3S Flow-backed, replayable per-turn workflows | +Standalone greetings are conversational turns: the model receives no tool +definitions and a friendly response is not converted into a synthetic +continuation. A greeting that also asks for work keeps the normal tool surface. + The built-in skill registry starts empty; skills come from configured directories, `AgentDir`, inline host input, or live registration. The model-visible `program` tool executes JavaScript in QuickJS, not arbitrary diff --git a/core/Cargo.toml b/core/Cargo.toml index 459a6910..4a936540 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-core" -version = "5.3.5" +version = "5.3.6" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -86,8 +86,7 @@ shell-words = "1.1" # HTML to text/markdown conversion html2text = "0.16" htmd = "0.5" -pdf-extract = "0.7" -lopdf = "0.34" +pdf-extract = "0.12" roxmltree = "0.20" zip = { version = "0.6", default-features = false, features = ["deflate"] } cfb = "0.14" @@ -147,6 +146,8 @@ s3 = [ serve = ["dep:cron"] [dev-dependencies] +# Build deterministic PDF fixtures for web_fetch extraction tests. +lopdf = "0.42" # Deterministically advance the exponential stream-retry backoff in async tests. tokio = { version = "1.35", features = ["test-util"] } # HTTP mocking for the RemoteGitBackend (and any future HTTP-backed workspace diff --git a/core/src/agent/completion_runtime.rs b/core/src/agent/completion_runtime.rs index aca3b005..d8610b0e 100644 --- a/core/src/agent/completion_runtime.rs +++ b/core/src/agent/completion_runtime.rs @@ -64,7 +64,9 @@ impl AgentLoop { candidate_text }; - if !force_terminal && self.inject_continuation_if_needed(state, turn, &candidate_text) { + if !force_terminal + && self.inject_continuation_if_needed(state, turn, &candidate_text, effective_prompt) + { return CompletionFlow::Continue; } @@ -152,7 +154,12 @@ impl AgentLoop { state: &mut ExecutionLoopState, turn: usize, candidate_text: &str, + effective_prompt: &str, ) -> bool { + if crate::tools::is_standalone_conversation(effective_prompt) { + return false; + } + let looks_incomplete = Self::looks_incomplete(candidate_text); if looks_incomplete && state.repeated_incomplete_response(candidate_text) { tracing::warn!( diff --git a/core/src/agent/tests.rs b/core/src/agent/tests.rs index ae4a4de8..21d6a03d 100644 --- a/core/src/agent/tests.rs +++ b/core/src/agent/tests.rs @@ -3302,6 +3302,29 @@ async fn test_repeated_incomplete_text_converges_after_one_continuation() { assert_eq!(mock_client.call_count.load(Ordering::SeqCst), 2); } +#[tokio::test] +async fn test_standalone_greeting_does_not_trigger_continuation() { + let greeting = "I'll be happy to help. What would you like to work on?"; + let mock_client = Arc::new(MockLlmClient::new(vec![ + MockLlmClient::text_response(greeting), + MockLlmClient::text_response("This response must not be consumed."), + ])); + let agent = AgentLoop::new( + mock_client.clone(), + Arc::new(ToolExecutor::new("/tmp".to_string())), + test_tool_context(), + AgentConfig { + max_continuation_turns: 20, + max_tool_rounds: 100, + ..Default::default() + }, + ); + + let result = agent.execute(&[], "你好", None).await.unwrap(); + assert_eq!(result.text, greeting); + assert_eq!(mock_client.call_count.load(Ordering::SeqCst), 1); +} + #[tokio::test] async fn test_agent_multiple_tools_single_turn() { // LLM returns 2 tool calls in one response 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/code_intelligence/language_profile.rs b/core/src/code_intelligence/language_profile.rs index 4736accd..30ec706d 100644 --- a/core/src/code_intelligence/language_profile.rs +++ b/core/src/code_intelligence/language_profile.rs @@ -26,6 +26,19 @@ pub(crate) struct LanguageServerCommand { pub(crate) env: BTreeMap, } +/// Process command and initialization payload resolved for one workspace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LanguageServerLaunch { + pub(crate) command: LanguageServerCommand, + pub(crate) initialization_options: Value, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LaunchResolution { + Static, + WorkspaceTypeScript, +} + impl LanguageServerCommand { fn new( program: impl Into, @@ -45,6 +58,7 @@ pub(crate) struct LanguageServerProfile { id: ProjectLanguageProfile, topology: ServerTopology, command: LanguageServerCommand, + launch_resolution: LaunchResolution, initialization_settle_delay: Duration, navigation_settle_delay: Duration, } @@ -55,6 +69,7 @@ impl LanguageServerProfile { id: ProjectLanguageProfile::Rust, topology: ServerTopology::MultiFolder, command: LanguageServerCommand::new(program, std::iter::empty::()), + launch_resolution: LaunchResolution::Static, initialization_settle_delay: DEFAULT_INITIALIZATION_SETTLE_DELAY, navigation_settle_delay: DEFAULT_NAVIGATION_SETTLE_DELAY, } @@ -65,6 +80,7 @@ impl LanguageServerProfile { id: ProjectLanguageProfile::TypeScriptJavaScript, topology: ServerTopology::MultiFolder, command: LanguageServerCommand::new(program, ["--stdio"]), + launch_resolution: LaunchResolution::Static, initialization_settle_delay: DEFAULT_INITIALIZATION_SETTLE_DELAY, navigation_settle_delay: DEFAULT_NAVIGATION_SETTLE_DELAY, } @@ -73,10 +89,16 @@ impl LanguageServerProfile { pub(crate) fn built_in_defaults() -> Vec { vec![ Self::rust("rust-analyzer"), - Self::typescript_javascript("typescript-language-server"), + Self::typescript_javascript("typescript-language-server") + .with_workspace_typescript_resolution(), ] } + fn with_workspace_typescript_resolution(mut self) -> Self { + self.launch_resolution = LaunchResolution::WorkspaceTypeScript; + self + } + pub(crate) fn id(&self) -> ProjectLanguageProfile { self.id } @@ -86,10 +108,34 @@ impl LanguageServerProfile { self.topology } + #[cfg(test)] pub(crate) fn command(&self) -> &LanguageServerCommand { &self.command } + /// Resolve a project-local TypeScript runtime without assuming that a + /// monorepo hoists its SDK to the served workspace root. Classic SDKs are + /// pinned through `tsserver.path`; TypeScript 7+ uses its native LSP mode. + pub(crate) async fn launch( + &self, + canonical_root: &Path, + layout: &ProjectLayout, + ) -> LanguageServerLaunch { + if self.launch_resolution == LaunchResolution::WorkspaceTypeScript { + if let Some(launch) = self + .workspace_typescript_launch(canonical_root, layout) + .await + { + return launch; + } + } + + LanguageServerLaunch { + command: self.command.clone(), + initialization_options: self.initialization_options(canonical_root, layout), + } + } + pub(crate) fn initialization_settle_delay(&self) -> Duration { self.initialization_settle_delay } @@ -203,16 +249,134 @@ impl LanguageServerProfile { }) .collect() } + + async fn workspace_typescript_launch( + &self, + canonical_root: &Path, + layout: &ProjectLayout, + ) -> Option { + for search_root in self.typescript_search_roots(canonical_root, layout) { + for package_root in typescript_package_roots(&search_root) { + let lib = package_root.join("lib"); + let tsserver = lib.join("tsserver.js"); + if is_file(&tsserver).await { + return Some(LanguageServerLaunch { + command: self.command.clone(), + initialization_options: json!({ + "tsserver": { + "path": protocol_path(&tsserver), + }, + }), + }); + } + + let native_entry = lib.join("tsc.js"); + if is_file(&native_entry).await + && typescript_major_version(&package_root) + .await + .is_some_and(|major| major >= 7) + { + return Some(LanguageServerLaunch { + command: LanguageServerCommand::new( + "node", + [ + native_entry.into_os_string(), + OsString::from("--lsp"), + OsString::from("--stdio"), + ], + ), + initialization_options: Value::Null, + }); + } + } + } + None + } + + fn typescript_search_roots( + &self, + canonical_root: &Path, + layout: &ProjectLayout, + ) -> Vec { + let project_directories = self + .project_roots(layout) + .into_iter() + .map(|root| { + if root.is_root() { + canonical_root.to_path_buf() + } else { + canonical_root.join(root.as_str()) + } + }) + .collect::>(); + let mut roots = Vec::new(); + let mut seen = BTreeSet::new(); + + // Prefer each package's own SDK before considering any hoisted SDK. + for directory in &project_directories { + push_unique_path(&mut roots, &mut seen, directory.clone()); + } + for directory in project_directories { + let mut ancestor = directory.parent(); + while let Some(candidate) = ancestor.filter(|path| path.starts_with(canonical_root)) { + push_unique_path(&mut roots, &mut seen, candidate.to_path_buf()); + if candidate == canonical_root { + break; + } + ancestor = candidate.parent(); + } + } + roots + } +} + +fn typescript_package_roots(search_root: &Path) -> [PathBuf; 3] { + [ + search_root.join("node_modules/typescript"), + search_root.join(".yarn/sdks/typescript"), + search_root.join(".vscode/pnpify/typescript"), + ] +} + +fn push_unique_path(target: &mut Vec, seen: &mut BTreeSet, path: PathBuf) { + if seen.insert(path.clone()) { + target.push(path); + } +} + +async fn is_file(path: &Path) -> bool { + tokio::fs::metadata(path) + .await + .is_ok_and(|metadata| metadata.is_file()) +} + +async fn typescript_major_version(package_root: &Path) -> Option { + let package = tokio::fs::read_to_string(package_root.join("package.json")) + .await + .ok()?; + let package: Value = serde_json::from_str(&package).ok()?; + package + .get("version")? + .as_str()? + .split('.') + .next()? + .parse() + .ok() +} + +fn protocol_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") } #[cfg(test)] mod tests { - use super::{LanguageServerProfile, ServerTopology}; + use super::{protocol_path, LanguageServerProfile, ServerTopology}; use crate::code_intelligence::project_layout::ProjectLayoutResolver; use crate::workspace::{ LocalWorkspaceFile, LocalWorkspaceFileStatus, LocalWorkspaceManifestSnapshot, }; - use serde_json::json; + use serde_json::{json, Value}; + use std::ffi::OsString; use std::path::{Path, PathBuf}; fn file(path: &str) -> LocalWorkspaceFile { @@ -257,6 +421,114 @@ mod tests { assert!(web.supports_path(Path::new("src/main.jsx"))); } + #[tokio::test] + async fn built_in_typescript_uses_nested_classic_sdk() { + let workspace = tempfile::tempdir().expect("temporary workspace"); + let package = workspace.path().join("apps/web/node_modules/typescript"); + tokio::fs::create_dir_all(package.join("lib")) + .await + .expect("create TypeScript SDK"); + tokio::fs::write(package.join("lib/tsserver.js"), "") + .await + .expect("write tsserver entry"); + tokio::fs::write(package.join("package.json"), r#"{"version":"5.9.3"}"#) + .await + .expect("write TypeScript package"); + let profile = LanguageServerProfile::built_in_defaults() + .pop() + .expect("TypeScript profile"); + + let launch = profile + .launch( + workspace.path(), + &layout(&["apps/web/package.json", "apps/web/tsconfig.json"]), + ) + .await; + + assert_eq!( + launch.command.program, + PathBuf::from("typescript-language-server") + ); + assert_eq!(launch.command.args, ["--stdio"]); + assert_eq!( + launch.initialization_options, + json!({ + "tsserver": { + "path": protocol_path(&package.join("lib/tsserver.js")), + }, + }) + ); + } + + #[tokio::test] + async fn built_in_typescript_uses_nested_native_sdk() { + let workspace = tempfile::tempdir().expect("temporary workspace"); + let package = workspace.path().join("apps/web/node_modules/typescript"); + tokio::fs::create_dir_all(package.join("lib")) + .await + .expect("create TypeScript SDK"); + tokio::fs::write(package.join("lib/tsc.js"), "") + .await + .expect("write native TypeScript entry"); + tokio::fs::write(package.join("package.json"), r#"{"version":"7.0.2"}"#) + .await + .expect("write TypeScript package"); + let profile = LanguageServerProfile::built_in_defaults() + .pop() + .expect("TypeScript profile"); + + let launch = profile + .launch( + workspace.path(), + &layout(&["apps/web/package.json", "apps/web/tsconfig.json"]), + ) + .await; + + assert_eq!(launch.command.program, PathBuf::from("node")); + assert_eq!( + launch.command.args, + [ + package.join("lib/tsc.js").into_os_string(), + OsString::from("--lsp"), + OsString::from("--stdio"), + ] + ); + assert_eq!(launch.initialization_options, Value::Null); + } + + #[tokio::test] + async fn workspace_typescript_prefers_package_sdk_over_hoisted_sdk() { + let workspace = tempfile::tempdir().expect("temporary workspace"); + let hoisted = workspace.path().join("node_modules/typescript"); + let local = workspace.path().join("packages/ui/node_modules/typescript"); + for package in [&hoisted, &local] { + tokio::fs::create_dir_all(package.join("lib")) + .await + .expect("create TypeScript SDK"); + tokio::fs::write(package.join("lib/tsserver.js"), "") + .await + .expect("write tsserver entry"); + tokio::fs::write(package.join("package.json"), r#"{"version":"5.9.3"}"#) + .await + .expect("write TypeScript package"); + } + let profile = LanguageServerProfile::built_in_defaults() + .pop() + .expect("TypeScript profile"); + + let launch = profile + .launch( + workspace.path(), + &layout(&["apps/web/package.json", "packages/ui/package.json"]), + ) + .await; + + assert_eq!( + launch.initialization_options["tsserver"]["path"], + protocol_path(&local.join("lib/tsserver.js")) + ); + } + #[test] fn project_roots_are_stable_and_deduplicated() { let profile = LanguageServerProfile::typescript_javascript("server"); diff --git a/core/src/code_intelligence/language_runtime.rs b/core/src/code_intelligence/language_runtime.rs index d6a34166..6b7c43ac 100644 --- a/core/src/code_intelligence/language_runtime.rs +++ b/core/src/code_intelligence/language_runtime.rs @@ -167,8 +167,9 @@ impl LanguageRuntime { workspace_folders.clone(), WorkspaceSettings::new(profile.workspace_settings(&canonical_root, &layout)), )); + let launch = profile.launch(&canonical_root, &layout).await; let process = - LspProcess::spawn(profile.command(), &canonical_root, router).map_err(|source| { + LspProcess::spawn(&launch.command, &canonical_root, router).map_err(|source| { LanguageRuntimeError::Process { operation: "start", source, @@ -195,9 +196,8 @@ impl LanguageRuntime { Arc::clone(&diagnostic_updates), )); - let initialization_options = profile.initialization_options(&canonical_root, &layout); let initialization_options = - (!initialization_options.is_null()).then_some(initialization_options); + (!launch.initialization_options.is_null()).then_some(launch.initialization_options); let config = InitializeConfig::new( root_url, workspace_folders, diff --git a/core/src/code_intelligence/language_runtime/diagnostic_runtime.rs b/core/src/code_intelligence/language_runtime/diagnostic_runtime.rs index 01596b54..60b966ba 100644 --- a/core/src/code_intelligence/language_runtime/diagnostic_runtime.rs +++ b/core/src/code_intelligence/language_runtime/diagnostic_runtime.rs @@ -5,10 +5,10 @@ use std::{ }; use lsp_types::{ - DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportResult, - PartialResultParams, PublishDiagnosticsParams, TextDocumentIdentifier, Uri, - WorkDoneProgressParams, + DocumentDiagnosticReport, DocumentDiagnosticReportResult, PartialResultParams, + PublishDiagnosticsParams, TextDocumentIdentifier, Uri, WorkDoneProgressParams, }; +use serde::Serialize; use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; @@ -26,6 +26,23 @@ use crate::workspace::WorkspacePath; const MAX_DIAGNOSTICS_PER_DOCUMENT: usize = 1_000; +/// `lsp_types` 0.97 serializes the optional diagnostic identifiers as JSON +/// `null`, while strict LSP servers require absent optional properties. Keep +/// the typed request shape and omit values that were not negotiated. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DocumentDiagnosticRequestParams { + text_document: TextDocumentIdentifier, + #[serde(skip_serializing_if = "Option::is_none")] + identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + previous_result_id: Option, + #[serde(flatten)] + work_done_progress_params: WorkDoneProgressParams, + #[serde(flatten)] + partial_result_params: PartialResultParams, +} + #[derive(Debug, Default)] pub(super) struct DiagnosticRuntimeState { result_ids: HashMap, @@ -51,7 +68,7 @@ impl LanguageRuntime { .request_typed( "diagnostics", "textDocument/diagnostic", - DocumentDiagnosticParams { + DocumentDiagnosticRequestParams { text_document: TextDocumentIdentifier::new(uri.clone()), identifier: None, previous_result_id: previous_result_id.clone(), diff --git a/core/src/code_intelligence/language_runtime/integration_tests.rs b/core/src/code_intelligence/language_runtime/integration_tests.rs index 76ecac5f..50981c27 100644 --- a/core/src/code_intelligence/language_runtime/integration_tests.rs +++ b/core/src/code_intelligence/language_runtime/integration_tests.rs @@ -162,6 +162,11 @@ async fn saved_document_runtime_completes_a_real_process_protocol_lifecycle() { "protocol log did not contain {method}: {protocol_log}" ); } + assert!( + !protocol_log.contains("\"identifier\":null") + && !protocol_log.contains("\"previousResultId\":null"), + "optional diagnostic parameters must be omitted instead of serialized as null: {protocol_log}" + ); } #[tokio::test] 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/store/session_snapshot.rs b/core/src/store/session_snapshot.rs index 80c83380..97ab5d83 100644 --- a/core/src/store/session_snapshot.rs +++ b/core/src/store/session_snapshot.rs @@ -62,6 +62,39 @@ impl SessionSnapshotV1 { ) } + /// Rebind a complete snapshot to a new session and workspace. + /// + /// Session forks retain historical artifacts, traces, run ids, and child + /// session ids. Top-level run ownership and subagent parent ownership must + /// move with the new session or the aggregate would no longer be loadable. + pub fn fork_for_session( + mut self, + session_id: impl Into, + workspace: impl Into, + ) -> Result { + let source_session_id = self.session.id.clone(); + self.validate_for_session(&source_session_id)?; + + let session_id = session_id.into(); + if session_id.trim().is_empty() { + bail!("forked session id cannot be empty"); + } + + self.session.id = session_id.clone(); + self.session.config.workspace = workspace.into(); + for record in &mut self.run_records { + record.snapshot.session_id.clone_from(&session_id); + } + for task in &mut self.subagent_tasks { + if !task.parent_session_id.is_empty() { + task.parent_session_id.clone_from(&session_id); + } + } + + self.validate_for_session(&session_id)?; + Ok(self) + } + pub fn artifact_store(&self) -> ArtifactStore { artifact_store_from(&self.artifacts) } diff --git a/core/src/store/tests.rs b/core/src/store/tests.rs index 84e6dddf..b82fa3eb 100644 --- a/core/src/store/tests.rs +++ b/core/src/store/tests.rs @@ -155,6 +155,31 @@ async fn create_test_snapshot() -> SessionSnapshotV1 { ) } +#[tokio::test] +async fn snapshot_fork_rebinds_every_top_level_session_owner() { + let mut snapshot = create_test_snapshot().await; + for record in &mut snapshot.run_records { + record.snapshot.session_id = snapshot.session.id.clone(); + } + snapshot.validate_for_session("test-session-1").unwrap(); + + let fork = snapshot + .fork_for_session("fork-session", "/tmp/fork-workspace") + .unwrap(); + + assert_eq!(fork.session.id, "fork-session"); + assert_eq!(fork.session.config.workspace, "/tmp/fork-workspace"); + assert!(fork + .run_records + .iter() + .all(|record| record.snapshot.session_id == "fork-session")); + assert!(fork + .subagent_tasks + .iter() + .all(|task| task.parent_session_id == "fork-session")); + fork.validate_for_session("fork-session").unwrap(); +} + // ======================================================================== // FileSessionStore Tests // ======================================================================== diff --git a/core/src/tools/builtin/web_fetch.rs b/core/src/tools/builtin/web_fetch.rs index 5a076ce6..40c6332e 100644 --- a/core/src/tools/builtin/web_fetch.rs +++ b/core/src/tools/builtin/web_fetch.rs @@ -8,6 +8,8 @@ use reqwest::{header::LOCATION, redirect::Policy, Url}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::time::Duration; +mod pdf; + /// Maximum response size (5MB) const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; /// Maximum number of redirects followed by a single fetch. @@ -26,7 +28,7 @@ impl Tool for WebFetchTool { } fn description(&self) -> &str { - "Fetch content from a URL and convert to text or markdown. Supports HTML to Markdown conversion. 5MB download size limit and capped tool output. Configurable timeout (max 120 seconds)." + "Fetch content from a URL and convert to text or markdown. Supports HTML to Markdown conversion and text extraction from PDF documents. 5MB download size limit and capped tool output. Configurable timeout (max 120 seconds)." } fn parameters(&self) -> serde_json::Value { @@ -186,6 +188,8 @@ impl Tool for WebFetchTool { Ok( ToolOutput::success(range.content).with_metadata(serde_json::json!({ "source_anchors": source_anchors, + "document_kind": page.document_kind, + "content_type": page.content_type, "range": { "offset": offset, "requested_max_chars": requested_max_chars, @@ -266,6 +270,8 @@ fn parse_macos_proxy(text: &str) -> Option { struct FetchedPage { content: String, final_url: Url, + document_kind: &'static str, + content_type: String, } /// Fetch a URL while validating and pinning DNS results for every redirect hop. @@ -348,9 +354,22 @@ async fn fetch_url( bytes.extend_from_slice(&chunk); } - let body = String::from_utf8_lossy(&bytes).to_string(); - if content_type.contains("text/html") { - if let Some(location) = html_refresh_location(&body) { + let is_pdf = pdf::response_is_pdf(&content_type, &bytes); + let is_html = !is_pdf + && content_type + .split(';') + .next() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("text/html")); + let document_kind = if is_pdf { + "pdf" + } else if is_html { + "html" + } else { + "text" + }; + let html_body = is_html.then(|| String::from_utf8_lossy(&bytes).into_owned()); + if let Some(body) = html_body.as_deref() { + if let Some(location) = html_refresh_location(body) { if redirect_count == MAX_REDIRECTS { return Err(format!( "Too many redirects while fetching URL (max: {})", @@ -361,21 +380,33 @@ async fn fetch_url( continue; } } - let body = if body_only && content_type.contains("text/html") { - extract_html_body(&body).unwrap_or(body) + let body = if is_pdf { + pdf::extract_text(bytes).await? } else { - body + let body = html_body.unwrap_or_else(|| String::from_utf8_lossy(&bytes).into_owned()); + if body_only && is_html { + extract_html_body(&body).unwrap_or(body) + } else { + body + } }; let content = match format { "html" => body, - "text" if content_type.contains("text/html") => html_to_text(&body), - "markdown" if content_type.contains("text/html") => html_to_markdown(&body), - _ if content_type.contains("text/html") => html_to_markdown(&body), + "text" if is_html => html_to_text(&body), + "markdown" if is_html => html_to_markdown(&body), + _ if is_html => html_to_markdown(&body), _ => body, }; return Ok(FetchedPage { content, final_url: url, + document_kind, + content_type: content_type + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(), }); } diff --git a/core/src/tools/builtin/web_fetch/pdf.rs b/core/src/tools/builtin/web_fetch/pdf.rs new file mode 100644 index 00000000..d64eaa1e --- /dev/null +++ b/core/src/tools/builtin/web_fetch/pdf.rs @@ -0,0 +1,169 @@ +const PDF_CONTENT_TYPE: &str = "application/pdf"; +const PDF_MAGIC: &[u8] = b"%PDF-"; + +pub(super) fn response_is_pdf(content_type: &str, bytes: &[u8]) -> bool { + content_type + .split(';') + .next() + .is_some_and(|value| value.trim().eq_ignore_ascii_case(PDF_CONTENT_TYPE)) + || bytes.starts_with(PDF_MAGIC) +} + +/// Extract PDF text away from Tokio's async worker threads. +pub(super) async fn extract_text(bytes: Vec) -> Result { + let text = tokio::task::spawn_blocking(move || pdf_extract::extract_text_from_mem(&bytes)) + .await + .map_err(|error| format!("PDF text extraction worker failed: {error}"))? + .map_err(|error| format!("Could not parse or extract text from PDF: {error}"))?; + if text.trim().is_empty() { + return Err( + "PDF contains no extractable text; it may be image-only or scanned".to_string(), + ); + } + Ok(text) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::types::{Tool, ToolContext}; + use lopdf::{ + content::{Content, Operation}, + dictionary, Document, Object, Stream, + }; + + fn pdf_document(text: Option<&str>) -> Vec { + let mut document = Document::with_version("1.5"); + let pages_id = document.new_object_id(); + let font_id = document.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "Type1", + "BaseFont" => "Courier", + }); + let resources_id = document.add_object(dictionary! { + "Font" => dictionary! { + "F1" => font_id, + }, + }); + let mut operations = Vec::new(); + if let Some(text) = text { + operations.extend([ + Operation::new("BT", vec![]), + Operation::new("Tf", vec!["F1".into(), 12.into()]), + Operation::new("Td", vec![72.into(), 720.into()]), + Operation::new("Tj", vec![Object::string_literal(text)]), + Operation::new("ET", vec![]), + ]); + } + let content = Content { operations }; + let content_id = document.add_object(Stream::new( + dictionary! {}, + content.encode().expect("test PDF content must encode"), + )); + let page_id = document.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "Contents" => content_id, + }); + document.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => vec![page_id.into()], + "Count" => 1, + "Resources" => resources_id, + "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()], + }), + ); + let catalog_id = document.add_object(dictionary! { + "Type" => "Catalog", + "Pages" => pages_id, + }); + document.trailer.set("Root", catalog_id); + + let mut bytes = Vec::new(); + document + .save_to(&mut bytes) + .expect("test PDF must serialize"); + bytes + } + + #[test] + fn detects_pdf_from_content_type_or_magic() { + assert!(response_is_pdf( + "Application/PDF; charset=binary", + b"not a PDF payload" + )); + assert!(response_is_pdf("application/octet-stream", b"%PDF-1.7\n")); + assert!(!response_is_pdf("text/html", b"")); + assert!(!response_is_pdf("application/json", b"{\"pdf\":true}")); + } + + #[tokio::test(flavor = "current_thread")] + async fn extracts_text_from_pdf_without_external_io() { + let text = extract_text(pdf_document(Some("STORM research evidence"))) + .await + .expect("generated PDF text must extract"); + + assert!(text.contains("STORM research evidence"), "{text:?}"); + } + + #[tokio::test(flavor = "current_thread")] + async fn rejects_pdf_without_extractable_text() { + let error = extract_text(pdf_document(None)).await.unwrap_err(); + + assert!(error.contains("no extractable text"), "{error}"); + } + + #[tokio::test(flavor = "current_thread")] + async fn reports_malformed_pdf_as_parse_failure() { + let error = extract_text(b"%PDF-1.7\nmalformed".to_vec()) + .await + .unwrap_err(); + + assert!( + error.contains("Could not parse or extract text from PDF"), + "{error}" + ); + } + + #[tokio::test] + #[ignore = "requires external network"] + async fn extracts_real_storm_arxiv_pdf() { + let result = super::super::WebFetchTool + .execute( + &serde_json::json!({ + "url": "https://arxiv.org/pdf/2402.14207", + "format": "text", + "timeout": 30 + }), + &ToolContext::new(std::env::temp_dir()), + ) + .await + .expect("web_fetch must execute"); + assert!(result.success, "{}", result.content); + assert_eq!( + result + .metadata + .as_ref() + .map(|value| &value["document_kind"]), + Some(&serde_json::json!("pdf")) + ); + assert_eq!( + result.metadata.as_ref().map(|value| &value["content_type"]), + Some(&serde_json::json!("application/pdf")) + ); + assert!( + result.content.contains("STORM"), + "extracted text omitted the title" + ); + assert!( + result.content.contains("Wikipedia-like Articles"), + "extracted text omitted the paper subject" + ); + assert!( + !result.content.contains("Taylor Hawkins"), + "extracted PDF text included unrelated page content" + ); + } +} diff --git a/core/src/tools/mod.rs b/core/src/tools/mod.rs index ddf7a3d8..ee1e3656 100644 --- a/core/src/tools/mod.rs +++ b/core/src/tools/mod.rs @@ -35,6 +35,7 @@ pub(crate) use invocation::{ }; pub use program_tool::{ProgramTool, MAX_PROGRAM_SCRIPT_SOURCE_BYTES}; pub use registry::ToolRegistry; +pub(crate) use selector::is_standalone_conversation; pub use selector::{select_tools_for_messages, select_tools_for_prompt}; pub use task::{ parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool, 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/selector.rs b/core/src/tools/selector.rs index 36ed676c..5be8cff4 100644 --- a/core/src/tools/selector.rs +++ b/core/src/tools/selector.rs @@ -64,6 +64,39 @@ const PROGRAM_TERMS: &[&str] = &[ const MCP_TERMS: &[&str] = &["mcp", "external tool", "external server", "外部工具"]; +const STANDALONE_CONVERSATION: &[&str] = &[ + "hi", + "hi there", + "hello", + "hello there", + "hey", + "greetings", + "good morning", + "good afternoon", + "good evening", + "how are you", + "how's it going", + "hows it going", + "what's up", + "whats up", + "thanks", + "thank you", + "你好", + "您好", + "嗨", + "哈喽", + "哈啰", + "早", + "早上好", + "上午好", + "下午好", + "晚上好", + "在吗", + "你好吗", + "谢谢", + "多谢", +]; + /// Select the tools that should be exposed to the model for this turn. /// /// The executor still owns every registered tool. This function only trims the @@ -78,7 +111,7 @@ pub fn select_tools_for_messages( } pub fn select_tools_for_prompt(tools: &[ToolDefinition], prompt: &str) -> Vec { - if tools.is_empty() { + if tools.is_empty() || is_standalone_conversation(prompt) { return Vec::new(); } @@ -114,6 +147,32 @@ pub fn select_tools_for_prompt(tools: &[ToolDefinition], prompt: &str) -> Vec bool { + let normalized = prompt + .trim() + .trim_matches(is_conversational_boundary) + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(); + + STANDALONE_CONVERSATION.contains(&normalized.as_str()) +} + +fn is_conversational_boundary(character: char) -> bool { + character.is_ascii_punctuation() + || character.is_whitespace() + || matches!( + character, + '。' | ',' | '、' | '!' | '?' | '…' | '~' | '👋' + ) +} + fn should_include_mcp_tool( name: &str, name_lower: &str, @@ -264,6 +323,38 @@ mod tests { assert!(!names.contains(&"mcp__github__create_issue")); } + #[test] + fn standalone_greetings_do_not_expose_tools() { + let tools = defs(&["read", "grep", "bash", "web_search", "task"]); + + for prompt in [ + "hi", + "Hello!", + "how are you?", + "你好", + "您好!", + "在吗?", + "谢谢", + ] { + assert!( + select_tools_for_prompt(&tools, prompt).is_empty(), + "standalone greeting exposed tools: {prompt}" + ); + } + } + + #[test] + fn greeting_with_an_action_keeps_relevant_tools() { + let selected = select_tools_for_prompt( + &defs(&["read", "grep", "web_search"]), + "Hello! Inspect this repository for the parser implementation.", + ); + let names: Vec<_> = selected.iter().map(|tool| tool.name.as_str()).collect(); + + assert!(names.contains(&"read")); + assert!(names.contains(&"grep")); + } + #[test] fn program_terms_enable_program_tool() { let selected = select_tools_for_prompt( 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; } diff --git a/scripts/check_semver.sh b/scripts/check_semver.sh index 713be62f..64096795 100644 --- a/scripts/check_semver.sh +++ b/scripts/check_semver.sh @@ -3,10 +3,13 @@ set -euo pipefail -BASELINE_VERSION="${1:-5.3.4}" +BASELINE_VERSION="${1:-5.3.5}" PACKAGE="a3s-code-core" case "$BASELINE_VERSION" in + 5.3.5) + BASELINE_SHA256="481f9350b8e65960c8cc1426a18131f26b50edd8275e034bc33ad60a6b5aa4ad" + ;; 5.3.4) BASELINE_SHA256="2ea4c48286d828e09fb44df83144d05b2d41db25e4695f3bdce768e7a46e0399" ;; diff --git a/sdk/node/Cargo.lock b/sdk/node/Cargo.lock index 060a9fc5..7274d243 100644 --- a/sdk/node/Cargo.lock +++ b/sdk/node/Cargo.lock @@ -15,7 +15,7 @@ source = "git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ac [[package]] name = "a3s-code-core" -version = "5.3.5" +version = "5.3.6" dependencies = [ "a3s-acl 0.2.1 (git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22)", "a3s-common", @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "a3s-code-node" -version = "5.3.5" +version = "5.3.6" dependencies = [ "a3s-code-core", "anyhow", diff --git a/sdk/node/Cargo.toml b/sdk/node/Cargo.toml index 18de7dd3..562e4760 100644 --- a/sdk/node/Cargo.toml +++ b/sdk/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-node" -version = "5.3.5" +version = "5.3.6" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -11,7 +11,7 @@ description = "A3S Code Node.js bindings - Native addon via napi-rs" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "5.3.5", path = "../../core", features = ["s3", "serve"] } +a3s-code-core = { version = "5.3.6", path = "../../core", features = ["s3", "serve"] } napi = { version = "2", features = ["async", "napi6", "serde-json"] } napi-derive = "2" tokio = { version = "1.35", features = ["full"] } diff --git a/sdk/node/examples/package-lock.json b/sdk/node/examples/package-lock.json index c092c849..4102d4ce 100644 --- a/sdk/node/examples/package-lock.json +++ b/sdk/node/examples/package-lock.json @@ -18,7 +18,7 @@ }, "..": { "name": "@a3s-lab/code", - "version": "5.3.5", + "version": "5.3.6", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -27,12 +27,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.3.5", - "@a3s-lab/code-linux-arm64-gnu": "5.3.5", - "@a3s-lab/code-linux-arm64-musl": "5.3.5", - "@a3s-lab/code-linux-x64-gnu": "5.3.5", - "@a3s-lab/code-linux-x64-musl": "5.3.5", - "@a3s-lab/code-win32-x64-msvc": "5.3.5" + "@a3s-lab/code-darwin-arm64": "5.3.6", + "@a3s-lab/code-linux-arm64-gnu": "5.3.6", + "@a3s-lab/code-linux-arm64-musl": "5.3.6", + "@a3s-lab/code-linux-x64-gnu": "5.3.6", + "@a3s-lab/code-linux-x64-musl": "5.3.6", + "@a3s-lab/code-win32-x64-msvc": "5.3.6" } }, "node_modules/@a3s-lab/code": { diff --git a/sdk/node/package-lock.json b/sdk/node/package-lock.json index c80a86ad..1619dfbf 100644 --- a/sdk/node/package-lock.json +++ b/sdk/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a3s-lab/code", - "version": "5.3.5", + "version": "5.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a3s-lab/code", - "version": "5.3.5", + "version": "5.3.6", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -15,12 +15,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.3.5", - "@a3s-lab/code-linux-arm64-gnu": "5.3.5", - "@a3s-lab/code-linux-arm64-musl": "5.3.5", - "@a3s-lab/code-linux-x64-gnu": "5.3.5", - "@a3s-lab/code-linux-x64-musl": "5.3.5", - "@a3s-lab/code-win32-x64-msvc": "5.3.5" + "@a3s-lab/code-darwin-arm64": "5.3.6", + "@a3s-lab/code-linux-arm64-gnu": "5.3.6", + "@a3s-lab/code-linux-arm64-musl": "5.3.6", + "@a3s-lab/code-linux-x64-gnu": "5.3.6", + "@a3s-lab/code-linux-x64-musl": "5.3.6", + "@a3s-lab/code-win32-x64-msvc": "5.3.6" } }, "node_modules/@a3s-lab/code-darwin-arm64": { diff --git a/sdk/node/package.json b/sdk/node/package.json index 9f90821c..d67b210b 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -1,6 +1,6 @@ { "name": "@a3s-lab/code", - "version": "5.3.5", + "version": "5.3.6", "description": "A3S Code - Native Node.js bindings for the coding-agent runtime", "main": "index.js", "types": "index.d.ts", @@ -44,11 +44,11 @@ "test:helpers": "node test-helpers.mjs" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.3.5", - "@a3s-lab/code-linux-x64-gnu": "5.3.5", - "@a3s-lab/code-linux-x64-musl": "5.3.5", - "@a3s-lab/code-linux-arm64-gnu": "5.3.5", - "@a3s-lab/code-linux-arm64-musl": "5.3.5", - "@a3s-lab/code-win32-x64-msvc": "5.3.5" + "@a3s-lab/code-darwin-arm64": "5.3.6", + "@a3s-lab/code-linux-x64-gnu": "5.3.6", + "@a3s-lab/code-linux-x64-musl": "5.3.6", + "@a3s-lab/code-linux-arm64-gnu": "5.3.6", + "@a3s-lab/code-linux-arm64-musl": "5.3.6", + "@a3s-lab/code-win32-x64-msvc": "5.3.6" } } diff --git a/sdk/python-bootstrap/README.md b/sdk/python-bootstrap/README.md index 134a320c..f9ed85ad 100644 --- a/sdk/python-bootstrap/README.md +++ b/sdk/python-bootstrap/README.md @@ -44,4 +44,4 @@ pip install \ 'https://github.com/A3S-Lab/Code/releases/download/v/a3s_code--cp312-cp312-manylinux_2_28_x86_64.whl' ``` -Replace `` with the release to install, for example `5.3.5`. +Replace `` with the release to install, for example `5.3.6`. diff --git a/sdk/python-bootstrap/pyproject.toml b/sdk/python-bootstrap/pyproject.toml index 2b946b0d..b44a2aa9 100644 --- a/sdk/python-bootstrap/pyproject.toml +++ b/sdk/python-bootstrap/pyproject.toml @@ -7,7 +7,7 @@ name = "a3s-code" # Keep in sync with crates/code core release. The bootstrap loader fetches # the matching native wheel from `https://github.com/A3S-Lab/Code/releases/tag/v` # at import time. -version = "5.3.5" +version = "5.3.6" description = "A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases" readme = "README.md" license = {text = "MIT"} diff --git a/sdk/python-bootstrap/src/a3s_code/_bootstrap.py b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py index c36106d2..cb5368e0 100644 --- a/sdk/python-bootstrap/src/a3s_code/_bootstrap.py +++ b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py @@ -31,7 +31,7 @@ # Version is the bootstrap's own version, which equals the matching native # wheel version on GH Releases. Bumped by the release workflow. -__version__ = "5.3.5" +__version__ = "5.3.6" _DEFAULT_BASE_URL = "https://github.com/A3S-Lab/Code/releases/download" _REQUEST_TIMEOUT_S = 120 diff --git a/sdk/python/CHANGELOG.md b/sdk/python/CHANGELOG.md index 98f9c834..325e7839 100644 --- a/sdk/python/CHANGELOG.md +++ b/sdk/python/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to the A3S Code Python SDK will be documented in this file. ## [Unreleased] +## [5.3.6] - 2026-07-19 + +### Changed + +- Updated the bundled Core with expanded TypeScript language profiles, PDF + fetching, invariant-safe session forks, typed MCP results and artifacts, + delegated permission boundaries, and tool-free standalone greetings. + ## [5.3.5] - 2026-07-17 ### Changed diff --git a/sdk/python/Cargo.lock b/sdk/python/Cargo.lock index bb871803..19453cd6 100644 --- a/sdk/python/Cargo.lock +++ b/sdk/python/Cargo.lock @@ -15,7 +15,7 @@ source = "git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ac [[package]] name = "a3s-code-core" -version = "5.3.5" +version = "5.3.6" dependencies = [ "a3s-acl 0.2.1 (git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22)", "a3s-common", @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "a3s-code-py" -version = "5.3.5" +version = "5.3.6" dependencies = [ "a3s-code-core", "anyhow", diff --git a/sdk/python/Cargo.toml b/sdk/python/Cargo.toml index 56f7a148..935fc323 100644 --- a/sdk/python/Cargo.toml +++ b/sdk/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-py" -version = "5.3.5" +version = "5.3.6" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -12,7 +12,7 @@ name = "a3s_code" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "5.3.5", path = "../../core", features = ["s3", "serve"] } +a3s-code-core = { version = "5.3.6", path = "../../core", features = ["s3", "serve"] } pyo3 = { version = "0.23", features = ["multiple-pymethods"] } tokio = { version = "1.35", features = ["full"] } serde_json = "1.0" diff --git a/sdk/python/README.md b/sdk/python/README.md index e849a437..d1eeaaa5 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -28,7 +28,7 @@ pip install \ 'https://github.com/A3S-Lab/Code/releases/download/v/a3s_code--cp312-cp312-manylinux_2_28_x86_64.whl' ``` -Replace `` with the release to install, for example `5.3.5`. +Replace `` with the release to install, for example `5.3.6`. ## Quick Start diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 0e45ae35..309502eb 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "a3s-code" -version = "5.3.5" +version = "5.3.6" description = "A3S Code - Native Python bindings for the coding-agent runtime" readme = "README.md" license = {text = "MIT"}