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}
81
82impl Default for AddonsConfig {
83    fn default() -> Self {
84        Self {
85            policy: AddonPolicy::Open.as_str().to_string(),
86            allowlist: Vec::new(),
87            require_signature: false,
88            sandbox: SandboxMode::Off.as_str().to_string(),
89            block_risky: false,
90            enforce_capabilities: false,
91            metering: true,
92        }
93    }
94}
95
96impl AddonsConfig {
97    /// The parsed install policy.
98    #[must_use]
99    pub fn policy(&self) -> AddonPolicy {
100        AddonPolicy::parse(&self.policy)
101    }
102
103    /// The parsed sandbox mode.
104    #[must_use]
105    pub fn sandbox_mode(&self) -> SandboxMode {
106        SandboxMode::parse(&self.sandbox)
107    }
108
109    fn allows_slug(&self, slug: &str) -> bool {
110        self.allowlist
111            .iter()
112            .any(|a| a.trim().eq_ignore_ascii_case(slug.trim()))
113    }
114}
115
116/// Decide whether `manifest` may be installed under `cfg`, given its risk
117/// `findings` (from [`super::trust::assess`]). Pure + deterministic.
118pub fn gate(
119    manifest: &AddonManifest,
120    cfg: &AddonsConfig,
121    findings: &[RiskFinding],
122) -> Result<(), String> {
123    let name = &manifest.addon.name;
124    match cfg.policy() {
125        AddonPolicy::Open => {}
126        AddonPolicy::VerifiedOnly => {
127            if TrustTier::of(manifest) != TrustTier::Verified {
128                return Err(format!(
129                    "addons.policy = verified_only: `{name}` is community-tier (not maintainer-verified). \
130                     Set addons.policy = open to install community addons."
131                ));
132            }
133        }
134        AddonPolicy::Allowlist => {
135            if !cfg.allows_slug(name) {
136                return Err(format!(
137                    "addons.policy = allowlist: `{name}` is not on addons.allowlist. \
138                     Add it with `lean-ctx config set addons.allowlist <slugs>`."
139                ));
140            }
141        }
142        AddonPolicy::Locked => {
143            return Err(
144                "addons.policy = locked: installing addons is disabled on this machine."
145                    .to_string(),
146            );
147        }
148    }
149
150    if cfg.block_risky
151        && let Some(danger) = findings.iter().find(|f| f.level == RiskLevel::Danger)
152    {
153        return Err(format!(
154            "addons.block_risky is on: `{name}` has a high-risk capability — {} \
155             Review it, then install with addons.block_risky = false if intended.",
156            danger.message
157        ));
158    }
159
160    Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn manifest(name: &str, verified: bool) -> AddonManifest {
168        AddonManifest::from_toml(&format!(
169            "[addon]\nname = \"{name}\"\nverified = {verified}\n\
170             [mcp]\ntransport = \"stdio\"\ncommand = \"x\"\n"
171        ))
172        .expect("parse")
173    }
174
175    #[test]
176    fn default_policy_is_open_and_permissive() {
177        let cfg = AddonsConfig::default();
178        assert_eq!(cfg.policy(), AddonPolicy::Open);
179        assert_eq!(cfg.sandbox_mode(), SandboxMode::Off);
180        assert!(gate(&manifest("x", false), &cfg, &[]).is_ok());
181    }
182
183    #[test]
184    fn policy_parse_is_lenient() {
185        assert_eq!(
186            AddonPolicy::parse("verified-only"),
187            AddonPolicy::VerifiedOnly
188        );
189        assert_eq!(AddonPolicy::parse("LOCKED"), AddonPolicy::Locked);
190        assert_eq!(AddonPolicy::parse("garbage"), AddonPolicy::Open);
191    }
192
193    #[test]
194    fn verified_only_blocks_community() {
195        let cfg = AddonsConfig {
196            policy: "verified_only".into(),
197            ..Default::default()
198        };
199        assert!(gate(&manifest("c", false), &cfg, &[]).is_err());
200        assert!(gate(&manifest("v", true), &cfg, &[]).is_ok());
201    }
202
203    #[test]
204    fn allowlist_only_permits_listed_slugs() {
205        let cfg = AddonsConfig {
206            policy: "allowlist".into(),
207            allowlist: vec!["allowed".into()],
208            ..Default::default()
209        };
210        assert!(gate(&manifest("allowed", false), &cfg, &[]).is_ok());
211        assert!(gate(&manifest("other", false), &cfg, &[]).is_err());
212    }
213
214    #[test]
215    fn locked_blocks_everything() {
216        let cfg = AddonsConfig {
217            policy: "locked".into(),
218            ..Default::default()
219        };
220        assert!(gate(&manifest("v", true), &cfg, &[]).is_err());
221    }
222
223    #[test]
224    fn block_risky_refuses_danger_findings() {
225        let cfg = AddonsConfig {
226            block_risky: true,
227            ..Default::default()
228        };
229        let danger = vec![RiskFinding {
230            level: RiskLevel::Danger,
231            code: "shell_exec",
232            message: "shells out".into(),
233        }];
234        assert!(gate(&manifest("x", false), &cfg, &danger).is_err());
235        // A non-danger finding is fine.
236        let info = vec![RiskFinding {
237            level: RiskLevel::Info,
238            code: "child_env",
239            message: "env".into(),
240        }];
241        assert!(gate(&manifest("x", false), &cfg, &info).is_ok());
242    }
243}