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::mcp_catalog::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/// Commands that fetch + execute a package from a registry, so they inherently
112/// need outbound network and a writable package cache. Superset of
113/// [`RUNNER_BINS`] with the package managers that also `exec` a fetched binary.
114/// Used by the `addon init` scaffold to pick capabilities that don't silently
115/// break the spawn (the secure default `network = none` would sandbox-block an
116/// `npx`/`npm` server — GH #1079).
117const 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/// Whether `command` is a package runner that fetches + executes code from a
126/// registry (`npx`, `uvx`, `npm`, …) and therefore needs network + a writable
127/// package cache to even start. Pure; basename-aware (`/usr/bin/npx` counts).
128#[must_use]
129pub fn command_is_package_runner(command: &str) -> bool {
130    PACKAGE_RUNNER_BINS.contains(&basename(command.trim()))
131}
132
133/// Statically inspect an addon's `[mcp]` wiring. Pure + deterministic; the
134/// returned findings are sorted by descending severity then code so output is
135/// byte-stable (provider prompt-cache friendly, #498).
136#[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            // Shell metacharacters anywhere in args → command injection surface.
184            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            // Unpinned package runner → upstream can change under you.
193            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/// The highest severity among `findings`, if any.
229#[must_use]
230pub fn max_level(findings: &[RiskFinding]) -> Option<RiskLevel> {
231    findings.iter().map(|f| f.level).max()
232}
233
234/// Whether the wiring inherently performs outbound network I/O — an HTTP
235/// transport, or a stdio command that fetches/executes remote code or runs an
236/// unpinned package from a remote registry. The capability audit
237/// ([`super::audit`]) uses this to flag an addon that declares `network = none`
238/// but actually needs the network (an under-declared capability).
239#[must_use]
240pub fn wiring_uses_network(manifest: &AddonManifest) -> bool {
241    // A `[install]` block fetches a package from a registry → needs the network,
242    // regardless of how the resulting `[mcp]` server is launched (#1105).
243    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/// Whether the wiring evidences spawning child processes — the command is a
256/// shell run with `-c`, a fetch/eval primitive, or any argument carries shell
257/// metacharacters that chain to another program. The capability audit
258/// ([`super::audit`]) uses this to flag an addon that declares no `exec`
259/// permission yet clearly shells out (an under-declared capability). HTTP
260/// addons run no local child, so this is stdio-only.
261#[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
300/// A package-runner invocation is "pinned" when some positional arg carries an
301/// explicit version (`pkg@1.2.3`, `pkg==1.2.3`, `pkg:1.2.3`).
302fn 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}