Skip to main content

lean_ctx/core/addons/
policy.rs

1//! Install policy for addons — the org-controllable floor (#865).
2//!
3//! [`AddonsConfig`] is the `[addons]` config block. Like `[gateway]`, it is
4//! **global-only** (never merged from a project-local `.lean-ctx.toml`), so a
5//! cloned, untrusted repo cannot loosen it; an org distributes it via
6//! MDM / config-management or pins it through the signed org-policy floor.
7//!
8//! [`gate`] is the single enforcement point, called by [`super::install`] before
9//! any addon is wired into the gateway. It is pure (config + findings in,
10//! verdict out) and fully unit-tested.
11
12use serde::{Deserialize, Serialize};
13
14use super::manifest::AddonManifest;
15use super::sandbox::SandboxMode;
16use super::trust::{RiskFinding, RiskLevel, TrustTier};
17
18/// What the endpoint allows to be installed.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum AddonPolicy {
21    /// Any registry addon may be installed (default — friction-free).
22    #[default]
23    Open,
24    /// Only `verified` (maintainer-vouched) addons may be installed.
25    VerifiedOnly,
26    /// Only addons whose slug is on [`AddonsConfig::allowlist`].
27    Allowlist,
28    /// Installing addons is disabled entirely.
29    Locked,
30}
31
32impl AddonPolicy {
33    #[must_use]
34    pub fn parse(s: &str) -> Self {
35        match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
36            "verified_only" | "verified" => Self::VerifiedOnly,
37            "allowlist" => Self::Allowlist,
38            "locked" | "off" | "disabled" => Self::Locked,
39            _ => Self::Open,
40        }
41    }
42
43    #[must_use]
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::Open => "open",
47            Self::VerifiedOnly => "verified_only",
48            Self::Allowlist => "allowlist",
49            Self::Locked => "locked",
50        }
51    }
52}
53
54/// `[addons]` configuration. Global-only; defaults preserve open installation
55/// while applying available sandboxing and capability safeguards.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct AddonsConfig {
59    /// Install policy: `open` | `verified_only` | `allowlist` | `locked`.
60    pub policy: String,
61    /// Slugs permitted when `policy = allowlist`.
62    pub allowlist: Vec<String>,
63    /// Honour a user-override registry (`<data_dir>/addon_registry.json`) only
64    /// when it carries a valid signature by a trusted org key.
65    pub require_signature: bool,
66    /// Sandbox spawned stdio servers without a declared `[capabilities]` block:
67    /// `off` | `auto` | `strict` (the legacy global mode).
68    pub sandbox: String,
69    /// Refuse to install an addon that has a high-risk (`Danger`) capability.
70    pub block_risky: bool,
71    /// Fail closed when an addon declares restricted `[capabilities]` but no OS
72    /// sandbox launcher (sandbox-exec / bwrap) is available to enforce them. On
73    /// by default so restricted capabilities fail closed; set this to `false` for
74    /// best-effort enforcement when a missing launcher must not block a spawn.
75    pub enforce_capabilities: bool,
76    /// Record per-addon / per-tool gateway usage counters to
77    /// `<data_dir>/addons/usage.json` (local-only; basis for analytics + billing,
78    /// P5). On by default; set `false` to disable all usage accounting.
79    pub metering: bool,
80    /// Allow `addon add` to provision an addon's upstream package via a pinned
81    /// package manager (uv/pip/cargo/npm/brew/dotnet) — the `[install]` block (#1105).
82    /// On by default: `add` is the user's explicit, consented action, and the
83    /// bootstrap is fully disclosed + pinned + audited before it runs. An org
84    /// that forbids local package-manager execution sets this to `false`.
85    pub allow_bootstrap: bool,
86    /// Zero-config grammar-addon fetch (#690): transparently download a
87    /// SHA-256-pinned grammar dylib on first use of a registry-covered file
88    /// extension. On by default (a grammar addon is a parsing fallback, not a
89    /// spawned server). Orgs with a strict egress/DLP posture set this to
90    /// `false` — reads then degrade to the regex-signature fallback, exactly
91    /// like offline. `policy = locked` implies the same.
92    pub grammar_auto_fetch: bool,
93}
94
95impl Default for AddonsConfig {
96    fn default() -> Self {
97        Self {
98            policy: AddonPolicy::Open.as_str().to_string(),
99            allowlist: Vec::new(),
100            require_signature: false,
101            sandbox: SandboxMode::Auto.as_str().to_string(),
102            block_risky: true,
103            enforce_capabilities: true,
104            metering: true,
105            allow_bootstrap: true,
106            grammar_auto_fetch: true,
107        }
108    }
109}
110
111impl AddonsConfig {
112    /// The parsed install policy.
113    #[must_use]
114    pub fn policy(&self) -> AddonPolicy {
115        AddonPolicy::parse(&self.policy)
116    }
117
118    /// The parsed sandbox mode.
119    #[must_use]
120    pub fn sandbox_mode(&self) -> SandboxMode {
121        SandboxMode::parse(&self.sandbox)
122    }
123
124    fn allows_slug(&self, slug: &str) -> bool {
125        self.allowlist
126            .iter()
127            .any(|a| a.trim().eq_ignore_ascii_case(slug.trim()))
128    }
129}
130
131/// Decide whether `manifest` may be installed under `cfg`, given its risk
132/// `findings` (from [`super::trust::assess`]). Pure + deterministic.
133pub fn gate(
134    manifest: &AddonManifest,
135    cfg: &AddonsConfig,
136    findings: &[RiskFinding],
137) -> Result<(), String> {
138    let name = &manifest.addon.name;
139    match cfg.policy() {
140        AddonPolicy::Open => {}
141        AddonPolicy::VerifiedOnly => {
142            if TrustTier::of(manifest) != TrustTier::Verified {
143                return Err(format!(
144                    "addons.policy = verified_only: `{name}` is community-tier (not maintainer-verified). \
145                     Set addons.policy = open to install community addons."
146                ));
147            }
148        }
149        AddonPolicy::Allowlist => {
150            if !cfg.allows_slug(name) {
151                return Err(format!(
152                    "addons.policy = allowlist: `{name}` is not on addons.allowlist. \
153                     Add it with `lean-ctx config set addons.allowlist <slugs>`."
154                ));
155            }
156        }
157        AddonPolicy::Locked => {
158            return Err(
159                "addons.policy = locked: installing addons is disabled on this machine."
160                    .to_string(),
161            );
162        }
163    }
164
165    // Bootstrap floor (#1105): an addon that runs a package manager on install
166    // is refused when the org disables local bootstrap, before anything runs.
167    if manifest.install.is_declared() && !cfg.allow_bootstrap {
168        return Err(format!(
169            "addons.allow_bootstrap is off: `{name}` installs `{}` via {} on add, but bootstrap \
170             installs are disabled on this machine. Enable with \
171             `lean-ctx config set addons.allow_bootstrap true`, or install `{}` yourself first.",
172            manifest.install.package.trim(),
173            manifest.install.manager.trim(),
174            manifest.install.bin(),
175        ));
176    }
177
178    if cfg.block_risky
179        && let Some(danger) = findings.iter().find(|f| f.level == RiskLevel::Danger)
180    {
181        return Err(format!(
182            "addons.block_risky is on: `{name}` has a high-risk capability — {} \
183             Review it, then install with addons.block_risky = false if intended.",
184            danger.message
185        ));
186    }
187
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn manifest(name: &str, verified: bool) -> AddonManifest {
196        AddonManifest::from_toml(&format!(
197            "[addon]\nname = \"{name}\"\nverified = {verified}\n\
198             [mcp]\ntransport = \"stdio\"\ncommand = \"x\"\n"
199        ))
200        .expect("parse")
201    }
202
203    #[test]
204    fn default_policy_is_open() {
205        let cfg = AddonsConfig::default();
206        assert_eq!(cfg.policy(), AddonPolicy::Open);
207        assert_eq!(cfg.sandbox_mode(), SandboxMode::Auto);
208        assert!(gate(&manifest("x", false), &cfg, &[]).is_ok());
209    }
210
211    #[test]
212    fn default_is_secure_by_default() {
213        let cfg = AddonsConfig::default();
214        assert!(cfg.block_risky);
215        assert!(cfg.enforce_capabilities);
216        assert_eq!(cfg.sandbox_mode(), SandboxMode::Auto);
217    }
218
219    #[test]
220    fn policy_parse_is_lenient() {
221        assert_eq!(
222            AddonPolicy::parse("verified-only"),
223            AddonPolicy::VerifiedOnly
224        );
225        assert_eq!(AddonPolicy::parse("LOCKED"), AddonPolicy::Locked);
226        assert_eq!(AddonPolicy::parse("garbage"), AddonPolicy::Open);
227    }
228
229    #[test]
230    fn verified_only_blocks_community() {
231        let cfg = AddonsConfig {
232            policy: "verified_only".into(),
233            ..Default::default()
234        };
235        assert!(gate(&manifest("c", false), &cfg, &[]).is_err());
236        assert!(gate(&manifest("v", true), &cfg, &[]).is_ok());
237    }
238
239    #[test]
240    fn allowlist_only_permits_listed_slugs() {
241        let cfg = AddonsConfig {
242            policy: "allowlist".into(),
243            allowlist: vec!["allowed".into()],
244            ..Default::default()
245        };
246        assert!(gate(&manifest("allowed", false), &cfg, &[]).is_ok());
247        assert!(gate(&manifest("other", false), &cfg, &[]).is_err());
248    }
249
250    #[test]
251    fn locked_blocks_everything() {
252        let cfg = AddonsConfig {
253            policy: "locked".into(),
254            ..Default::default()
255        };
256        assert!(gate(&manifest("v", true), &cfg, &[]).is_err());
257    }
258
259    #[test]
260    fn allow_bootstrap_floor_gates_install_blocks() {
261        let with_install = AddonManifest::from_toml(
262            "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
263             [install]\nmanager = \"uv\"\npackage = \"boot-pkg\"\nversion = \"1.0.0\"\n",
264        )
265        .expect("parse");
266
267        // Default (on) → permitted.
268        assert!(gate(&with_install, &AddonsConfig::default(), &[]).is_ok());
269
270        // Off → refused before anything runs.
271        let locked = AddonsConfig {
272            allow_bootstrap: false,
273            ..Default::default()
274        };
275        let err = gate(&with_install, &locked, &[]).expect_err("bootstrap is off");
276        assert!(err.contains("allow_bootstrap"), "got: {err}");
277
278        // An addon with no [install] block is unaffected by the floor.
279        assert!(gate(&manifest("plain", false), &locked, &[]).is_ok());
280    }
281
282    #[test]
283    fn block_risky_refuses_danger_findings() {
284        let cfg = AddonsConfig {
285            block_risky: true,
286            ..Default::default()
287        };
288        let danger = vec![RiskFinding {
289            level: RiskLevel::Danger,
290            code: "shell_exec",
291            message: "shells out".into(),
292        }];
293        assert!(gate(&manifest("x", false), &cfg, &danger).is_err());
294        // A non-danger finding is fine.
295        let info = vec![RiskFinding {
296            level: RiskLevel::Info,
297            code: "child_env",
298            message: "env".into(),
299        }];
300        assert!(gate(&manifest("x", false), &cfg, &info).is_ok());
301    }
302}