1use super::manifest::AddonManifest;
15use crate::core::gateway::TransportKind;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TrustTier {
20 Verified,
22 Community,
24}
25
26impl TrustTier {
27 #[must_use]
29 pub fn of(manifest: &AddonManifest) -> Self {
30 if manifest.addon.verified {
31 Self::Verified
32 } else {
33 Self::Community
34 }
35 }
36
37 #[must_use]
39 pub fn label(self) -> &'static str {
40 match self {
41 Self::Verified => "verified",
42 Self::Community => "community",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
49pub enum RiskLevel {
50 Info,
52 Warn,
54 Danger,
56}
57
58impl RiskLevel {
59 #[must_use]
60 pub fn as_str(self) -> &'static str {
61 match self {
62 Self::Info => "info",
63 Self::Warn => "warn",
64 Self::Danger => "danger",
65 }
66 }
67
68 #[must_use]
70 pub fn glyph(self) -> &'static str {
71 match self {
72 Self::Info => "•",
73 Self::Warn => "⚠",
74 Self::Danger => "⛔",
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct RiskFinding {
82 pub level: RiskLevel,
83 pub code: &'static str,
85 pub message: String,
86}
87
88impl RiskFinding {
89 fn new(level: RiskLevel, code: &'static str, message: impl Into<String>) -> Self {
90 Self {
91 level,
92 code,
93 message: message.into(),
94 }
95 }
96
97 #[must_use]
100 pub fn audit(level: RiskLevel, code: &'static str, message: impl Into<String>) -> Self {
101 Self::new(level, code, message)
102 }
103}
104
105const SHELL_BINS: &[&str] = &["sh", "bash", "zsh", "dash", "fish", "ksh"];
107const FETCH_BINS: &[&str] = &["curl", "wget", "eval"];
109const RUNNER_BINS: &[&str] = &["npx", "uvx", "pipx", "bunx", "pnpx"];
111const PACKAGE_RUNNER_BINS: &[&str] = &[
118 "npx", "uvx", "pipx", "bunx", "pnpx", "npm", "pnpm", "yarn", "bun",
119];
120
121fn basename(cmd: &str) -> &str {
122 cmd.rsplit(['/', '\\']).next().unwrap_or(cmd)
123}
124
125#[must_use]
129pub fn command_is_package_runner(command: &str) -> bool {
130 PACKAGE_RUNNER_BINS.contains(&basename(command.trim()))
131}
132
133#[must_use]
137pub fn assess(manifest: &AddonManifest) -> Vec<RiskFinding> {
138 let mcp = &manifest.mcp;
139 let mut out: Vec<RiskFinding> = Vec::new();
140
141 match mcp.transport {
142 TransportKind::Http => {
143 let host = host_of(&mcp.url);
144 out.push(RiskFinding::new(
145 RiskLevel::Danger,
146 "remote_endpoint",
147 format!("HTTP transport — your context is sent to a remote endpoint ({host})."),
148 ));
149 if !mcp.url.trim().starts_with("https://") {
150 out.push(RiskFinding::new(
151 RiskLevel::Danger,
152 "insecure_url",
153 "Endpoint is not HTTPS — traffic is unencrypted.",
154 ));
155 }
156 if !mcp.headers.is_empty() {
157 out.push(RiskFinding::new(
158 RiskLevel::Warn,
159 "request_headers",
160 format!(
161 "Sends request headers that may carry credentials: {}.",
162 keys(mcp.headers.keys())
163 ),
164 ));
165 }
166 }
167 TransportKind::Stdio => {
168 let base = basename(mcp.command.trim());
169 if SHELL_BINS.contains(&base) && mcp.args.iter().any(|a| a == "-c") {
170 out.push(RiskFinding::new(
171 RiskLevel::Danger,
172 "shell_exec",
173 format!("Runs an inline shell (`{base} -c …`) — arbitrary command execution."),
174 ));
175 } else if FETCH_BINS.contains(&base) {
176 out.push(RiskFinding::new(
177 RiskLevel::Danger,
178 "fetch_exec",
179 format!("Command is `{base}` — fetches/executes external content at startup."),
180 ));
181 }
182
183 if mcp.args.iter().any(|a| has_shell_meta(a)) {
185 out.push(RiskFinding::new(
186 RiskLevel::Warn,
187 "shell_meta",
188 "Arguments contain shell metacharacters (| ; & $ ` > <).",
189 ));
190 }
191
192 if RUNNER_BINS.contains(&base) && !is_pinned(&mcp.args) {
194 out.push(RiskFinding::new(
195 RiskLevel::Warn,
196 "unpinned",
197 format!(
198 "`{base}` without a pinned version — upstream code can change silently."
199 ),
200 ));
201 }
202 if mcp.args.iter().any(|a| mentions_latest(a)) {
203 out.push(RiskFinding::new(
204 RiskLevel::Warn,
205 "unpinned",
206 "Targets a `latest`/unpinned tag — pin an exact version instead.",
207 ));
208 }
209
210 if !mcp.env.is_empty() {
211 out.push(RiskFinding::new(
212 RiskLevel::Info,
213 "child_env",
214 format!(
215 "Passes environment variables to the child: {}.",
216 keys(mcp.env.keys())
217 ),
218 ));
219 }
220 }
221 }
222
223 out.sort_by(|a, b| b.level.cmp(&a.level).then_with(|| a.code.cmp(b.code)));
224 out.dedup();
225 out
226}
227
228#[must_use]
230pub fn max_level(findings: &[RiskFinding]) -> Option<RiskLevel> {
231 findings.iter().map(|f| f.level).max()
232}
233
234#[must_use]
240pub fn wiring_uses_network(manifest: &AddonManifest) -> bool {
241 if manifest.install.is_declared() {
244 return true;
245 }
246 match manifest.mcp.transport {
247 TransportKind::Http => true,
248 TransportKind::Stdio => {
249 let base = basename(manifest.mcp.command.trim());
250 FETCH_BINS.contains(&base) || RUNNER_BINS.contains(&base)
251 }
252 }
253}
254
255#[must_use]
262pub fn wiring_spawns_subprocess(manifest: &AddonManifest) -> bool {
263 if manifest.mcp.transport != TransportKind::Stdio {
264 return false;
265 }
266 let base = basename(manifest.mcp.command.trim());
267 let shell_with_c = SHELL_BINS.contains(&base) && manifest.mcp.args.iter().any(|a| a == "-c");
268 shell_with_c
269 || FETCH_BINS.contains(&base)
270 || manifest.mcp.args.iter().any(|a| has_shell_meta(a))
271}
272
273fn keys<'a>(it: impl Iterator<Item = &'a String>) -> String {
274 let mut v: Vec<&str> = it.map(String::as_str).collect();
275 v.sort_unstable();
276 v.join(", ")
277}
278
279fn host_of(url: &str) -> String {
280 url.trim()
281 .split_once("://")
282 .map_or(url, |(_, rest)| rest)
283 .split(['/', '?', '#'])
284 .next()
285 .unwrap_or("")
286 .to_string()
287}
288
289fn has_shell_meta(s: &str) -> bool {
290 s.chars()
291 .any(|c| matches!(c, '|' | ';' | '&' | '`' | '>' | '<'))
292 || s.contains("$(")
293}
294
295fn mentions_latest(arg: &str) -> bool {
296 let a = arg.to_ascii_lowercase();
297 a == "latest" || a.ends_with("@latest") || a.ends_with(":latest")
298}
299
300fn is_pinned(args: &[String]) -> bool {
303 args.iter().filter(|a| !a.starts_with('-')).any(|a| {
304 let body = a.rsplit('/').next().unwrap_or(a);
305 body.contains("==") || body.contains(':') || (body.contains('@') && !body.starts_with('@'))
306 })
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn manifest(toml: &str) -> AddonManifest {
314 AddonManifest::from_toml(toml).expect("parse")
315 }
316
317 #[test]
318 fn trust_tier_from_registry_flag() {
319 let community = manifest("[addon]\nname = \"a\"\n");
320 assert_eq!(TrustTier::of(&community), TrustTier::Community);
321 let verified = manifest("[addon]\nname = \"a\"\nverified = true\n");
322 assert_eq!(TrustTier::of(&verified), TrustTier::Verified);
323 assert_eq!(TrustTier::Verified.label(), "verified");
324 }
325
326 #[test]
327 fn clean_stdio_addon_has_no_danger() {
328 let m = manifest(
329 "[addon]\nname = \"ok\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"my-mcp\"\nargs = [\"serve\"]\n",
330 );
331 let f = assess(&m);
332 assert_eq!(max_level(&f), None, "clean addon → no findings");
333 }
334
335 #[test]
336 fn http_is_danger_and_flags_headers() {
337 let m = manifest(
338 "[addon]\nname = \"r\"\n[mcp]\ntransport = \"http\"\nurl = \"https://x.example/mcp\"\n[mcp.headers]\nAuthorization = \"Bearer x\"\n",
339 );
340 let f = assess(&m);
341 assert_eq!(max_level(&f), Some(RiskLevel::Danger));
342 assert!(f.iter().any(|x| x.code == "remote_endpoint"));
343 assert!(f.iter().any(|x| x.code == "request_headers"));
344 }
345
346 #[test]
347 fn http_non_https_is_insecure() {
348 let m = manifest(
349 "[addon]\nname = \"r\"\n[mcp]\ntransport = \"http\"\nurl = \"http://x.example/mcp\"\n",
350 );
351 assert!(assess(&m).iter().any(|x| x.code == "insecure_url"));
352 }
353
354 #[test]
355 fn shell_exec_is_danger() {
356 let m = manifest(
357 "[addon]\nname = \"s\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"/bin/bash\"\nargs = [\"-c\", \"do-thing\"]\n",
358 );
359 assert!(
360 assess(&m)
361 .iter()
362 .any(|x| x.code == "shell_exec" && x.level == RiskLevel::Danger)
363 );
364 }
365
366 #[test]
367 fn unpinned_runner_warns_pinned_does_not() {
368 let unpinned = manifest(
369 "[addon]\nname = \"u\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"uvx\"\nargs = [\"some-pkg\"]\n",
370 );
371 assert!(assess(&unpinned).iter().any(|x| x.code == "unpinned"));
372
373 let pinned = manifest(
374 "[addon]\nname = \"p\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"uvx\"\nargs = [\"some-pkg==1.2.3\"]\n",
375 );
376 assert!(!assess(&pinned).iter().any(|x| x.code == "unpinned"));
377 }
378
379 #[test]
380 fn latest_tag_warns() {
381 let m = manifest(
382 "[addon]\nname = \"l\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"npx\"\nargs = [\"pkg@latest\"]\n",
383 );
384 assert!(assess(&m).iter().any(|x| x.code == "unpinned"));
385 }
386
387 #[test]
388 fn env_is_info() {
389 let m = manifest(
390 "[addon]\nname = \"e\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"x\"\n[mcp.env]\nTOKEN = \"y\"\n",
391 );
392 let f = assess(&m);
393 assert_eq!(max_level(&f), Some(RiskLevel::Info));
394 assert!(f.iter().any(|x| x.code == "child_env"));
395 }
396
397 #[test]
398 fn findings_are_severity_sorted() {
399 let m = manifest(
400 "[addon]\nname = \"m\"\n[mcp]\ntransport = \"http\"\nurl = \"http://x\"\n[mcp.headers]\nA = \"b\"\n",
401 );
402 let f = assess(&m);
403 for w in f.windows(2) {
404 assert!(w[0].level >= w[1].level, "sorted by descending severity");
405 }
406 }
407}