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::Off.as_str().to_string(),
102 block_risky: false,
103 enforce_capabilities: false,
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_and_permissive() {
205 let cfg = AddonsConfig::default();
206 assert_eq!(cfg.policy(), AddonPolicy::Open);
207 assert_eq!(cfg.sandbox_mode(), SandboxMode::Off);
208 assert!(gate(&manifest("x", false), &cfg, &[]).is_ok());
209 }
210
211 #[test]
212 fn policy_parse_is_lenient() {
213 assert_eq!(
214 AddonPolicy::parse("verified-only"),
215 AddonPolicy::VerifiedOnly
216 );
217 assert_eq!(AddonPolicy::parse("LOCKED"), AddonPolicy::Locked);
218 assert_eq!(AddonPolicy::parse("garbage"), AddonPolicy::Open);
219 }
220
221 #[test]
222 fn verified_only_blocks_community() {
223 let cfg = AddonsConfig {
224 policy: "verified_only".into(),
225 ..Default::default()
226 };
227 assert!(gate(&manifest("c", false), &cfg, &[]).is_err());
228 assert!(gate(&manifest("v", true), &cfg, &[]).is_ok());
229 }
230
231 #[test]
232 fn allowlist_only_permits_listed_slugs() {
233 let cfg = AddonsConfig {
234 policy: "allowlist".into(),
235 allowlist: vec!["allowed".into()],
236 ..Default::default()
237 };
238 assert!(gate(&manifest("allowed", false), &cfg, &[]).is_ok());
239 assert!(gate(&manifest("other", false), &cfg, &[]).is_err());
240 }
241
242 #[test]
243 fn locked_blocks_everything() {
244 let cfg = AddonsConfig {
245 policy: "locked".into(),
246 ..Default::default()
247 };
248 assert!(gate(&manifest("v", true), &cfg, &[]).is_err());
249 }
250
251 #[test]
252 fn allow_bootstrap_floor_gates_install_blocks() {
253 let with_install = AddonManifest::from_toml(
254 "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
255 [install]\nmanager = \"uv\"\npackage = \"boot-pkg\"\nversion = \"1.0.0\"\n",
256 )
257 .expect("parse");
258
259 assert!(gate(&with_install, &AddonsConfig::default(), &[]).is_ok());
261
262 let locked = AddonsConfig {
264 allow_bootstrap: false,
265 ..Default::default()
266 };
267 let err = gate(&with_install, &locked, &[]).expect_err("bootstrap is off");
268 assert!(err.contains("allow_bootstrap"), "got: {err}");
269
270 assert!(gate(&manifest("plain", false), &locked, &[]).is_ok());
272 }
273
274 #[test]
275 fn block_risky_refuses_danger_findings() {
276 let cfg = AddonsConfig {
277 block_risky: true,
278 ..Default::default()
279 };
280 let danger = vec![RiskFinding {
281 level: RiskLevel::Danger,
282 code: "shell_exec",
283 message: "shells out".into(),
284 }];
285 assert!(gate(&manifest("x", false), &cfg, &danger).is_err());
286 let info = vec![RiskFinding {
288 level: RiskLevel::Info,
289 code: "child_env",
290 message: "env".into(),
291 }];
292 assert!(gate(&manifest("x", false), &cfg, &info).is_ok());
293 }
294}