Skip to main content

lean_ctx/core/addons/
trust.rs

1//! Trust tiers + static risk assessment for addons (#864).
2//!
3//! Two orthogonal questions about an addon:
4//!
5//! 1. **Trust tier** — *who vouches for it?* [`TrustTier`] is conferred by the
6//!    curated registry (`addon.verified`), never by the entry claiming it.
7//! 2. **Risk** — *what does its wiring actually do?* [`assess`] statically
8//!    inspects the `[mcp]` block for signals that warrant a louder warning
9//!    (remote endpoints, shelling out, unpinned upstreams, secret-bearing env).
10//!
11//! Both are pure + deterministic so the CLI preview, the registry validator and
12//! the install policy gate all read from one source of truth.
13
14use super::manifest::AddonManifest;
15use crate::core::gateway::TransportKind;
16
17/// How much an addon is trusted — set by the registry it ships in.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TrustTier {
20    /// Audited and vouched for by maintainers (`addon.verified = true`).
21    Verified,
22    /// Community-submitted: installable, but unaudited. The default.
23    Community,
24}
25
26impl TrustTier {
27    /// The tier the registry confers on this entry.
28    #[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    /// Lower-case label for CLI / website (`verified` / `community`).
38    #[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/// Severity of a [`RiskFinding`], ordered `Info < Warn < Danger`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
49pub enum RiskLevel {
50    /// Worth disclosing, not alarming (e.g. passes env vars).
51    Info,
52    /// Deserves a second look before installing (e.g. unpinned upstream).
53    Warn,
54    /// High-impact capability (e.g. shells out, remote endpoint).
55    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    /// A glyph for the CLI preview.
69    #[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/// One observation about an addon's wiring.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct RiskFinding {
82    pub level: RiskLevel,
83    /// Stable machine code (for tests / the validator), e.g. `"shell_exec"`.
84    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    /// Construct a finding from a sibling auditor (the capability/malware audit
98    /// in [`super::audit`]) so all findings share one type + rendering.
99    #[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
105/// Executables that hand an addon an arbitrary-code primitive.
106const SHELL_BINS: &[&str] = &["sh", "bash", "zsh", "dash", "fish", "ksh"];
107/// Fetch-and-run / eval primitives worth flagging when used as the command.
108const FETCH_BINS: &[&str] = &["curl", "wget", "eval"];
109/// Package runners that execute remote code; risky when unpinned.
110const RUNNER_BINS: &[&str] = &["npx", "uvx", "pipx", "bunx", "pnpx"];
111
112fn basename(cmd: &str) -> &str {
113    cmd.rsplit(['/', '\\']).next().unwrap_or(cmd)
114}
115
116/// Statically inspect an addon's `[mcp]` wiring. Pure + deterministic; the
117/// returned findings are sorted by descending severity then code so output is
118/// byte-stable (provider prompt-cache friendly, #498).
119#[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            // Shell metacharacters anywhere in args → command injection surface.
167            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            // Unpinned package runner → upstream can change under you.
176            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/// The highest severity among `findings`, if any.
212#[must_use]
213pub fn max_level(findings: &[RiskFinding]) -> Option<RiskLevel> {
214    findings.iter().map(|f| f.level).max()
215}
216
217/// Whether the wiring inherently performs outbound network I/O — an HTTP
218/// transport, or a stdio command that fetches/executes remote code or runs an
219/// unpinned package from a remote registry. The capability audit
220/// ([`super::audit`]) uses this to flag an addon that declares `network = none`
221/// but actually needs the network (an under-declared capability).
222#[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/// Whether the wiring evidences spawning child processes — the command is a
234/// shell run with `-c`, a fetch/eval primitive, or any argument carries shell
235/// metacharacters that chain to another program. The capability audit
236/// ([`super::audit`]) uses this to flag an addon that declares no `exec`
237/// permission yet clearly shells out (an under-declared capability). HTTP
238/// addons run no local child, so this is stdio-only.
239#[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
278/// A package-runner invocation is "pinned" when some positional arg carries an
279/// explicit version (`pkg@1.2.3`, `pkg==1.2.3`, `pkg:1.2.3`).
280fn 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}