1use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34use serde::{Deserialize, Serialize};
35use tokio::process::Command;
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Capabilities {
43 #[serde(default = "default_net")]
44 pub net: bool,
45 #[serde(default)]
48 pub write_paths: Vec<String>,
49 #[serde(default)]
53 pub read_paths: Vec<String>,
54 #[serde(default)]
56 pub env: EnvPolicy,
57}
58
59fn default_net() -> bool {
60 true
61}
62
63impl Default for Capabilities {
64 fn default() -> Self {
69 Self {
70 net: true,
71 write_paths: Vec::new(),
72 read_paths: Vec::new(),
73 env: EnvPolicy::default(),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81#[serde(rename_all = "snake_case")]
82pub enum EnvPolicy {
83 #[default]
85 Scrubbed,
86 Inherit,
89 Vars(Vec<String>),
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum SandboxLayer {
97 None,
98 Lightweight,
99 MacosSandboxExec,
100 LinuxLandlock,
101}
102
103pub fn resolve_layer(caps: &Capabilities) -> SandboxLayer {
106 let needs_os = !caps.net || !caps.write_paths.is_empty() || !caps.read_paths.is_empty();
107 if !needs_os {
108 return SandboxLayer::Lightweight;
109 }
110 #[cfg(target_os = "macos")]
111 {
112 SandboxLayer::MacosSandboxExec
113 }
114 #[cfg(target_os = "linux")]
115 {
116 if landlock_available() {
117 return SandboxLayer::LinuxLandlock;
118 }
119 return SandboxLayer::Lightweight;
120 }
121 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
122 {
123 SandboxLayer::Lightweight
124 }
125}
126
127#[cfg(target_os = "linux")]
128fn landlock_available() -> bool {
129 false
134}
135
136const SCRUB_ALLOW: &[&str] = &[
138 "HOME", "USER", "PATH", "LANG", "TERM", "SHELL",
139 ];
141
142fn lc_prefix(name: &str) -> bool {
143 name.starts_with("LC_")
144}
145
146pub fn scrub_env(policy: &EnvPolicy) -> HashMap<String, String> {
148 match policy {
149 EnvPolicy::Inherit => std::env::vars().collect(),
150 EnvPolicy::Scrubbed => std::env::vars()
151 .filter(|(k, _)| SCRUB_ALLOW.contains(&k.as_str()) || lc_prefix(k))
152 .collect(),
153 EnvPolicy::Vars(allow) => {
154 let allow: Vec<&str> = allow.iter().map(String::as_str).collect();
155 std::env::vars()
156 .filter(|(k, _)| allow.contains(&k.as_str()))
157 .collect()
158 }
159 }
160}
161
162fn expand_paths(paths: &[String], cwd: &Path) -> Vec<PathBuf> {
165 paths
166 .iter()
167 .map(|p| {
168 if p == "cwd" {
169 cwd.to_path_buf()
170 } else if let Some(stripped) = p.strip_prefix("~/") {
171 if let Some(home) = std::env::var_os("HOME") {
172 PathBuf::from(home).join(stripped)
173 } else {
174 PathBuf::from(p)
175 }
176 } else {
177 PathBuf::from(p)
178 }
179 })
180 .collect()
181}
182
183pub fn wrap_command(
191 program: &str,
192 args: &[String],
193 cwd: &Path,
194 caps: &Capabilities,
195) -> (Command, SandboxLayer) {
196 let layer = resolve_layer(caps);
197 let mut cmd = match layer {
198 SandboxLayer::MacosSandboxExec => {
199 let sbpl = build_macos_sbpl(caps, cwd);
200 let mut c = Command::new("sandbox-exec");
201 c.arg("-p").arg(sbpl).arg(program).args(args);
202 c
203 }
204 _ => {
208 let mut c = Command::new(program);
209 c.args(args);
210 c
211 }
212 };
213
214 cmd.current_dir(cwd);
215 cmd.env_clear();
216 for (k, v) in scrub_env(&caps.env) {
217 cmd.env(k, v);
218 }
219
220 (cmd, layer)
221}
222
223pub fn build_macos_sbpl(caps: &Capabilities, cwd: &Path) -> String {
237 let mut out = String::new();
238 out.push_str("(version 1)\n");
239 out.push_str("(deny default)\n");
240 out.push_str("(import \"system.sb\")\n");
241
242 out.push_str("(allow process-fork process-exec)\n");
244 out.push_str("(allow signal (target self))\n");
245 out.push_str("(allow sysctl-read)\n");
246 out.push_str("(allow mach-lookup)\n");
247 out.push_str("(allow ipc-posix-shm)\n");
248
249 out.push_str(&format!(
252 "(allow file-read* (subpath {:?}))\n",
253 cwd.to_string_lossy()
254 ));
255 for p in expand_paths(&caps.read_paths, cwd) {
256 out.push_str(&format!(
257 "(allow file-read* (subpath {:?}))\n",
258 p.to_string_lossy()
259 ));
260 }
261 out.push_str("(allow file-read* (subpath \"/usr\"))\n");
263 out.push_str("(allow file-read* (subpath \"/bin\"))\n");
264 out.push_str("(allow file-read* (subpath \"/sbin\"))\n");
265 out.push_str("(allow file-read* (subpath \"/System\"))\n");
266 out.push_str("(allow file-read* (subpath \"/Library\"))\n");
267 out.push_str("(allow file-read* (subpath \"/private/etc\"))\n");
268 out.push_str("(allow file-read* (subpath \"/dev\"))\n");
269
270 out.push_str(&format!(
273 "(allow file-write* (subpath {:?}))\n",
274 cwd.to_string_lossy()
275 ));
276 for p in expand_paths(&caps.write_paths, cwd) {
277 out.push_str(&format!(
278 "(allow file-write* (subpath {:?}))\n",
279 p.to_string_lossy()
280 ));
281 }
282 out.push_str("(allow file-write* (subpath \"/private/tmp\"))\n");
283 out.push_str("(allow file-write* (subpath \"/private/var/folders\"))\n");
284
285 if caps.net {
287 out.push_str("(allow network*)\n");
288 }
289
290 out
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct PathEscape(pub String);
298
299impl std::fmt::Display for PathEscape {
300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 write!(f, "{}", self.0)
302 }
303}
304
305impl std::error::Error for PathEscape {}
306
307pub fn confine_path(root: &Path, candidate: &Path) -> Result<PathBuf, PathEscape> {
326 let root_canon = root
327 .canonicalize()
328 .map_err(|e| PathEscape(format!("workspace root {} is unusable: {e}", root.display())))?;
329
330 let joined = if candidate.is_absolute() {
332 candidate.to_path_buf()
333 } else {
334 root_canon.join(candidate)
335 };
336
337 let normalized = lexically_normalize(&joined);
340
341 let resolved = canonicalize_existing_prefix(&normalized);
344
345 if resolved == root_canon || resolved.starts_with(&root_canon) {
346 Ok(resolved)
347 } else {
348 Err(PathEscape(format!(
349 "path {} escapes workspace root {}",
350 candidate.display(),
351 root.display()
352 )))
353 }
354}
355
356fn lexically_normalize(p: &Path) -> PathBuf {
359 use std::path::Component;
360 let mut out: Vec<std::ffi::OsString> = Vec::new();
361 let mut prefix_root = PathBuf::new();
362 for comp in p.components() {
363 match comp {
364 Component::Prefix(_) | Component::RootDir => {
365 prefix_root.push(comp.as_os_str());
366 }
367 Component::CurDir => {}
368 Component::ParentDir => {
369 out.pop();
370 }
371 Component::Normal(seg) => out.push(seg.to_os_string()),
372 }
373 }
374 let mut result = prefix_root;
375 for seg in out {
376 result.push(seg);
377 }
378 result
379}
380
381fn canonicalize_existing_prefix(p: &Path) -> PathBuf {
384 let mut existing = p.to_path_buf();
385 let mut tail: Vec<std::ffi::OsString> = Vec::new();
386 loop {
387 if let Ok(canon) = existing.canonicalize() {
388 let mut result = canon;
389 for seg in tail.iter().rev() {
390 result.push(seg);
391 }
392 return result;
393 }
394 match existing.file_name() {
395 Some(name) => {
396 tail.push(name.to_os_string());
397 if !existing.pop() {
398 break;
399 }
400 }
401 None => break,
402 }
403 }
404 p.to_path_buf()
407}
408
409pub fn audit_log_path(date_yyyy_mm_dd: &str) -> PathBuf {
417 let home = std::env::var_os("HOME")
418 .map(PathBuf::from)
419 .unwrap_or_else(|| PathBuf::from("."));
420 let dir = home.join(".flow-studio").join("logs").join("audit");
421 let _ = std::fs::create_dir_all(&dir);
422 dir.join(format!("shell-{}.log", date_yyyy_mm_dd))
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use std::path::PathBuf;
429
430 fn caps_with_paths(net: bool, writes: &[&str]) -> Capabilities {
431 Capabilities {
432 net,
433 write_paths: writes.iter().map(|s| s.to_string()).collect(),
434 read_paths: Vec::new(),
435 env: EnvPolicy::Scrubbed,
436 }
437 }
438
439 fn unique_tmp(tag: &str) -> PathBuf {
440 use std::sync::atomic::{AtomicU64, Ordering};
441 static COUNTER: AtomicU64 = AtomicU64::new(0);
442 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
443 std::env::temp_dir().join(format!("flow-{tag}-{}-{n}", std::process::id()))
444 }
445
446 #[test]
447 fn confine_allows_paths_inside_root() {
448 let tmp = unique_tmp("confine");
449 std::fs::create_dir_all(tmp.join("sub")).unwrap();
450 std::fs::write(tmp.join("sub/a.txt"), b"x").unwrap();
451
452 let ok = confine_path(&tmp, Path::new("sub/a.txt")).unwrap();
454 assert!(ok.starts_with(tmp.canonicalize().unwrap()));
455
456 let new_file = confine_path(&tmp, Path::new("sub/new.txt")).unwrap();
458 assert!(new_file.starts_with(tmp.canonicalize().unwrap()));
459 assert!(new_file.ends_with("new.txt"));
460
461 let _ = std::fs::remove_dir_all(&tmp);
462 }
463
464 #[test]
465 fn confine_rejects_traversal_escape() {
466 let tmp = unique_tmp("confine");
467 std::fs::create_dir_all(&tmp).unwrap();
468
469 let err = confine_path(&tmp, Path::new("../../etc/passwd"));
471 assert!(err.is_err(), "traversal must be rejected: {err:?}");
472
473 let err2 = confine_path(&tmp, Path::new("/etc/passwd"));
475 assert!(err2.is_err(), "absolute outside must be rejected: {err2:?}");
476
477 let _ = std::fs::remove_dir_all(&tmp);
478 }
479
480 #[test]
481 fn confine_rejects_symlink_escape() {
482 #[cfg(unix)]
485 {
486 let tmp = unique_tmp("confine");
487 let outside = unique_tmp("outside");
488 std::fs::create_dir_all(&tmp).unwrap();
489 std::fs::create_dir_all(&outside).unwrap();
490 std::os::unix::fs::symlink(&outside, tmp.join("link")).unwrap();
491
492 let err = confine_path(&tmp, Path::new("link/evil.txt"));
493 assert!(err.is_err(), "symlink escape must be rejected: {err:?}");
494
495 let _ = std::fs::remove_dir_all(&tmp);
496 let _ = std::fs::remove_dir_all(&outside);
497 }
498 }
499
500 #[test]
501 fn env_scrubbed_keeps_safe_vars_only() {
502 std::env::set_var("FLOW_TEST_SECRET", "should-not-leak");
503 std::env::set_var("HOME", "/Users/test");
504 let map = scrub_env(&EnvPolicy::Scrubbed);
505 assert!(map.contains_key("HOME"));
506 assert!(!map.contains_key("FLOW_TEST_SECRET"));
507 std::env::remove_var("FLOW_TEST_SECRET");
508 }
509
510 #[test]
511 fn env_inherit_passes_secrets() {
512 std::env::set_var("FLOW_TEST_INHERIT", "yes");
513 let map = scrub_env(&EnvPolicy::Inherit);
514 assert_eq!(
515 map.get("FLOW_TEST_INHERIT").map(String::as_str),
516 Some("yes")
517 );
518 std::env::remove_var("FLOW_TEST_INHERIT");
519 }
520
521 #[test]
522 fn env_vars_explicit_allow_list() {
523 std::env::set_var("FLOW_TEST_ALLOWED", "yes");
524 std::env::set_var("FLOW_TEST_DENIED", "no");
525 let policy = EnvPolicy::Vars(vec!["FLOW_TEST_ALLOWED".to_string()]);
526 let map = scrub_env(&policy);
527 assert!(map.contains_key("FLOW_TEST_ALLOWED"));
528 assert!(!map.contains_key("FLOW_TEST_DENIED"));
529 std::env::remove_var("FLOW_TEST_ALLOWED");
530 std::env::remove_var("FLOW_TEST_DENIED");
531 }
532
533 #[test]
534 fn resolve_layer_lightweight_when_no_caps() {
535 let caps = Capabilities {
536 net: true,
537 write_paths: Vec::new(),
538 read_paths: Vec::new(),
539 env: EnvPolicy::Scrubbed,
540 };
541 assert_eq!(resolve_layer(&caps), SandboxLayer::Lightweight);
544 }
545
546 #[cfg(target_os = "macos")]
547 #[test]
548 fn resolve_layer_picks_macos_sandbox_exec_when_caps_set() {
549 let caps = caps_with_paths(false, &["cwd"]);
550 assert_eq!(resolve_layer(&caps), SandboxLayer::MacosSandboxExec);
551 }
552
553 #[test]
554 fn build_macos_sbpl_denies_network_when_net_false() {
555 let caps = caps_with_paths(false, &["cwd"]);
556 let cwd = PathBuf::from("/tmp/flow-test");
557 let sbpl = build_macos_sbpl(&caps, &cwd);
558 assert!(sbpl.contains("(deny default)"));
559 assert!(!sbpl.contains("(allow network*)"));
560 }
561
562 #[test]
563 fn build_macos_sbpl_grants_network_when_net_true() {
564 let caps = caps_with_paths(true, &["cwd"]);
565 let cwd = PathBuf::from("/tmp/flow-test");
566 let sbpl = build_macos_sbpl(&caps, &cwd);
567 assert!(sbpl.contains("(allow network*)"));
568 }
569
570 #[test]
571 fn build_macos_sbpl_grants_write_to_declared_paths() {
572 let caps = caps_with_paths(false, &["cwd", "~/.cache"]);
573 let cwd = PathBuf::from("/tmp/flow-test");
574 let sbpl = build_macos_sbpl(&caps, &cwd);
575 assert!(sbpl.contains("/tmp/flow-test"));
576 assert!(sbpl.matches("(allow file-write*").count() >= 2);
578 }
579
580 #[test]
581 fn build_macos_sbpl_always_grants_write_to_cwd() {
582 let caps = caps_with_paths(false, &[]);
584 let cwd = PathBuf::from("/tmp/flow-workspace");
585 let sbpl = build_macos_sbpl(&caps, &cwd);
586 assert!(sbpl.contains("(allow file-write* (subpath \"/tmp/flow-workspace\"))"));
587 }
588
589 #[test]
590 fn audit_log_path_lives_under_flow_studio_audit() {
591 let p = audit_log_path("2026-05-04");
592 let s = p.to_string_lossy();
593 assert!(s.contains(".flow-studio/logs/audit/"));
594 assert!(s.ends_with("shell-2026-05-04.log"));
595 }
596}