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"];
111
112fn basename(cmd: &str) -> &str {
113 cmd.rsplit(['/', '\\']).next().unwrap_or(cmd)
114}
115
116#[must_use]
120pub fn assess(manifest: &AddonManifest) -> Vec<RiskFinding> {
121 let mcp = &manifest.mcp;
122 let mut out: Vec<RiskFinding> = Vec::new();
123
124 match mcp.transport {
125 TransportKind::Http => {
126 let host = host_of(&mcp.url);
127 out.push(RiskFinding::new(
128 RiskLevel::Danger,
129 "remote_endpoint",
130 format!("HTTP transport — your context is sent to a remote endpoint ({host})."),
131 ));
132 if !mcp.url.trim().starts_with("https://") {
133 out.push(RiskFinding::new(
134 RiskLevel::Danger,
135 "insecure_url",
136 "Endpoint is not HTTPS — traffic is unencrypted.",
137 ));
138 }
139 if !mcp.headers.is_empty() {
140 out.push(RiskFinding::new(
141 RiskLevel::Warn,
142 "request_headers",
143 format!(
144 "Sends request headers that may carry credentials: {}.",
145 keys(mcp.headers.keys())
146 ),
147 ));
148 }
149 }
150 TransportKind::Stdio => {
151 let base = basename(mcp.command.trim());
152 if SHELL_BINS.contains(&base) && mcp.args.iter().any(|a| a == "-c") {
153 out.push(RiskFinding::new(
154 RiskLevel::Danger,
155 "shell_exec",
156 format!("Runs an inline shell (`{base} -c …`) — arbitrary command execution."),
157 ));
158 } else if FETCH_BINS.contains(&base) {
159 out.push(RiskFinding::new(
160 RiskLevel::Danger,
161 "fetch_exec",
162 format!("Command is `{base}` — fetches/executes external content at startup."),
163 ));
164 }
165
166 if mcp.args.iter().any(|a| has_shell_meta(a)) {
168 out.push(RiskFinding::new(
169 RiskLevel::Warn,
170 "shell_meta",
171 "Arguments contain shell metacharacters (| ; & $ ` > <).",
172 ));
173 }
174
175 if RUNNER_BINS.contains(&base) && !is_pinned(&mcp.args) {
177 out.push(RiskFinding::new(
178 RiskLevel::Warn,
179 "unpinned",
180 format!(
181 "`{base}` without a pinned version — upstream code can change silently."
182 ),
183 ));
184 }
185 if mcp.args.iter().any(|a| mentions_latest(a)) {
186 out.push(RiskFinding::new(
187 RiskLevel::Warn,
188 "unpinned",
189 "Targets a `latest`/unpinned tag — pin an exact version instead.",
190 ));
191 }
192
193 if !mcp.env.is_empty() {
194 out.push(RiskFinding::new(
195 RiskLevel::Info,
196 "child_env",
197 format!(
198 "Passes environment variables to the child: {}.",
199 keys(mcp.env.keys())
200 ),
201 ));
202 }
203 }
204 }
205
206 out.sort_by(|a, b| b.level.cmp(&a.level).then_with(|| a.code.cmp(b.code)));
207 out.dedup();
208 out
209}
210
211#[must_use]
213pub fn max_level(findings: &[RiskFinding]) -> Option<RiskLevel> {
214 findings.iter().map(|f| f.level).max()
215}
216
217#[must_use]
223pub fn wiring_uses_network(manifest: &AddonManifest) -> bool {
224 match manifest.mcp.transport {
225 TransportKind::Http => true,
226 TransportKind::Stdio => {
227 let base = basename(manifest.mcp.command.trim());
228 FETCH_BINS.contains(&base) || RUNNER_BINS.contains(&base)
229 }
230 }
231}
232
233#[must_use]
240pub fn wiring_spawns_subprocess(manifest: &AddonManifest) -> bool {
241 if manifest.mcp.transport != TransportKind::Stdio {
242 return false;
243 }
244 let base = basename(manifest.mcp.command.trim());
245 let shell_with_c = SHELL_BINS.contains(&base) && manifest.mcp.args.iter().any(|a| a == "-c");
246 shell_with_c
247 || FETCH_BINS.contains(&base)
248 || manifest.mcp.args.iter().any(|a| has_shell_meta(a))
249}
250
251fn keys<'a>(it: impl Iterator<Item = &'a String>) -> String {
252 let mut v: Vec<&str> = it.map(String::as_str).collect();
253 v.sort_unstable();
254 v.join(", ")
255}
256
257fn host_of(url: &str) -> String {
258 url.trim()
259 .split_once("://")
260 .map_or(url, |(_, rest)| rest)
261 .split(['/', '?', '#'])
262 .next()
263 .unwrap_or("")
264 .to_string()
265}
266
267fn has_shell_meta(s: &str) -> bool {
268 s.chars()
269 .any(|c| matches!(c, '|' | ';' | '&' | '`' | '>' | '<'))
270 || s.contains("$(")
271}
272
273fn mentions_latest(arg: &str) -> bool {
274 let a = arg.to_ascii_lowercase();
275 a == "latest" || a.ends_with("@latest") || a.ends_with(":latest")
276}
277
278fn is_pinned(args: &[String]) -> bool {
281 args.iter().filter(|a| !a.starts_with('-')).any(|a| {
282 let body = a.rsplit('/').next().unwrap_or(a);
283 body.contains("==") || body.contains(':') || (body.contains('@') && !body.starts_with('@'))
284 })
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn manifest(toml: &str) -> AddonManifest {
292 AddonManifest::from_toml(toml).expect("parse")
293 }
294
295 #[test]
296 fn trust_tier_from_registry_flag() {
297 let community = manifest("[addon]\nname = \"a\"\n");
298 assert_eq!(TrustTier::of(&community), TrustTier::Community);
299 let verified = manifest("[addon]\nname = \"a\"\nverified = true\n");
300 assert_eq!(TrustTier::of(&verified), TrustTier::Verified);
301 assert_eq!(TrustTier::Verified.label(), "verified");
302 }
303
304 #[test]
305 fn clean_stdio_addon_has_no_danger() {
306 let m = manifest(
307 "[addon]\nname = \"ok\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"my-mcp\"\nargs = [\"serve\"]\n",
308 );
309 let f = assess(&m);
310 assert_eq!(max_level(&f), None, "clean addon → no findings");
311 }
312
313 #[test]
314 fn http_is_danger_and_flags_headers() {
315 let m = manifest(
316 "[addon]\nname = \"r\"\n[mcp]\ntransport = \"http\"\nurl = \"https://x.example/mcp\"\n[mcp.headers]\nAuthorization = \"Bearer x\"\n",
317 );
318 let f = assess(&m);
319 assert_eq!(max_level(&f), Some(RiskLevel::Danger));
320 assert!(f.iter().any(|x| x.code == "remote_endpoint"));
321 assert!(f.iter().any(|x| x.code == "request_headers"));
322 }
323
324 #[test]
325 fn http_non_https_is_insecure() {
326 let m = manifest(
327 "[addon]\nname = \"r\"\n[mcp]\ntransport = \"http\"\nurl = \"http://x.example/mcp\"\n",
328 );
329 assert!(assess(&m).iter().any(|x| x.code == "insecure_url"));
330 }
331
332 #[test]
333 fn shell_exec_is_danger() {
334 let m = manifest(
335 "[addon]\nname = \"s\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"/bin/bash\"\nargs = [\"-c\", \"do-thing\"]\n",
336 );
337 assert!(
338 assess(&m)
339 .iter()
340 .any(|x| x.code == "shell_exec" && x.level == RiskLevel::Danger)
341 );
342 }
343
344 #[test]
345 fn unpinned_runner_warns_pinned_does_not() {
346 let unpinned = manifest(
347 "[addon]\nname = \"u\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"uvx\"\nargs = [\"some-pkg\"]\n",
348 );
349 assert!(assess(&unpinned).iter().any(|x| x.code == "unpinned"));
350
351 let pinned = manifest(
352 "[addon]\nname = \"p\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"uvx\"\nargs = [\"some-pkg==1.2.3\"]\n",
353 );
354 assert!(!assess(&pinned).iter().any(|x| x.code == "unpinned"));
355 }
356
357 #[test]
358 fn latest_tag_warns() {
359 let m = manifest(
360 "[addon]\nname = \"l\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"npx\"\nargs = [\"pkg@latest\"]\n",
361 );
362 assert!(assess(&m).iter().any(|x| x.code == "unpinned"));
363 }
364
365 #[test]
366 fn env_is_info() {
367 let m = manifest(
368 "[addon]\nname = \"e\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"x\"\n[mcp.env]\nTOKEN = \"y\"\n",
369 );
370 let f = assess(&m);
371 assert_eq!(max_level(&f), Some(RiskLevel::Info));
372 assert!(f.iter().any(|x| x.code == "child_env"));
373 }
374
375 #[test]
376 fn findings_are_severity_sorted() {
377 let m = manifest(
378 "[addon]\nname = \"m\"\n[mcp]\ntransport = \"http\"\nurl = \"http://x\"\n[mcp.headers]\nA = \"b\"\n",
379 );
380 let f = assess(&m);
381 for w in f.windows(2) {
382 assert!(w[0].level >= w[1].level, "sorted by descending severity");
383 }
384 }
385}