lean_ctx/core/plugins/
sandbox.rs1use std::path::Path;
21use std::process::Command;
22
23use serde::Deserialize;
24
25pub const ENV_ALLOWLIST: &[&str] = &[
29 "PATH",
30 "HOME",
31 "LANG",
32 "LC_ALL",
33 "LC_CTYPE",
34 "TMPDIR",
35 "TEMP",
36 "TMP",
37 "SystemRoot",
39 "SYSTEMROOT",
40 "ComSpec",
41 "PATHEXT",
42];
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Permission {
47 Network,
49 FsWrite,
51 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#[derive(Debug, Clone, Default, Deserialize)]
76pub struct TrustSpec {
77 #[serde(default)]
79 pub permissions: Vec<String>,
80}
81
82impl TrustSpec {
83 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub struct SandboxPolicy {
110 pub env_passthrough: bool,
112 pub allow_network: bool,
114 pub allow_fs_write: bool,
116}
117
118impl SandboxPolicy {
119 #[must_use]
121 pub fn strict() -> Self {
122 Self::default()
123 }
124
125 #[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 #[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 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 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 let _env_lock = crate::core::data_dir::test_env_lock();
228 use std::time::Duration;
229 crate::test_env::set_var("LEAN_CTX_TEST_SECRET", "top-secret");
231 let out = crate::core::plugins::executor::run_subprocess(
232 "env",
233 std::path::Path::new("/tmp"),
234 &[],
235 "",
236 Duration::from_secs(2),
237 &SandboxPolicy::strict(),
238 )
239 .unwrap();
240 let env_dump = String::from_utf8_lossy(&out.stdout);
241 crate::test_env::remove_var("LEAN_CTX_TEST_SECRET");
242 assert!(
243 !env_dump.contains("top-secret"),
244 "scrubbed child leaked host secret"
245 );
246 assert!(env_dump.contains("PATH="));
248 }
249
250 #[cfg(unix)]
251 #[test]
252 fn passthrough_env_exposes_host_var() {
253 let _env_lock = crate::core::data_dir::test_env_lock();
254 use std::time::Duration;
255 crate::test_env::set_var("LEAN_CTX_TEST_PASSTHRU", "visible");
256 let out = crate::core::plugins::executor::run_subprocess(
257 "env",
258 std::path::Path::new("/tmp"),
259 &[],
260 "",
261 Duration::from_secs(2),
262 &SandboxPolicy::permissive(),
263 )
264 .unwrap();
265 let env_dump = String::from_utf8_lossy(&out.stdout);
266 crate::test_env::remove_var("LEAN_CTX_TEST_PASSTHRU");
267 assert!(env_dump.contains("visible"));
268 }
269}