Skip to main content

lean_ctx/core/addons/
capabilities.rs

1//! Declared capability model for addons (P1 — platform keystone).
2//!
3//! An addon's optional `[capabilities]` block tells lean-ctx exactly what the
4//! addon needs: outbound network, filesystem writes, and which host environment
5//! variables it may receive. The declaration is **secure-by-default** — an
6//! addon that declares a `[capabilities]` block but omits a field gets the most
7//! restrictive value (no network, read-only filesystem, scrubbed environment).
8//!
9//! A declared block drives two real, *enforced* controls at the single gateway
10//! spawn point ([`crate::core::gateway::client`]):
11//!
12//! 1. the per-addon OS sandbox profile ([`super::sandbox`]) — network egress and
13//!    filesystem writes are wrapped via `sandbox-exec` (macOS) / `bwrap` (Linux),
14//! 2. the environment allowlist — host secrets never reach the child unless the
15//!    addon explicitly lists the variable name,
16//!
17//! and is surfaced to the user for explicit consent at install time
18//! ([`crate::cli::addon_cmd`]). Child processes inherit the OS sandbox, so a
19//! subprocess an addon spawns is bound by the same network/filesystem limits;
20//! the declared `exec` capability is therefore disclosed + audited rather than
21//! OS-enforced (see [`super::sandbox`]).
22//!
23//! Unlike the legacy blanket `addons.sandbox` mode, this is *per addon* and
24//! bound to the manifest, so a marketplace addon is granted exactly what it
25//! asked for — no more. Addons **without** a `[capabilities]` block keep the
26//! legacy behaviour (governed by `addons.sandbox`) so existing installs do not
27//! change.
28
29use serde::{Deserialize, Serialize};
30
31/// Host environment variables a scrubbed child is always allowed to see, on top
32/// of whatever the addon declares. Chosen to let normal programs start (binary
33/// resolution, locale, temp dir) without exposing ambient secrets. Reuses the
34/// plugin allowlist so the two sandboxes converge on one list (P0).
35pub use crate::core::plugins::sandbox::ENV_ALLOWLIST as BASE_ENV_ALLOWLIST;
36
37/// Outbound-network capability a stdio addon declares.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "snake_case")]
40pub enum NetworkAccess {
41    /// No outbound network. The default — most local tools never need it, and
42    /// blocking egress is the single highest-value sandbox control.
43    #[default]
44    None,
45    /// Full outbound network (the addon talks to the internet / remote APIs).
46    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    /// Whether the OS sandbox should permit outbound network.
59    #[must_use]
60    pub fn allowed(self) -> bool {
61        matches!(self, Self::Full)
62    }
63}
64
65/// Filesystem capability a stdio addon declares.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
67#[serde(rename_all = "snake_case")]
68pub enum FilesystemAccess {
69    /// Read-only filesystem; writes restricted to a scratch tmp. The default.
70    #[default]
71    ReadOnly,
72    /// Read-write filesystem (the addon needs to write outside tmp).
73    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    /// Whether the OS sandbox should permit filesystem writes.
86    #[must_use]
87    pub fn writable(self) -> bool {
88        matches!(self, Self::ReadWrite)
89    }
90}
91
92/// Subprocess-execution capability a stdio addon declares.
93///
94/// Modeled as an untagged enum so the manifest can write either a mode string
95/// or a binary allowlist:
96///
97/// ```toml
98/// exec = "none"              # block all child process execution (default)
99/// exec = "full"             # may execute any binary
100/// exec = ["lean-ctx", "git"] # may execute exactly these binaries (by name/path)
101/// ```
102///
103/// `exec` is a **declared, audited and consented** capability — it is *not*
104/// OS-enforced (see [`super::sandbox`]): path-allowlisting `execve` is not
105/// portable (`bwrap`/seccomp cannot do it) and breaks interpreted servers,
106/// whose own interpreter chain is itself a `process-exec`. The real data-safety
107/// guarantees come from the network/filesystem sandbox, which child processes
108/// inherit — so a subprocess an addon spawns still cannot exfiltrate or tamper.
109/// Declaring `exec` keeps the audit honest (an addon that shells out must say
110/// so) and is surfaced for consent at install on every platform.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(untagged)]
113pub enum ExecAccess {
114    /// A bare mode: `"none"` (block all child exec) or `"full"` (unrestricted).
115    Mode(ExecMode),
116    /// An explicit allowlist of binary names / absolute paths the addon may
117    /// `execve`. An empty list is equivalent to [`ExecMode::None`].
118    Allowlist(Vec<String>),
119}
120
121/// The two bare exec modes (the non-allowlist forms of [`ExecAccess`]).
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
123#[serde(rename_all = "snake_case")]
124pub enum ExecMode {
125    /// No child process execution. The default — most addons never spawn
126    /// subprocesses, and arbitrary `execve` is the highest-impact escape.
127    #[default]
128    None,
129    /// May execute any binary (the legacy, unrestricted behaviour).
130    Full,
131}
132
133impl Default for ExecAccess {
134    fn default() -> Self {
135        Self::Mode(ExecMode::None)
136    }
137}
138
139impl ExecAccess {
140    /// Whether the addon is permitted to execute *any* child process at all.
141    /// `full` and a non-empty allowlist are permissive; `none` / empty list are
142    /// not.
143    #[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    /// Whether this is a restricted declaration (`none` or an allowlist) vs.
153    /// blanket `full`. Drives the audit/consent disclosure, not OS enforcement.
154    #[must_use]
155    pub fn is_restricted(&self) -> bool {
156        !matches!(self, Self::Mode(ExecMode::Full))
157    }
158
159    /// The declared allowlist of binaries, if any (`full`/`none` have none).
160    #[must_use]
161    pub fn allowlist(&self) -> &[String] {
162        match self {
163            Self::Allowlist(list) => list,
164            Self::Mode(_) => &[],
165        }
166    }
167
168    /// Short human label for the consent preview.
169    #[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/// `[capabilities]` — what an addon is permitted to do. A present-but-empty
181/// block resolves to the strictest profile (see module docs). Secure-by-default.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
183#[serde(default)]
184pub struct AddonCapabilities {
185    /// Outbound network access.
186    pub network: NetworkAccess,
187    /// Filesystem access.
188    pub filesystem: FilesystemAccess,
189    /// Host environment variable names the addon may receive, in addition to
190    /// [`BASE_ENV_ALLOWLIST`]. Anything not listed is scrubbed before spawn, so
191    /// ambient secrets never reach the child process.
192    pub env: Vec<String>,
193    /// Subprocess-execution permission. Defaults to [`ExecMode::None`] inside a
194    /// declared block (secure-by-default): an addon that spawns child processes
195    /// (e.g. shells out, or calls back into `lean-ctx call`) must declare it.
196    pub exec: ExecAccess,
197}
198
199impl AddonCapabilities {
200    /// True when the addon declares no elevated capability — the strictest,
201    /// default profile and the safest to run.
202    #[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    /// Whether the addon may execute child processes at all.
211    #[must_use]
212    pub fn exec_allowed(&self) -> bool {
213        self.exec.allowed()
214    }
215
216    /// Whether the addon declares a restricted exec profile (`none` or an
217    /// allowlist, vs. blanket `full`). Used by the audit + consent surface;
218    /// `exec` is not OS-enforced (see [`super::sandbox`]).
219    #[must_use]
220    pub fn exec_restricted(&self) -> bool {
221        self.exec.is_restricted()
222    }
223
224    /// Whether exec is a blanket `full` grant (vs. an allowlist or `none`). Used
225    /// by the audit to nudge blanket grants toward least privilege.
226    #[must_use]
227    pub fn exec_is_blanket(&self) -> bool {
228        matches!(self.exec, ExecAccess::Mode(ExecMode::Full))
229    }
230
231    /// Whether the OS sandbox should permit outbound network for this addon.
232    #[must_use]
233    pub fn network_allowed(&self) -> bool {
234        self.network.allowed()
235    }
236
237    /// Whether the OS sandbox should permit filesystem writes for this addon.
238    #[must_use]
239    pub fn filesystem_writable(&self) -> bool {
240        self.filesystem.writable()
241    }
242
243    /// Validate the declaration (fail-closed: a malformed declaration is a
244    /// manifest error, not a silent grant). Env names must be plausible
245    /// `[A-Za-z0-9_]` identifiers.
246    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    /// Human-readable lines for the install-consent preview. Always returns the
269    /// three dimensions so the user sees exactly what they are granting.
270    #[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        // exec was omitted → secure-by-default (none).
322        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}