use serde::{Deserialize, Serialize};
use super::manifest::AddonManifest;
use super::sandbox::SandboxMode;
use super::trust::{RiskFinding, RiskLevel, TrustTier};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AddonPolicy {
#[default]
Open,
VerifiedOnly,
Allowlist,
Locked,
}
impl AddonPolicy {
#[must_use]
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"verified_only" | "verified" => Self::VerifiedOnly,
"allowlist" => Self::Allowlist,
"locked" | "off" | "disabled" => Self::Locked,
_ => Self::Open,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::VerifiedOnly => "verified_only",
Self::Allowlist => "allowlist",
Self::Locked => "locked",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AddonsConfig {
pub policy: String,
pub allowlist: Vec<String>,
pub require_signature: bool,
pub sandbox: String,
pub block_risky: bool,
pub enforce_capabilities: bool,
pub metering: bool,
}
impl Default for AddonsConfig {
fn default() -> Self {
Self {
policy: AddonPolicy::Open.as_str().to_string(),
allowlist: Vec::new(),
require_signature: false,
sandbox: SandboxMode::Off.as_str().to_string(),
block_risky: false,
enforce_capabilities: false,
metering: true,
}
}
}
impl AddonsConfig {
#[must_use]
pub fn policy(&self) -> AddonPolicy {
AddonPolicy::parse(&self.policy)
}
#[must_use]
pub fn sandbox_mode(&self) -> SandboxMode {
SandboxMode::parse(&self.sandbox)
}
fn allows_slug(&self, slug: &str) -> bool {
self.allowlist
.iter()
.any(|a| a.trim().eq_ignore_ascii_case(slug.trim()))
}
}
pub fn gate(
manifest: &AddonManifest,
cfg: &AddonsConfig,
findings: &[RiskFinding],
) -> Result<(), String> {
let name = &manifest.addon.name;
match cfg.policy() {
AddonPolicy::Open => {}
AddonPolicy::VerifiedOnly => {
if TrustTier::of(manifest) != TrustTier::Verified {
return Err(format!(
"addons.policy = verified_only: `{name}` is community-tier (not maintainer-verified). \
Set addons.policy = open to install community addons."
));
}
}
AddonPolicy::Allowlist => {
if !cfg.allows_slug(name) {
return Err(format!(
"addons.policy = allowlist: `{name}` is not on addons.allowlist. \
Add it with `lean-ctx config set addons.allowlist <slugs>`."
));
}
}
AddonPolicy::Locked => {
return Err(
"addons.policy = locked: installing addons is disabled on this machine."
.to_string(),
);
}
}
if cfg.block_risky
&& let Some(danger) = findings.iter().find(|f| f.level == RiskLevel::Danger)
{
return Err(format!(
"addons.block_risky is on: `{name}` has a high-risk capability — {} \
Review it, then install with addons.block_risky = false if intended.",
danger.message
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(name: &str, verified: bool) -> AddonManifest {
AddonManifest::from_toml(&format!(
"[addon]\nname = \"{name}\"\nverified = {verified}\n\
[mcp]\ntransport = \"stdio\"\ncommand = \"x\"\n"
))
.expect("parse")
}
#[test]
fn default_policy_is_open_and_permissive() {
let cfg = AddonsConfig::default();
assert_eq!(cfg.policy(), AddonPolicy::Open);
assert_eq!(cfg.sandbox_mode(), SandboxMode::Off);
assert!(gate(&manifest("x", false), &cfg, &[]).is_ok());
}
#[test]
fn policy_parse_is_lenient() {
assert_eq!(
AddonPolicy::parse("verified-only"),
AddonPolicy::VerifiedOnly
);
assert_eq!(AddonPolicy::parse("LOCKED"), AddonPolicy::Locked);
assert_eq!(AddonPolicy::parse("garbage"), AddonPolicy::Open);
}
#[test]
fn verified_only_blocks_community() {
let cfg = AddonsConfig {
policy: "verified_only".into(),
..Default::default()
};
assert!(gate(&manifest("c", false), &cfg, &[]).is_err());
assert!(gate(&manifest("v", true), &cfg, &[]).is_ok());
}
#[test]
fn allowlist_only_permits_listed_slugs() {
let cfg = AddonsConfig {
policy: "allowlist".into(),
allowlist: vec!["allowed".into()],
..Default::default()
};
assert!(gate(&manifest("allowed", false), &cfg, &[]).is_ok());
assert!(gate(&manifest("other", false), &cfg, &[]).is_err());
}
#[test]
fn locked_blocks_everything() {
let cfg = AddonsConfig {
policy: "locked".into(),
..Default::default()
};
assert!(gate(&manifest("v", true), &cfg, &[]).is_err());
}
#[test]
fn block_risky_refuses_danger_findings() {
let cfg = AddonsConfig {
block_risky: true,
..Default::default()
};
let danger = vec![RiskFinding {
level: RiskLevel::Danger,
code: "shell_exec",
message: "shells out".into(),
}];
assert!(gate(&manifest("x", false), &cfg, &danger).is_err());
let info = vec![RiskFinding {
level: RiskLevel::Info,
code: "child_env",
message: "env".into(),
}];
assert!(gate(&manifest("x", false), &cfg, &info).is_ok());
}
}