use serde::{Deserialize, Serialize};
pub use crate::core::plugins::sandbox::ENV_ALLOWLIST as BASE_ENV_ALLOWLIST;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum NetworkAccess {
#[default]
None,
Full,
}
impl NetworkAccess {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Full => "full",
}
}
#[must_use]
pub fn allowed(self) -> bool {
matches!(self, Self::Full)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FilesystemAccess {
#[default]
ReadOnly,
ReadWrite,
}
impl FilesystemAccess {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read_only",
Self::ReadWrite => "read_write",
}
}
#[must_use]
pub fn writable(self) -> bool {
matches!(self, Self::ReadWrite)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ExecAccess {
Mode(ExecMode),
Allowlist(Vec<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ExecMode {
#[default]
None,
Full,
}
impl Default for ExecAccess {
fn default() -> Self {
Self::Mode(ExecMode::None)
}
}
impl ExecAccess {
#[must_use]
pub fn allowed(&self) -> bool {
match self {
Self::Mode(ExecMode::Full) => true,
Self::Mode(ExecMode::None) => false,
Self::Allowlist(list) => !list.is_empty(),
}
}
#[must_use]
pub fn is_restricted(&self) -> bool {
!matches!(self, Self::Mode(ExecMode::Full))
}
#[must_use]
pub fn allowlist(&self) -> &[String] {
match self {
Self::Allowlist(list) => list,
Self::Mode(_) => &[],
}
}
#[must_use]
fn label(&self) -> String {
match self {
Self::Mode(ExecMode::None) => "none (no subprocesses)".to_string(),
Self::Mode(ExecMode::Full) => "full (any binary)".to_string(),
Self::Allowlist(list) if list.is_empty() => "none (empty allowlist)".to_string(),
Self::Allowlist(list) => format!("only {}", list.join(", ")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AddonCapabilities {
pub network: NetworkAccess,
pub filesystem: FilesystemAccess,
pub env: Vec<String>,
pub exec: ExecAccess,
}
impl AddonCapabilities {
#[must_use]
pub fn is_minimal(&self) -> bool {
self.network == NetworkAccess::None
&& self.filesystem == FilesystemAccess::ReadOnly
&& self.env.is_empty()
&& !self.exec.allowed()
}
#[must_use]
pub fn exec_allowed(&self) -> bool {
self.exec.allowed()
}
#[must_use]
pub fn exec_restricted(&self) -> bool {
self.exec.is_restricted()
}
#[must_use]
pub fn exec_is_blanket(&self) -> bool {
matches!(self.exec, ExecAccess::Mode(ExecMode::Full))
}
#[must_use]
pub fn network_allowed(&self) -> bool {
self.network.allowed()
}
#[must_use]
pub fn filesystem_writable(&self) -> bool {
self.filesystem.writable()
}
pub fn validate(&self) -> Result<(), String> {
for name in &self.env {
let n = name.trim();
if n.is_empty() || !n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(format!(
"capabilities.env entry `{name}` is not a valid environment variable name \
(use [A-Za-z0-9_])"
));
}
}
for bin in self.exec.allowlist() {
let b = bin.trim();
if b.is_empty() || b.contains(char::is_whitespace) {
return Err(format!(
"capabilities.exec entry `{bin}` is not a valid binary name or path \
(no whitespace, non-empty)"
));
}
}
Ok(())
}
#[must_use]
pub fn summary(&self) -> Vec<String> {
let network = if self.network_allowed() {
"full (outbound internet)"
} else {
"none (egress blocked)"
};
let filesystem = if self.filesystem_writable() {
"read-write"
} else {
"read-only (+ scratch tmp)"
};
let env = if self.env.is_empty() {
"scrubbed (base allowlist only)".to_string()
} else {
format!("+ {}", self.env.join(", "))
};
vec![
format!("network: {network}"),
format!("filesystem: {filesystem}"),
format!("env: {env}"),
format!("exec: {}", self.exec.label()),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_minimal_and_locked_down() {
let caps = AddonCapabilities::default();
assert!(caps.is_minimal());
assert!(!caps.network_allowed());
assert!(!caps.filesystem_writable());
assert!(caps.env.is_empty());
assert!(!caps.exec_allowed());
assert!(caps.exec_restricted());
}
#[test]
fn parses_declared_block() {
let caps: AddonCapabilities = toml::from_str(
"network = \"full\"\nfilesystem = \"read_write\"\nenv = [\"GITHUB_TOKEN\"]\n",
)
.expect("parse");
assert!(caps.network_allowed());
assert!(caps.filesystem_writable());
assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
assert!(!caps.is_minimal());
assert!(!caps.exec_allowed());
}
#[test]
fn parses_exec_modes_and_allowlist() {
let full: AddonCapabilities = toml::from_str("exec = \"full\"\n").expect("parse full");
assert!(full.exec_allowed());
assert!(!full.exec_restricted());
assert!(full.exec.allowlist().is_empty());
let none: AddonCapabilities = toml::from_str("exec = \"none\"\n").expect("parse none");
assert!(!none.exec_allowed());
assert!(none.exec_restricted());
let allow: AddonCapabilities =
toml::from_str("exec = [\"lean-ctx\", \"git\"]\n").expect("parse allowlist");
assert!(allow.exec_allowed());
assert!(allow.exec_restricted());
assert_eq!(allow.exec.allowlist(), &["lean-ctx", "git"]);
assert!(!allow.is_minimal());
let empty: AddonCapabilities = toml::from_str("exec = []\n").expect("parse empty");
assert!(!empty.exec_allowed(), "empty allowlist == none");
assert!(empty.exec_restricted());
}
#[test]
fn exec_allowlist_rejects_whitespace_entries() {
let bad = AddonCapabilities {
exec: ExecAccess::Allowlist(vec!["ok-bin".into(), "bad bin".into()]),
..Default::default()
};
assert!(bad.validate().is_err());
let good = AddonCapabilities {
exec: ExecAccess::Allowlist(vec!["/usr/bin/git".into(), "lean-ctx".into()]),
..Default::default()
};
assert!(good.validate().is_ok());
}
#[test]
fn empty_block_resolves_to_strictest() {
let caps: AddonCapabilities = toml::from_str("").expect("parse");
assert!(caps.is_minimal());
}
#[test]
fn unknown_enum_value_is_rejected() {
let err = toml::from_str::<AddonCapabilities>("network = \"halfway\"\n");
assert!(err.is_err(), "unknown network value must fail-closed");
}
#[test]
fn validate_rejects_bad_env_names() {
let bad = AddonCapabilities {
env: vec!["OK_NAME".into(), "bad name".into()],
..Default::default()
};
assert!(bad.validate().is_err());
let good = AddonCapabilities {
env: vec!["GITHUB_TOKEN".into(), "API_KEY_2".into()],
..Default::default()
};
assert!(good.validate().is_ok());
}
#[test]
fn summary_always_lists_all_dimensions() {
let s = AddonCapabilities::default().summary();
assert_eq!(s.len(), 4);
assert!(s[0].contains("none"));
assert!(s[1].contains("read-only"));
assert!(s[2].contains("scrubbed"));
assert!(s[3].contains("exec") && s[3].contains("none"));
let elevated = AddonCapabilities {
network: NetworkAccess::Full,
filesystem: FilesystemAccess::ReadWrite,
env: vec!["TOKEN".into()],
exec: ExecAccess::Allowlist(vec!["lean-ctx".into()]),
};
let s = elevated.summary();
assert!(s[0].contains("full"));
assert!(s[1].contains("read-write"));
assert!(s[2].contains("TOKEN"));
assert!(s[3].contains("lean-ctx"));
}
#[test]
fn as_str_roundtrips() {
assert_eq!(NetworkAccess::None.as_str(), "none");
assert_eq!(NetworkAccess::Full.as_str(), "full");
assert_eq!(FilesystemAccess::ReadOnly.as_str(), "read_only");
assert_eq!(FilesystemAccess::ReadWrite.as_str(), "read_write");
}
}