1use serde::{Deserialize, Serialize};
30
31pub use crate::core::plugins::sandbox::ENV_ALLOWLIST as BASE_ENV_ALLOWLIST;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "snake_case")]
40pub enum NetworkAccess {
41 #[default]
44 None,
45 Full,
47}
48
49impl NetworkAccess {
50 #[must_use]
51 pub fn as_str(self) -> &'static str {
52 match self {
53 Self::None => "none",
54 Self::Full => "full",
55 }
56 }
57
58 #[must_use]
60 pub fn allowed(self) -> bool {
61 matches!(self, Self::Full)
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
67#[serde(rename_all = "snake_case")]
68pub enum FilesystemAccess {
69 #[default]
71 ReadOnly,
72 ReadWrite,
74}
75
76impl FilesystemAccess {
77 #[must_use]
78 pub fn as_str(self) -> &'static str {
79 match self {
80 Self::ReadOnly => "read_only",
81 Self::ReadWrite => "read_write",
82 }
83 }
84
85 #[must_use]
87 pub fn writable(self) -> bool {
88 matches!(self, Self::ReadWrite)
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(untagged)]
113pub enum ExecAccess {
114 Mode(ExecMode),
116 Allowlist(Vec<String>),
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
123#[serde(rename_all = "snake_case")]
124pub enum ExecMode {
125 #[default]
128 None,
129 Full,
131}
132
133impl Default for ExecAccess {
134 fn default() -> Self {
135 Self::Mode(ExecMode::None)
136 }
137}
138
139impl ExecAccess {
140 #[must_use]
144 pub fn allowed(&self) -> bool {
145 match self {
146 Self::Mode(ExecMode::Full) => true,
147 Self::Mode(ExecMode::None) => false,
148 Self::Allowlist(list) => !list.is_empty(),
149 }
150 }
151
152 #[must_use]
155 pub fn is_restricted(&self) -> bool {
156 !matches!(self, Self::Mode(ExecMode::Full))
157 }
158
159 #[must_use]
161 pub fn allowlist(&self) -> &[String] {
162 match self {
163 Self::Allowlist(list) => list,
164 Self::Mode(_) => &[],
165 }
166 }
167
168 #[must_use]
170 fn label(&self) -> String {
171 match self {
172 Self::Mode(ExecMode::None) => "none (no subprocesses)".to_string(),
173 Self::Mode(ExecMode::Full) => "full (any binary)".to_string(),
174 Self::Allowlist(list) if list.is_empty() => "none (empty allowlist)".to_string(),
175 Self::Allowlist(list) => format!("only {}", list.join(", ")),
176 }
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
183#[serde(default)]
184pub struct AddonCapabilities {
185 pub network: NetworkAccess,
187 pub filesystem: FilesystemAccess,
189 pub env: Vec<String>,
193 pub exec: ExecAccess,
197}
198
199impl AddonCapabilities {
200 #[must_use]
203 pub fn is_minimal(&self) -> bool {
204 self.network == NetworkAccess::None
205 && self.filesystem == FilesystemAccess::ReadOnly
206 && self.env.is_empty()
207 && !self.exec.allowed()
208 }
209
210 #[must_use]
212 pub fn exec_allowed(&self) -> bool {
213 self.exec.allowed()
214 }
215
216 #[must_use]
220 pub fn exec_restricted(&self) -> bool {
221 self.exec.is_restricted()
222 }
223
224 #[must_use]
227 pub fn exec_is_blanket(&self) -> bool {
228 matches!(self.exec, ExecAccess::Mode(ExecMode::Full))
229 }
230
231 #[must_use]
233 pub fn network_allowed(&self) -> bool {
234 self.network.allowed()
235 }
236
237 #[must_use]
239 pub fn filesystem_writable(&self) -> bool {
240 self.filesystem.writable()
241 }
242
243 pub fn validate(&self) -> Result<(), String> {
247 for name in &self.env {
248 let n = name.trim();
249 if n.is_empty() || !n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
250 return Err(format!(
251 "capabilities.env entry `{name}` is not a valid environment variable name \
252 (use [A-Za-z0-9_])"
253 ));
254 }
255 }
256 for bin in self.exec.allowlist() {
257 let b = bin.trim();
258 if b.is_empty() || b.contains(char::is_whitespace) {
259 return Err(format!(
260 "capabilities.exec entry `{bin}` is not a valid binary name or path \
261 (no whitespace, non-empty)"
262 ));
263 }
264 }
265 Ok(())
266 }
267
268 #[must_use]
271 pub fn summary(&self) -> Vec<String> {
272 let network = if self.network_allowed() {
273 "full (outbound internet)"
274 } else {
275 "none (egress blocked)"
276 };
277 let filesystem = if self.filesystem_writable() {
278 "read-write"
279 } else {
280 "read-only (+ scratch tmp)"
281 };
282 let env = if self.env.is_empty() {
283 "scrubbed (base allowlist only)".to_string()
284 } else {
285 format!("+ {}", self.env.join(", "))
286 };
287 vec![
288 format!("network: {network}"),
289 format!("filesystem: {filesystem}"),
290 format!("env: {env}"),
291 format!("exec: {}", self.exec.label()),
292 ]
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn default_is_minimal_and_locked_down() {
302 let caps = AddonCapabilities::default();
303 assert!(caps.is_minimal());
304 assert!(!caps.network_allowed());
305 assert!(!caps.filesystem_writable());
306 assert!(caps.env.is_empty());
307 assert!(!caps.exec_allowed());
308 assert!(caps.exec_restricted());
309 }
310
311 #[test]
312 fn parses_declared_block() {
313 let caps: AddonCapabilities = toml::from_str(
314 "network = \"full\"\nfilesystem = \"read_write\"\nenv = [\"GITHUB_TOKEN\"]\n",
315 )
316 .expect("parse");
317 assert!(caps.network_allowed());
318 assert!(caps.filesystem_writable());
319 assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
320 assert!(!caps.is_minimal());
321 assert!(!caps.exec_allowed());
323 }
324
325 #[test]
326 fn parses_exec_modes_and_allowlist() {
327 let full: AddonCapabilities = toml::from_str("exec = \"full\"\n").expect("parse full");
328 assert!(full.exec_allowed());
329 assert!(!full.exec_restricted());
330 assert!(full.exec.allowlist().is_empty());
331
332 let none: AddonCapabilities = toml::from_str("exec = \"none\"\n").expect("parse none");
333 assert!(!none.exec_allowed());
334 assert!(none.exec_restricted());
335
336 let allow: AddonCapabilities =
337 toml::from_str("exec = [\"lean-ctx\", \"git\"]\n").expect("parse allowlist");
338 assert!(allow.exec_allowed());
339 assert!(allow.exec_restricted());
340 assert_eq!(allow.exec.allowlist(), &["lean-ctx", "git"]);
341 assert!(!allow.is_minimal());
342
343 let empty: AddonCapabilities = toml::from_str("exec = []\n").expect("parse empty");
344 assert!(!empty.exec_allowed(), "empty allowlist == none");
345 assert!(empty.exec_restricted());
346 }
347
348 #[test]
349 fn exec_allowlist_rejects_whitespace_entries() {
350 let bad = AddonCapabilities {
351 exec: ExecAccess::Allowlist(vec!["ok-bin".into(), "bad bin".into()]),
352 ..Default::default()
353 };
354 assert!(bad.validate().is_err());
355 let good = AddonCapabilities {
356 exec: ExecAccess::Allowlist(vec!["/usr/bin/git".into(), "lean-ctx".into()]),
357 ..Default::default()
358 };
359 assert!(good.validate().is_ok());
360 }
361
362 #[test]
363 fn empty_block_resolves_to_strictest() {
364 let caps: AddonCapabilities = toml::from_str("").expect("parse");
365 assert!(caps.is_minimal());
366 }
367
368 #[test]
369 fn unknown_enum_value_is_rejected() {
370 let err = toml::from_str::<AddonCapabilities>("network = \"halfway\"\n");
371 assert!(err.is_err(), "unknown network value must fail-closed");
372 }
373
374 #[test]
375 fn validate_rejects_bad_env_names() {
376 let bad = AddonCapabilities {
377 env: vec!["OK_NAME".into(), "bad name".into()],
378 ..Default::default()
379 };
380 assert!(bad.validate().is_err());
381 let good = AddonCapabilities {
382 env: vec!["GITHUB_TOKEN".into(), "API_KEY_2".into()],
383 ..Default::default()
384 };
385 assert!(good.validate().is_ok());
386 }
387
388 #[test]
389 fn summary_always_lists_all_dimensions() {
390 let s = AddonCapabilities::default().summary();
391 assert_eq!(s.len(), 4);
392 assert!(s[0].contains("none"));
393 assert!(s[1].contains("read-only"));
394 assert!(s[2].contains("scrubbed"));
395 assert!(s[3].contains("exec") && s[3].contains("none"));
396
397 let elevated = AddonCapabilities {
398 network: NetworkAccess::Full,
399 filesystem: FilesystemAccess::ReadWrite,
400 env: vec!["TOKEN".into()],
401 exec: ExecAccess::Allowlist(vec!["lean-ctx".into()]),
402 };
403 let s = elevated.summary();
404 assert!(s[0].contains("full"));
405 assert!(s[1].contains("read-write"));
406 assert!(s[2].contains("TOKEN"));
407 assert!(s[3].contains("lean-ctx"));
408 }
409
410 #[test]
411 fn as_str_roundtrips() {
412 assert_eq!(NetworkAccess::None.as_str(), "none");
413 assert_eq!(NetworkAccess::Full.as_str(), "full");
414 assert_eq!(FilesystemAccess::ReadOnly.as_str(), "read_only");
415 assert_eq!(FilesystemAccess::ReadWrite.as_str(), "read_write");
416 }
417}