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
13pub 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(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 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 _ => "", }
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 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}