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; default is fully permissive so the
55/// out-of-the-box experience is unchanged.
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. Off
73    /// by default → best-effort (warn + run) so a missing launcher never blocks
74    /// a spawn; orgs that require real enforcement set this to `true`.
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}
87
88impl Default for AddonsConfig {
89    fn default() -> Self {
90        Self {
91            policy: AddonPolicy::Open.as_str().to_string(),
92            allowlist: Vec::new(),
93            require_signature: false,
94            sandbox: SandboxMode::Off.as_str().to_string(),
95            block_risky: false,
96            enforce_capabilities: false,
97            metering: true,
98            allow_bootstrap: true,
99        }
100    }
101}
102
103impl AddonsConfig {
104    /// The parsed install policy.
105    #[must_use]
106    pub fn policy(&self) -> AddonPolicy {
107        AddonPolicy::parse(&self.policy)
108    }
109
110    /// The parsed sandbox mode.
111    #[must_use]
112    pub fn sandbox_mode(&self) -> SandboxMode {
113        SandboxMode::parse(&self.sandbox)
114    }
115
116    fn allows_slug(&self, slug: &str) -> bool {
117        self.allowlist
118            .iter()
119            .any(|a| a.trim().eq_ignore_ascii_case(slug.trim()))
120    }
121}
122
123/// Decide whether `manifest` may be installed under `cfg`, given its risk
124/// `findings` (from [`super::trust::assess`]). Pure + deterministic.
125pub fn gate(
126    manifest: &AddonManifest,
127    cfg: &AddonsConfig,
128    findings: &[RiskFinding],
129) -> Result<(), String> {
130    let name = &manifest.addon.name;
131    match cfg.policy() {
132        AddonPolicy::Open => {}
133        AddonPolicy::VerifiedOnly => {
134            if TrustTier::of(manifest) != TrustTier::Verified {
135                return Err(format!(
136                    "addons.policy = verified_only: `{name}` is community-tier (not maintainer-verified). \
137                     Set addons.policy = open to install community addons."
138                ));
139            }
140        }
141        AddonPolicy::Allowlist => {
142            if !cfg.allows_slug(name) {
143                return Err(format!(
144                    "addons.policy = allowlist: `{name}` is not on addons.allowlist. \
145                     Add it with `lean-ctx config set addons.allowlist <slugs>`."
146                ));
147            }
148        }
149        AddonPolicy::Locked => {
150            return Err(
151                "addons.policy = locked: installing addons is disabled on this machine."
152                    .to_string(),
153            );
154        }
155    }
156
157    // Bootstrap floor (#1105): an addon that runs a package manager on install
158    // is refused when the org disables local bootstrap, before anything runs.
159    if manifest.install.is_declared() && !cfg.allow_bootstrap {
160        return Err(format!(
161            "addons.allow_bootstrap is off: `{name}` installs `{}` via {} on add, but bootstrap \
162             installs are disabled on this machine. Enable with \
163             `lean-ctx config set addons.allow_bootstrap true`, or install `{}` yourself first.",
164            manifest.install.package.trim(),
165            manifest.install.manager.trim(),
166            manifest.install.bin(),
167        ));
168    }
169
170    if cfg.block_risky
171        && let Some(danger) = findings.iter().find(|f| f.level == RiskLevel::Danger)
172    {
173        return Err(format!(
174            "addons.block_risky is on: `{name}` has a high-risk capability — {} \
175             Review it, then install with addons.block_risky = false if intended.",
176            danger.message
177        ));
178    }
179
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn manifest(name: &str, verified: bool) -> AddonManifest {
188        AddonManifest::from_toml(&format!(
189            "[addon]\nname = \"{name}\"\nverified = {verified}\n\
190             [mcp]\ntransport = \"stdio\"\ncommand = \"x\"\n"
191        ))
192        .expect("parse")
193    }
194
195    #[test]
196    fn default_policy_is_open_and_permissive() {
197        let cfg = AddonsConfig::default();
198        assert_eq!(cfg.policy(), AddonPolicy::Open);
199        assert_eq!(cfg.sandbox_mode(), SandboxMode::Off);
200        assert!(gate(&manifest("x", false), &cfg, &[]).is_ok());
201    }
202
203    #[test]
204    fn policy_parse_is_lenient() {
205        assert_eq!(
206            AddonPolicy::parse("verified-only"),
207            AddonPolicy::VerifiedOnly
208        );
209        assert_eq!(AddonPolicy::parse("LOCKED"), AddonPolicy::Locked);
210        assert_eq!(AddonPolicy::parse("garbage"), AddonPolicy::Open);
211    }
212
213    #[test]
214    fn verified_only_blocks_community() {
215        let cfg = AddonsConfig {
216            policy: "verified_only".into(),
217            ..Default::default()
218        };
219        assert!(gate(&manifest("c", false), &cfg, &[]).is_err());
220        assert!(gate(&manifest("v", true), &cfg, &[]).is_ok());
221    }
222
223    #[test]
224    fn allowlist_only_permits_listed_slugs() {
225        let cfg = AddonsConfig {
226            policy: "allowlist".into(),
227            allowlist: vec!["allowed".into()],
228            ..Default::default()
229        };
230        assert!(gate(&manifest("allowed", false), &cfg, &[]).is_ok());
231        assert!(gate(&manifest("other", false), &cfg, &[]).is_err());
232    }
233
234    #[test]
235    fn locked_blocks_everything() {
236        let cfg = AddonsConfig {
237            policy: "locked".into(),
238            ..Default::default()
239        };
240        assert!(gate(&manifest("v", true), &cfg, &[]).is_err());
241    }
242
243    #[test]
244    fn allow_bootstrap_floor_gates_install_blocks() {
245        let with_install = AddonManifest::from_toml(
246            "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
247             [install]\nmanager = \"uv\"\npackage = \"boot-pkg\"\nversion = \"1.0.0\"\n",
248        )
249        .expect("parse");
250
251        // Default (on) → permitted.
252        assert!(gate(&with_install, &AddonsConfig::default(), &[]).is_ok());
253
254        // Off → refused before anything runs.
255        let locked = AddonsConfig {
256            allow_bootstrap: false,
257            ..Default::default()
258        };
259        let err = gate(&with_install, &locked, &[]).expect_err("bootstrap is off");
260        assert!(err.contains("allow_bootstrap"), "got: {err}");
261
262        // An addon with no [install] block is unaffected by the floor.
263        assert!(gate(&manifest("plain", false), &locked, &[]).is_ok());
264    }
265
266    #[test]
267    fn block_risky_refuses_danger_findings() {
268        let cfg = AddonsConfig {
269            block_risky: true,
270            ..Default::default()
271        };
272        let danger = vec![RiskFinding {
273            level: RiskLevel::Danger,
274            code: "shell_exec",
275            message: "shells out".into(),
276        }];
277        assert!(gate(&manifest("x", false), &cfg, &danger).is_err());
278        // A non-danger finding is fine.
279        let info = vec![RiskFinding {
280            level: RiskLevel::Info,
281            code: "child_env",
282            message: "env".into(),
283        }];
284        assert!(gate(&manifest("x", false), &cfg, &info).is_ok());
285    }
286}