diff --git a/Cargo.lock b/Cargo.lock index e6434d1..ba7a6a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,6 +832,7 @@ dependencies = [ "turbovault-parser", "ureq", "url", + "usvg", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9af7a3f..0a9ab6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ time = { version = "0.3.54", features = ["formatting", "parsing"] } turbovault-parser = "1.6.0" ureq = { version = "3.3.0", default-features = false, features = ["rustls"] } url = "2.5.8" +usvg = "0.45.1" [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.19.3", default-features = false } diff --git a/src/agent.rs b/src/agent.rs index c4778de..a2dc915 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -28,6 +28,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; const MAX_RALPH_REPORT_BYTES: usize = 16 * 1024; +const MAX_SVG_SOURCE_BYTES: usize = 64 * 1024; +const MAX_SVG_ALT_BYTES: usize = 512; +const MAX_SVG_NODES: usize = 1_024; +const MIN_SVG_DIMENSION: f32 = 1.0; +const MAX_SVG_DIMENSION: f32 = 4_096.0; const DEFAULT_RALPH_ROUNDS: usize = 8; const MAX_RALPH_STEPS_PER_ROUND: usize = 64; const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5); @@ -322,6 +327,7 @@ const RISK_CLASSIFIER_SYSTEM_PROMPT: &str = "You are a shell-command risk classi #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ToolHandler { + PresentSvg, GoogleSearch, VisitPage, Bash, @@ -383,6 +389,25 @@ const POSITIVE: ParameterKind = ParameterKind::Integer { max: usize::MAX as u64, }; const TOOLS: &[ToolSpec] = &[ + ToolSpec { + name: "present_svg", + description: "Present a self-contained static SVG inline in the chat when a diagram or illustration materially improves the answer. alt must concisely describe the information conveyed. This is presentation only: use A2UI instead for interactive controls or stateful UI. Do not include scripts, animation, links, HTML, raster images, external resources, file paths, or remote URLs.", + parameters: &[ + ToolParameter { + name: "svg", + kind: NON_EMPTY, + required: true, + }, + ToolParameter { + name: "alt", + kind: NON_EMPTY, + required: true, + }, + ], + rule: ToolRule::None, + handler: ToolHandler::PresentSvg, + dev_brain: false, + }, ToolSpec { name: "google_search", description: "Search Google in a browser and return compact Markdown links. If browser startup is denied, do not repeat the unchanged call; explain why web access is needed or continue with local evidence.", @@ -1260,6 +1285,198 @@ pub(crate) struct ToolCall { pub(crate) arguments: Map, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SvgPresentation { + pub(crate) svg: String, + pub(crate) alt: String, + pub(crate) height: f32, +} + +fn present_svg(call: &ToolCall) -> Result { + let presentation = + validate_svg_presentation(required_string(call, "svg")?, required_string(call, "alt")?)?; + Ok(format!("SVG presented inline: {}", presentation.alt)) +} + +fn validate_svg_presentation(svg: &str, alt: &str) -> Result { + if svg.trim().is_empty() { + return Err("SVG source is empty".into()); + } + if svg.len() > MAX_SVG_SOURCE_BYTES { + return Err(format!( + "SVG source is {} bytes; maximum is {MAX_SVG_SOURCE_BYTES} bytes", + svg.len() + )); + } + let alt = alt.trim(); + if alt.is_empty() { + return Err("SVG alt text is empty".into()); + } + if alt.len() > MAX_SVG_ALT_BYTES { + return Err(format!( + "SVG alt text is {} bytes; maximum is {MAX_SVG_ALT_BYTES} bytes", + alt.len() + )); + } + + let document = usvg::roxmltree::Document::parse(svg) + .map_err(|error| format!("SVG is malformed XML: {error}"))?; + if document + .descendants() + .any(|node| node.node_type() == usvg::roxmltree::NodeType::PI) + { + return Err("SVG processing instructions are not allowed".into()); + } + let root = document.root_element(); + if root.tag_name().name() != "svg" + || !matches!( + root.tag_name().namespace(), + None | Some("http://www.w3.org/2000/svg") + ) + { + return Err("SVG source must have an svg root element".into()); + } + + let mut source_nodes = 0; + for node in document + .descendants() + .filter(usvg::roxmltree::Node::is_element) + { + source_nodes += 1; + if source_nodes > MAX_SVG_NODES { + return Err(format!( + "SVG has more than {MAX_SVG_NODES} elements and is too complex" + )); + } + if !matches!( + node.tag_name().namespace(), + None | Some("http://www.w3.org/2000/svg") + ) { + return Err("SVG contains foreign or HTML content".into()); + } + let element = node.tag_name().name().to_ascii_lowercase(); + if matches!( + element.as_str(), + "script" + | "animate" + | "animatecolor" + | "animatemotion" + | "animatetransform" + | "set" + | "discard" + | "handler" + | "listener" + | "a" + | "foreignobject" + | "iframe" + | "object" + | "embed" + | "audio" + | "video" + | "canvas" + | "image" + | "feimage" + | "style" + ) { + return Err(format!("SVG element <{element}> is not allowed")); + } + for attribute in node.attributes() { + let name = attribute.name().to_ascii_lowercase(); + let value = attribute.value().trim(); + if name.starts_with("on") { + return Err(format!("SVG event attribute {name} is not allowed")); + } + if name == "href" && !value.starts_with('#') { + return Err("SVG external references and links are not allowed".into()); + } + if has_external_svg_reference(value) { + return Err("SVG external resources and URLs are not allowed".into()); + } + } + } + + let options = usvg::Options { + resources_dir: None, + image_href_resolver: usvg::ImageHrefResolver { + resolve_data: Box::new(|_, _, _| None), + resolve_string: Box::new(|_, _| None), + }, + ..usvg::Options::default() + }; + let tree = usvg::Tree::from_str(svg, &options) + .map_err(|error| format!("SVG cannot be rendered: {error}"))?; + let size = tree.size(); + let (width, height) = (size.width(), size.height()); + if !width.is_finite() + || !height.is_finite() + || width < MIN_SVG_DIMENSION + || height < MIN_SVG_DIMENSION + || width > MAX_SVG_DIMENSION + || height > MAX_SVG_DIMENSION + { + return Err(format!( + "SVG dimensions must be finite and between {MIN_SVG_DIMENSION} and {MAX_SVG_DIMENSION}" + )); + } + let mut rendered_nodes = 0; + let mut visible_nodes = 0; + count_svg_nodes(tree.root(), &mut rendered_nodes, &mut visible_nodes); + if visible_nodes == 0 { + return Err("SVG has no renderable vector or text content".into()); + } + if rendered_nodes > MAX_SVG_NODES { + return Err(format!( + "rendered SVG has more than {MAX_SVG_NODES} nodes and is too complex" + )); + } + Ok(SvgPresentation { + svg: svg.to_owned(), + alt: alt.to_owned(), + height, + }) +} + +fn has_external_svg_reference(value: &str) -> bool { + let value = value.to_ascii_lowercase(); + if value.contains("@import") + || value.contains("javascript:") + || value.contains("data:") + || value.contains("file:") + || value.contains("http:") + || value.contains("https:") + || value.contains("expression(") + { + return true; + } + let mut rest = value.as_str(); + while let Some(start) = rest.find("url(") { + let body = &rest[start + 4..]; + let Some(end) = body.find(')') else { + return true; + }; + if !body[..end] + .trim() + .trim_matches(['\'', '"']) + .starts_with('#') + { + return true; + } + rest = &body[end + 1..]; + } + false +} + +fn count_svg_nodes(group: &usvg::Group, nodes: &mut usize, visible: &mut usize) { + for node in group.children() { + *nodes += 1; + match node { + usvg::Node::Group(group) => count_svg_nodes(group, nodes, visible), + usvg::Node::Path(_) | usvg::Node::Text(_) => *visible += 1, + usvg::Node::Image(_) => {} + } + } +} + pub(crate) struct ActiveTools { pub(crate) results: Receiver, pub(crate) events: Receiver, @@ -1334,6 +1551,13 @@ impl ToolCard { } } +pub(crate) fn svg_presentation(card: &ToolCard) -> Option { + if card.call.name != "present_svg" || card.state != ToolLifecycle::Completed { + return None; + } + validate_svg_presentation(string(&card.call, "svg")?, string(&card.call, "alt")?).ok() +} + #[derive(Clone, Debug)] pub(crate) struct ApprovalPrompt { pub(crate) title: String, @@ -1691,6 +1915,7 @@ impl Tools { cancel: &AtomicBool, ) -> String { let result = match tool.handler { + ToolHandler::PresentSvg => present_svg(call), ToolHandler::Read => self.read(call), ToolHandler::More => self.more(call), ToolHandler::Write => self.write(call), @@ -3863,6 +4088,176 @@ mod tests { ); } + #[test] + fn present_svg_parsers_recover_exact_source_for_both_model_transports() { + let svg = r##""##; + let dsml = format!( + "<|DSML|tool_calls><|DSML|invoke name=\"present_svg\"><|DSML|parameter name=\"svg\" string=\"true\">{svg}<|DSML|parameter name=\"alt\" string=\"true\">A dark rectangle" + ); + let (_, calls) = parse_tool_calls(ModelChoice::DeepSeekV4Flash0731, &dsml).unwrap(); + assert_eq!(string(&calls[0], "svg"), Some(svg)); + assert_eq!(string(&calls[0], "alt"), Some("A dark rectangle")); + + let glm = format!( + "present_svgsvg{svg}altA dark rectangle" + ); + let (_, calls) = parse_tool_calls(ModelChoice::Glm52, &glm).unwrap(); + assert_eq!(string(&calls[0], "svg"), Some(svg)); + assert_eq!(string(&calls[0], "alt"), Some("A dark rectangle")); + } + + #[test] + fn present_svg_validation_enforces_the_static_vector_boundary() { + let valid = r##""##; + let presentation = validate_svg_presentation(valid, "A gradient rectangle").unwrap(); + assert_eq!(presentation.svg, valid); + assert_eq!(presentation.alt, "A gradient rectangle"); + assert_eq!(presentation.height, 80.0); + + let excessive = format!( + "{}", + "".repeat(MAX_SVG_NODES) + ); + let cases = [ + ("", "empty"), + (&"x".repeat(MAX_SVG_SOURCE_BYTES + 1), "maximum"), + ("", "svg root"), + ("", "no renderable"), + ( + "", + "dimensions", + ), + (&excessive, "too complex"), + ( + "", + "external references", + ), + ( + "", + "external resources", + ), + ( + "", + "", + ), + ( + "", + "