1use async_trait::async_trait;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5pub mod backends;
6
7#[cfg(target_os = "linux")]
8mod linux_hardened {
9 use super::{Command, ExecResult, FsNetPolicy, Limits, Sandbox};
14 use std::path::PathBuf;
15
16 pub struct HardenedSandbox {
17 root: PathBuf,
18 policy: FsNetPolicy,
19 }
20
21 impl HardenedSandbox {
22 pub fn new(root: PathBuf) -> Self {
23 Self {
24 root: root.clone(),
25 policy: FsNetPolicy {
26 allowed_paths: vec![root],
27 allow_network: false,
28 ..FsNetPolicy::default()
29 },
30 }
31 }
32 }
33
34 #[async_trait::async_trait]
35 impl Sandbox for HardenedSandbox {
36 async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
37 use std::process::Command as StdCommand;
38
39 let (program, args) = if which("firejail") {
41 let mut fargs = vec![
42 "--quiet".into(),
43 format!("--timeout={}", limits.timeout_ms / 1000),
44 format!("--private={}", self.root.display()),
45 ];
46 if !self.policy.allow_network {
47 fargs.push("--net=none".into());
48 }
49 for path in &self.policy.allowed_paths {
50 fargs.push(format!("--whitelist={}", path.display()));
51 }
52 fargs.push("--".into());
53 fargs.push(cmd.program.clone());
54 fargs.extend(cmd.args.clone());
55 ("firejail".to_string(), fargs)
56 } else if which("bwrap") {
57 let mut bargs = vec![
58 "--ro-bind".into(),
59 "/usr".into(),
60 "/usr".into(),
61 "--ro-bind".into(),
62 "/lib".into(),
63 "/lib".into(),
64 "--ro-bind".into(),
65 "/lib64".into(),
66 "/lib64".into(),
67 "--ro-bind".into(),
68 "/bin".into(),
69 "/bin".into(),
70 "--bind".into(),
71 self.root.display().to_string(),
72 self.root.display().to_string(),
73 "--chdir".into(),
74 self.root.display().to_string(),
75 ];
76 if !self.policy.allow_network {
77 bargs.push("--unshare-net".into());
78 }
79 bargs.push("--".into());
80 bargs.push(cmd.program.clone());
81 bargs.extend(cmd.args.clone());
82 ("bwrap".to_string(), bargs)
83 } else {
84 let mut uargs = vec![
86 "--mount".into(),
87 "--pid".into(),
88 "--fork".into(),
89 "--root".into(),
90 self.root.display().to_string(),
91 ];
92 uargs.push(cmd.program.clone());
93 uargs.extend(cmd.args.clone());
94 ("unshare".to_string(), uargs)
95 };
96
97 let output = StdCommand::new(&program)
98 .args(&args)
99 .current_dir(&cmd.workdir)
100 .output()?;
101
102 Ok(ExecResult {
103 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
104 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
105 exit_code: output.status.code().unwrap_or(-1),
106 })
107 }
108
109 fn root(&self) -> &std::path::Path {
110 &self.root
111 }
112
113 fn policy(&self) -> &FsNetPolicy {
114 &self.policy
115 }
116 }
117
118 fn which(cmd: &str) -> bool {
119 std::process::Command::new("which")
120 .arg(cmd)
121 .output()
122 .map(|o| o.status.success())
123 .unwrap_or(false)
124 }
125}
126
127#[cfg(target_os = "linux")]
128pub use linux_hardened::HardenedSandbox;
129
130#[cfg(not(target_os = "linux"))]
131pub struct HardenedSandbox {
132 _root: PathBuf,
133 _policy: FsNetPolicy,
134}
135
136#[cfg(not(target_os = "linux"))]
137impl HardenedSandbox {
138 pub fn new(root: PathBuf) -> Self {
139 Self {
140 _root: root,
141 _policy: FsNetPolicy::default(),
142 }
143 }
144}
145
146#[cfg(not(target_os = "linux"))]
147#[async_trait::async_trait]
148impl Sandbox for HardenedSandbox {
149 async fn exec(&self, _cmd: &Command, _limits: &Limits) -> anyhow::Result<ExecResult> {
150 Ok(ExecResult {
151 stdout: String::new(),
152 stderr: "local-hardened sandbox requires Linux (firejail/bwrap/unshare)".into(),
153 exit_code: 127,
154 })
155 }
156
157 fn root(&self) -> &Path {
158 &self._root
159 }
160
161 fn policy(&self) -> &FsNetPolicy {
162 &self._policy
163 }
164}
165
166#[derive(Debug, Clone)]
169pub struct Command {
170 pub program: String,
171 pub args: Vec<String>,
172 pub env: HashMap<String, String>,
173 pub workdir: PathBuf,
174}
175
176#[derive(Debug, Clone)]
177pub struct Limits {
178 pub timeout_ms: u64,
179 pub max_output_bytes: usize,
180}
181
182#[derive(Debug, Clone)]
183pub struct ExecResult {
184 pub stdout: String,
185 pub stderr: String,
186 pub exit_code: i32,
187}
188
189#[derive(Debug, Clone)]
192pub struct FsNetPolicy {
193 pub allowed_paths: Vec<PathBuf>,
194 pub allow_network: bool,
195 pub denied_paths: Vec<PathBuf>,
198 pub env_allowlist: Vec<String>,
202}
203
204impl Default for FsNetPolicy {
205 fn default() -> Self {
206 Self {
207 allowed_paths: vec![],
208 allow_network: false,
209 denied_paths: default_denied_paths(),
210 env_allowlist: Vec::new(),
211 }
212 }
213}
214
215pub fn default_denied_paths() -> Vec<PathBuf> {
219 vec![
220 PathBuf::from(".git"),
221 PathBuf::from(".env"),
222 PathBuf::from(".env.local"),
223 PathBuf::from(".ssh"),
224 PathBuf::from("id_rsa"),
225 PathBuf::from("id_ed25519"),
226 ]
227}
228
229pub fn path_is_denied(path: &Path, denied: &[PathBuf]) -> bool {
232 let comps: Vec<String> = path
233 .components()
234 .filter_map(|c| match c {
235 std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
236 _ => None,
237 })
238 .collect();
239 for d in denied {
240 let d_comps: Vec<String> = d
241 .components()
242 .filter_map(|c| match c {
243 std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
244 _ => None,
245 })
246 .collect();
247 if d_comps.is_empty() {
248 continue;
249 }
250 if comps
251 .windows(d_comps.len())
252 .any(|w| w == d_comps.as_slice())
253 {
254 return true;
255 }
256 if comps.last() == d_comps.last() && d_comps.len() == 1 {
257 return true;
258 }
259 }
260 false
261}
262
263#[async_trait]
267pub trait Sandbox: Send + Sync {
268 async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult>;
269 fn root(&self) -> &Path;
270 fn policy(&self) -> &FsNetPolicy;
271}
272
273pub struct LocalSandbox {
276 root: PathBuf,
277 policy: FsNetPolicy,
278}
279
280impl LocalSandbox {
281 pub fn new(root: PathBuf) -> Self {
282 Self {
283 root: root.clone(),
284 policy: FsNetPolicy {
285 allowed_paths: vec![root],
286 allow_network: true,
287 ..FsNetPolicy::default()
288 },
289 }
290 }
291
292 pub fn hardened(root: PathBuf) -> Self {
293 Self {
294 root: root.clone(),
295 policy: FsNetPolicy {
296 allowed_paths: vec![root],
297 allow_network: false, ..FsNetPolicy::default()
299 },
300 }
301 }
302
303 pub fn with_policy(mut self, policy: FsNetPolicy) -> Self {
304 self.policy = policy;
305 self
306 }
307}
308
309#[async_trait]
310impl Sandbox for LocalSandbox {
311 async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
312 use std::process::Command as StdCommand;
313 use std::time::Instant;
314
315 let root = self
316 .root
317 .canonicalize()
318 .unwrap_or_else(|_| self.root.clone());
319 let workdir = cmd
320 .workdir
321 .canonicalize()
322 .unwrap_or_else(|_| cmd.workdir.clone());
323 if !workdir.starts_with(&root) {
324 anyhow::bail!(
325 "Command workdir escapes sandbox root: {}",
326 cmd.workdir.display()
327 );
328 }
329
330 if path_is_denied(&workdir, &self.policy.denied_paths) {
331 anyhow::bail!(
332 "Command workdir hits a protected path: {}",
333 cmd.workdir.display()
334 );
335 }
336 for arg in &cmd.args {
337 let p = Path::new(arg);
338 if path_is_denied(p, &self.policy.denied_paths) {
339 anyhow::bail!("Command argument refers to a protected path: {}", arg);
340 }
341 }
342
343 let env: HashMap<String, String> = if self.policy.env_allowlist.is_empty() {
344 cmd.env.clone()
345 } else {
346 cmd.env
347 .iter()
348 .filter(|(k, _)| self.policy.env_allowlist.iter().any(|a| a == *k))
349 .map(|(k, v)| (k.clone(), v.clone()))
350 .collect()
351 };
352
353 let mut builder = StdCommand::new(&cmd.program);
354 builder
355 .args(&cmd.args)
356 .current_dir(&workdir)
357 .stdout(std::process::Stdio::piped())
358 .stderr(std::process::Stdio::piped());
359 if !self.policy.env_allowlist.is_empty() {
360 builder.env_clear();
361 }
362 builder.envs(&env);
363 let mut child = builder.spawn()?;
364
365 let start = Instant::now();
366 let timeout = std::time::Duration::from_millis(limits.timeout_ms);
367
368 loop {
370 match child.try_wait()? {
371 Some(status) => {
372 let output = child.wait_with_output()?;
373 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
374 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
375 let exit_code = status.code().unwrap_or(-1);
376
377 return Ok(ExecResult {
378 stdout: truncate(stdout, limits.max_output_bytes),
379 stderr: truncate(stderr, limits.max_output_bytes),
380 exit_code,
381 });
382 }
383 None => {
384 if start.elapsed() > timeout {
385 let _ = child.kill();
386 return Ok(ExecResult {
387 stdout: String::new(),
388 stderr: "TIMEOUT".to_string(),
389 exit_code: -1,
390 });
391 }
392 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
393 }
394 }
395 }
396 }
397
398 fn root(&self) -> &Path {
399 &self.root
400 }
401
402 fn policy(&self) -> &FsNetPolicy {
403 &self.policy
404 }
405}
406
407fn truncate(s: String, max_bytes: usize) -> String {
408 if s.len() <= max_bytes {
409 s
410 } else {
411 let truncate_at = max_bytes.saturating_sub(100);
412 format!(
413 "{}\n... [truncated, {} bytes total]",
414 &s[..truncate_at.min(s.len())],
415 s.len()
416 )
417 }
418}