1use serde::{Deserialize, Serialize};
13
14use super::manifest::AddonManifest;
15use super::sandbox::SandboxMode;
16use super::trust::{RiskFinding, RiskLevel, TrustTier};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum AddonPolicy {
21 #[default]
23 Open,
24 VerifiedOnly,
26 Allowlist,
28 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct AddonsConfig {
59 pub policy: String,
61 pub allowlist: Vec<String>,
63 pub require_signature: bool,
66 pub sandbox: String,
69 pub block_risky: bool,
71 pub enforce_capabilities: bool,
76 pub metering: bool,
80 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 #[must_use]
106 pub fn policy(&self) -> AddonPolicy {
107 AddonPolicy::parse(&self.policy)
108 }
109
110 #[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
123pub 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 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 assert!(gate(&with_install, &AddonsConfig::default(), &[]).is_ok());
253
254 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 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 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}