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 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 #[must_use]
114 pub fn policy(&self) -> AddonPolicy {
115 AddonPolicy::parse(&self.policy)
116 }
117
118 #[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
131pub 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 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 assert!(gate(&with_install, &AddonsConfig::default(), &[]).is_ok());
269
270 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 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 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}