Skip to main content

microclaw_engine/tools/
osv_check.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use serde_json::json;
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::internal::storage::db::{call_blocking, Database};
8use crate::internal::tool_runtime::tool_cache::{cache_key, default_ttls};
9use microclaw_core::llm_types::ToolDefinition;
10
11use super::{schema_object, Tool, ToolResult};
12
13/// OSV (Open Source Vulnerabilities) query tool.
14///
15/// Queries `https://api.osv.dev/v1/query` for vulnerabilities affecting a
16/// given package / ecosystem / version. Modeled after hermes-agent's
17/// `tools/osv_check.py`, which narrows to MAL-* (malware) advisories; this
18/// Rust port returns every advisory and lets the caller filter.
19pub struct OsvCheckTool {
20    endpoint: String,
21    timeout_secs: u64,
22    db: Option<Arc<Database>>,
23}
24
25impl OsvCheckTool {
26    pub fn new(timeout_secs: u64) -> Self {
27        Self {
28            endpoint: "https://api.osv.dev/v1/query".to_string(),
29            timeout_secs: timeout_secs.clamp(1, 30),
30            db: None,
31        }
32    }
33
34    pub fn with_cache(mut self, db: Arc<Database>) -> Self {
35        self.db = Some(db);
36        self
37    }
38
39    /// Allow tests to override the endpoint.
40    #[allow(dead_code)]
41    pub fn with_endpoint(mut self, endpoint: String) -> Self {
42        self.endpoint = endpoint;
43        self
44    }
45}
46
47#[derive(Serialize)]
48struct OsvPackage<'a> {
49    name: &'a str,
50    ecosystem: &'a str,
51}
52
53#[derive(Serialize)]
54struct OsvQuery<'a> {
55    package: OsvPackage<'a>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    version: Option<&'a str>,
58}
59
60#[derive(Deserialize, Default)]
61struct OsvResponse {
62    #[serde(default)]
63    vulns: Vec<OsvVuln>,
64}
65
66#[derive(Deserialize)]
67struct OsvVuln {
68    id: String,
69    #[serde(default)]
70    summary: Option<String>,
71    #[serde(default)]
72    aliases: Vec<String>,
73    #[serde(default)]
74    severity: Vec<OsvSeverity>,
75}
76
77#[derive(Deserialize)]
78struct OsvSeverity {
79    #[serde(rename = "type", default)]
80    kind: String,
81    #[serde(default)]
82    score: String,
83}
84
85fn is_supported_ecosystem(raw: &str) -> bool {
86    matches!(
87        raw.to_ascii_lowercase().as_str(),
88        "npm"
89            | "pypi"
90            | "cargo"
91            | "go"
92            | "maven"
93            | "rubygems"
94            | "nuget"
95            | "packagist"
96            | "hex"
97            | "pub"
98            | "swifturl"
99            | "crates.io"
100    )
101}
102
103fn canonical_ecosystem(raw: &str) -> &str {
104    // OSV accepts "PyPI", "npm", "crates.io" exactly. Normalize the common
105    // variants the agent is likely to pass.
106    match raw.to_ascii_lowercase().as_str() {
107        "pypi" | "py" | "python" => "PyPI",
108        "crates.io" | "cargo" | "rust" | "crates" => "crates.io",
109        "npm" | "node" | "nodejs" => "npm",
110        "rubygems" | "gem" | "ruby" => "RubyGems",
111        "maven" | "java" => "Maven",
112        "nuget" | "dotnet" | "csharp" => "NuGet",
113        "packagist" | "composer" | "php" => "Packagist",
114        "hex" | "elixir" => "Hex",
115        "pub" | "dart" => "Pub",
116        "go" | "golang" => "Go",
117        _ => "", // unknown → caller-handled rejection
118    }
119}
120
121#[async_trait]
122impl Tool for OsvCheckTool {
123    fn name(&self) -> &str {
124        "osv_check"
125    }
126
127    fn definition(&self) -> ToolDefinition {
128        ToolDefinition {
129            name: self.name().into(),
130            description: "Query the OSV (Open Source Vulnerabilities) database at \
131                api.osv.dev for advisories against a package. Supports PyPI, npm, \
132                crates.io, RubyGems, Maven, NuGet, Packagist, Hex, Pub, and Go. \
133                Use before recommending or installing a third-party dependency, \
134                or when the user wants a quick security check."
135                .into(),
136            input_schema: schema_object(
137                json!({
138                    "package": {
139                        "type": "string",
140                        "description": "Package name (e.g. 'lodash', 'requests', 'serde_json')."
141                    },
142                    "ecosystem": {
143                        "type": "string",
144                        "description": "Ecosystem: npm, PyPI, crates.io, RubyGems, Maven, NuGet, Packagist, Hex, Pub, or Go."
145                    },
146                    "version": {
147                        "type": "string",
148                        "description": "Optional specific version. When omitted, returns all known advisories for the package."
149                    }
150                }),
151                &["package", "ecosystem"],
152            ),
153        }
154    }
155
156    async fn execute(&self, input: serde_json::Value) -> ToolResult {
157        // Cache lookup before the HTTP call. Keyed by normalized (tool,
158        // input) — auth context is stripped automatically.
159        let key = cache_key(self.name(), &input);
160        let ttl = default_ttls()
161            .get(self.name())
162            .copied()
163            .unwrap_or_else(|| Duration::from_secs(3600));
164        if let Some(db) = &self.db {
165            let now = chrono::Utc::now().to_rfc3339();
166            let lookup_key = key.clone();
167            if let Ok(Some(cached)) = call_blocking(db.clone(), move |d| {
168                d.get_cached_tool_result(&lookup_key, &now)
169            })
170            .await
171            {
172                if !cached.is_error {
173                    return ToolResult::success(cached.content)
174                        .with_metadata(json!({ "cached": true }));
175                }
176            }
177        }
178        let result = self.execute_uncached(input).await;
179        if let Some(db) = &self.db {
180            if !result.is_error {
181                let expires = (chrono::Utc::now()
182                    + chrono::Duration::from_std(ttl).unwrap_or_default())
183                .to_rfc3339();
184                let content = result.content.clone();
185                let tool_name = self.name().to_string();
186                let _ = call_blocking(db.clone(), move |d| {
187                    d.put_cached_tool_result(&key, &tool_name, &content, false, None, &expires)
188                })
189                .await;
190            }
191        }
192        result
193    }
194}
195
196impl OsvCheckTool {
197    async fn execute_uncached(&self, input: serde_json::Value) -> ToolResult {
198        let package = match input.get("package").and_then(|v| v.as_str()) {
199            Some(v) if !v.trim().is_empty() => v.trim().to_string(),
200            _ => return ToolResult::error("Missing required parameter: package".into()),
201        };
202        let ecosystem_raw = match input.get("ecosystem").and_then(|v| v.as_str()) {
203            Some(v) if !v.trim().is_empty() => v.trim().to_string(),
204            _ => return ToolResult::error("Missing required parameter: ecosystem".into()),
205        };
206        let version = input
207            .get("version")
208            .and_then(|v| v.as_str())
209            .map(|s| s.trim().to_string())
210            .filter(|s| !s.is_empty());
211
212        if !is_supported_ecosystem(&ecosystem_raw) {
213            return ToolResult::error(format!(
214                "Unsupported ecosystem '{ecosystem_raw}'. Supported: npm, PyPI, \
215                 crates.io, RubyGems, Maven, NuGet, Packagist, Hex, Pub, Go."
216            ));
217        }
218        let ecosystem = canonical_ecosystem(&ecosystem_raw);
219        if ecosystem.is_empty() {
220            return ToolResult::error(format!("Unsupported ecosystem '{ecosystem_raw}'"));
221        }
222
223        let client = match reqwest::Client::builder()
224            .timeout(Duration::from_secs(self.timeout_secs))
225            .user_agent("MicroClaw-osv-check/1.0")
226            .build()
227        {
228            Ok(c) => c,
229            Err(e) => return ToolResult::error(format!("failed to build HTTP client: {e}")),
230        };
231
232        let body = OsvQuery {
233            package: OsvPackage {
234                name: package.as_str(),
235                ecosystem,
236            },
237            version: version.as_deref(),
238        };
239
240        let resp = match client.post(&self.endpoint).json(&body).send().await {
241            Ok(r) => r,
242            Err(e) => return ToolResult::error(format!("OSV request failed: {e}")),
243        };
244        if !resp.status().is_success() {
245            return ToolResult::error(format!("OSV returned HTTP {}", resp.status()));
246        }
247        let parsed: OsvResponse = match resp.json().await {
248            Ok(v) => v,
249            Err(e) => return ToolResult::error(format!("invalid OSV JSON: {e}")),
250        };
251
252        if parsed.vulns.is_empty() {
253            let ver = version.as_deref().unwrap_or("<all>");
254            return ToolResult::success(format!(
255                "No OSV advisories for {ecosystem}:{package} (version {ver})."
256            ));
257        }
258
259        let mut lines = Vec::new();
260        lines.push(format!(
261            "{} OSV advisor(y/ies) for {ecosystem}:{package}{}",
262            parsed.vulns.len(),
263            version
264                .as_deref()
265                .map(|v| format!(" @ {v}"))
266                .unwrap_or_default()
267        ));
268        for v in &parsed.vulns {
269            let mut line = format!("- {}", v.id);
270            if !v.aliases.is_empty() {
271                line.push_str(" (aliases: ");
272                line.push_str(&v.aliases.join(", "));
273                line.push(')');
274            }
275            if let Some(sum) = v.summary.as_deref().filter(|s| !s.is_empty()) {
276                line.push_str(" — ");
277                line.push_str(sum);
278            }
279            if let Some(sev) = v
280                .severity
281                .iter()
282                .find(|s| !s.kind.is_empty() && !s.score.is_empty())
283            {
284                line.push_str(&format!(" [severity {}: {}]", sev.kind, sev.score));
285            }
286            lines.push(line);
287        }
288        let malware_count = parsed
289            .vulns
290            .iter()
291            .filter(|v| v.id.starts_with("MAL-"))
292            .count();
293        if malware_count > 0 {
294            lines.push(format!(
295                "WARNING: {malware_count} advisor(y/ies) are confirmed-malware (MAL-*)."
296            ));
297        }
298        ToolResult::success(lines.join("\n"))
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn supports_common_ecosystems() {
308        assert!(is_supported_ecosystem("npm"));
309        assert!(is_supported_ecosystem("PyPI"));
310        assert!(is_supported_ecosystem("crates.io"));
311        assert!(!is_supported_ecosystem("homebrew"));
312    }
313
314    #[test]
315    fn canonicalizes_aliases() {
316        assert_eq!(canonical_ecosystem("python"), "PyPI");
317        assert_eq!(canonical_ecosystem("rust"), "crates.io");
318        assert_eq!(canonical_ecosystem("npm"), "npm");
319        assert_eq!(canonical_ecosystem("go"), "Go");
320        assert_eq!(canonical_ecosystem("unknown"), "");
321    }
322}