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}
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 #[must_use]
99 pub fn policy(&self) -> AddonPolicy {
100 AddonPolicy::parse(&self.policy)
101 }
102
103 #[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
116pub 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 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}