Skip to main content

lean_ctx/core/plugins/
sandbox.rs

1//! Extension trust & sandbox model (EPIC 12.3).
2//!
3//! Every plugin subprocess (hooks + manifest tools) runs under a
4//! [`SandboxPolicy`] derived from the plugin's declared `[trust]` section. The
5//! model is **least-privilege by default** and splits cleanly into two honest
6//! categories so we never claim enforcement we do not perform:
7//!
8//! * **Enforced, deterministically** — environment isolation (the child gets a
9//!   scrubbed env containing only a fixed allowlist, so host secrets in env do
10//!   not leak), working-directory jail (cwd pinned to the plugin dir), and a
11//!   per-call timeout (in [`super::executor`]).
12//! * **Declared (consent surface)** — `network` / `fs_write`. These cannot be
13//!   blocked portably without OS namespaces/seccomp, so they are *declared*
14//!   capabilities surfaced to the user (and `/v1/capabilities`) for informed
15//!   trust, not silent OS-level blocks.
16//!
17//! Granting `env_passthrough` opts a plugin out of env scrubbing (it then sees
18//! the full host environment) — an explicit elevation a user can audit.
19
20use std::path::Path;
21use std::process::Command;
22
23use serde::Deserialize;
24
25/// Host environment variables a scrubbed child is still allowed to see. Chosen
26/// to let normal programs run (binary resolution, locale, temp dir) without
27/// exposing secrets that tend to live in the ambient environment.
28pub const ENV_ALLOWLIST: &[&str] = &[
29    "PATH",
30    "HOME",
31    "LANG",
32    "LC_ALL",
33    "LC_CTYPE",
34    "TMPDIR",
35    "TEMP",
36    "TMP",
37    // Windows needs these for most binaries to start.
38    "SystemRoot",
39    "SYSTEMROOT",
40    "ComSpec",
41    "PATHEXT",
42];
43
44/// A single capability a plugin may request in its `[trust]` section.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Permission {
47    /// Plugin intends to make network calls (declared; surfaced, not blocked).
48    Network,
49    /// Plugin intends to write files outside its own dir (declared; surfaced).
50    FsWrite,
51    /// Plugin opts out of env scrubbing and receives the full host environment.
52    EnvPassthrough,
53}
54
55impl Permission {
56    pub fn parse(s: &str) -> Option<Self> {
57        match s {
58            "network" => Some(Self::Network),
59            "fs_write" => Some(Self::FsWrite),
60            "env_passthrough" => Some(Self::EnvPassthrough),
61            _ => None,
62        }
63    }
64
65    pub fn as_str(self) -> &'static str {
66        match self {
67            Self::Network => "network",
68            Self::FsWrite => "fs_write",
69            Self::EnvPassthrough => "env_passthrough",
70        }
71    }
72}
73
74/// Declarative `[trust]` section of a plugin manifest. Absent ⇒ least privilege.
75#[derive(Debug, Clone, Default, Deserialize)]
76pub struct TrustSpec {
77    /// Requested capabilities (`network`, `fs_write`, `env_passthrough`).
78    #[serde(default)]
79    pub permissions: Vec<String>,
80}
81
82impl TrustSpec {
83    /// Validate that every declared permission is recognized (fail-closed: an
84    /// unknown permission is a manifest error, not a silent grant).
85    pub fn validate(&self) -> Result<(), String> {
86        for p in &self.permissions {
87            if Permission::parse(p).is_none() {
88                return Err(format!("unknown permission '{p}'"));
89            }
90        }
91        Ok(())
92    }
93
94    /// Resolve the enforceable policy. Unknown strings are ignored here because
95    /// `validate()` already rejects them at parse time.
96    pub fn policy(&self) -> SandboxPolicy {
97        let perms: Vec<Permission> = self
98            .permissions
99            .iter()
100            .filter_map(|p| Permission::parse(p))
101            .collect();
102        SandboxPolicy::from_permissions(&perms)
103    }
104}
105
106/// The resolved, enforceable sandbox for a plugin subprocess. The derived
107/// `Default` is least privilege (all `false`): scrubbed env, nothing declared.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub struct SandboxPolicy {
110    /// When false (default) the child runs with a scrubbed env (allowlist only).
111    pub env_passthrough: bool,
112    /// Declared network intent (surfaced, not OS-enforced).
113    pub allow_network: bool,
114    /// Declared out-of-dir write intent (surfaced, not OS-enforced).
115    pub allow_fs_write: bool,
116}
117
118impl SandboxPolicy {
119    /// The strictest policy: scrubbed env, no declared capabilities.
120    #[must_use]
121    pub fn strict() -> Self {
122        Self::default()
123    }
124
125    /// A policy mirroring legacy behavior (full host env). Used only where the
126    /// caller is not a plugin (e.g. direct internal subprocess helpers/tests).
127    #[must_use]
128    pub fn permissive() -> Self {
129        Self {
130            env_passthrough: true,
131            allow_network: true,
132            allow_fs_write: true,
133        }
134    }
135
136    #[must_use]
137    pub fn from_permissions(perms: &[Permission]) -> Self {
138        Self {
139            env_passthrough: perms.contains(&Permission::EnvPassthrough),
140            allow_network: perms.contains(&Permission::Network),
141            allow_fs_write: perms.contains(&Permission::FsWrite),
142        }
143    }
144
145    /// The declared permissions, as stable strings (for capabilities/audit).
146    #[must_use]
147    pub fn declared_permissions(&self) -> Vec<&'static str> {
148        let mut out = Vec::new();
149        if self.allow_network {
150            out.push(Permission::Network.as_str());
151        }
152        if self.allow_fs_write {
153            out.push(Permission::FsWrite.as_str());
154        }
155        if self.env_passthrough {
156            out.push(Permission::EnvPassthrough.as_str());
157        }
158        out
159    }
160
161    /// Apply the *enforced* controls to a [`Command`] before spawn: env scrub
162    /// (unless `env_passthrough`) and cwd jail to `plugin_dir` (when it exists).
163    /// The timeout is enforced separately by the executor's wait loop.
164    pub fn apply(&self, cmd: &mut Command, plugin_dir: &Path) {
165        if !self.env_passthrough {
166            cmd.env_clear();
167            for key in ENV_ALLOWLIST {
168                if let Ok(val) = std::env::var(key) {
169                    cmd.env(key, val);
170                }
171            }
172        }
173        // Jail to the plugin dir so relative paths resolve there. Guard on
174        // existence: pointing cwd at a missing dir would make spawn fail.
175        if plugin_dir.is_dir() {
176            cmd.current_dir(plugin_dir);
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn default_is_least_privilege() {
187        let p = SandboxPolicy::default();
188        assert!(!p.env_passthrough);
189        assert!(!p.allow_network);
190        assert!(!p.allow_fs_write);
191        assert!(p.declared_permissions().is_empty());
192    }
193
194    #[test]
195    fn parse_permissions_roundtrip() {
196        for s in ["network", "fs_write", "env_passthrough"] {
197            assert_eq!(Permission::parse(s).unwrap().as_str(), s);
198        }
199        assert!(Permission::parse("rm_rf_everything").is_none());
200    }
201
202    #[test]
203    fn trust_spec_rejects_unknown_permission() {
204        let spec = TrustSpec {
205            permissions: vec!["network".into(), "bogus".into()],
206        };
207        assert!(spec.validate().unwrap_err().contains("bogus"));
208    }
209
210    #[test]
211    fn policy_reflects_declared_permissions() {
212        let spec = TrustSpec {
213            permissions: vec!["network".into(), "env_passthrough".into()],
214        };
215        let policy = spec.policy();
216        assert!(policy.allow_network);
217        assert!(policy.env_passthrough);
218        assert!(!policy.allow_fs_write);
219        let declared = policy.declared_permissions();
220        assert!(declared.contains(&"network"));
221        assert!(declared.contains(&"env_passthrough"));
222    }
223
224    #[cfg(unix)]
225    #[test]
226    fn scrubbed_env_hides_host_secret_but_keeps_path() {
227        use std::time::Duration;
228        // A secret in the host env must NOT reach a scrubbed child.
229        crate::test_env::set_var("LEAN_CTX_TEST_SECRET", "top-secret");
230        let out = crate::core::plugins::executor::run_subprocess(
231            "env",
232            std::path::Path::new("/tmp"),
233            &[],
234            "",
235            Duration::from_secs(2),
236            &SandboxPolicy::strict(),
237        )
238        .unwrap();
239        let env_dump = String::from_utf8_lossy(&out.stdout);
240        crate::test_env::remove_var("LEAN_CTX_TEST_SECRET");
241        assert!(
242            !env_dump.contains("top-secret"),
243            "scrubbed child leaked host secret"
244        );
245        // PATH survives so binaries still resolve.
246        assert!(env_dump.contains("PATH="));
247    }
248
249    #[cfg(unix)]
250    #[test]
251    fn passthrough_env_exposes_host_var() {
252        use std::time::Duration;
253        crate::test_env::set_var("LEAN_CTX_TEST_PASSTHRU", "visible");
254        let out = crate::core::plugins::executor::run_subprocess(
255            "env",
256            std::path::Path::new("/tmp"),
257            &[],
258            "",
259            Duration::from_secs(2),
260            &SandboxPolicy::permissive(),
261        )
262        .unwrap();
263        let env_dump = String::from_utf8_lossy(&out.stdout);
264        crate::test_env::remove_var("LEAN_CTX_TEST_PASSTHRU");
265        assert!(env_dump.contains("visible"));
266    }
267}