Skip to main content

safe_shell_sandbox/
lib.rs

1mod macos;
2pub mod proxy;
3
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::process::ExitStatus;
7
8/// Result from sandbox execution, including stats.
9pub struct SandboxResult {
10    pub status: ExitStatus,
11    pub file_reads_blocked: usize,
12    pub network_requests_blocked: usize,
13}
14
15/// Configuration for the sandbox, built from the merged profile.
16pub struct SandboxConfig {
17    pub command: Vec<String>,
18    pub env: HashMap<String, String>,
19    pub cwd: PathBuf,
20    pub allow_write: Vec<String>,
21    pub deny_read: Vec<String>,
22    pub network_allow: Vec<String>,
23    pub quiet: bool,
24}
25
26/// Resolve the absolute path to bash for sandbox execution.
27///
28/// Always uses bash, not the user's SHELL. Reason: zsh with env_clear()
29/// reads /etc/zshenv which calls path_helper and can reset PATH, causing
30/// "command not found" for basic tools like cat/ls. Bash handles minimal
31/// environments reliably. The commands *inside* the sandbox (npm, node, etc.)
32/// are unaffected — this only controls the `-c` wrapper.
33pub fn resolve_shell() -> Result<String, Box<dyn std::error::Error>> {
34    // Find bash via PATH (works before env_clear)
35    if let Ok(path) = which::which("bash") {
36        return Ok(path.to_string_lossy().to_string());
37    }
38
39    // Common locations as fallback
40    for path in ["/bin/bash", "/usr/bin/bash", "/opt/homebrew/bin/bash"] {
41        if std::path::Path::new(path).exists() {
42            return Ok(path.to_string());
43        }
44    }
45
46    // Last resort: sh (POSIX, always available)
47    if let Ok(path) = which::which("sh") {
48        return Ok(path.to_string_lossy().to_string());
49    }
50
51    Err("Could not find bash or sh.".into())
52}
53
54/// Ensure the environment has a usable PATH. If the scrubbed env
55/// doesn't include PATH, add a minimal one so commands like cat/ls work.
56pub fn ensure_path(env: &mut HashMap<String, String>) {
57    if !env.contains_key("PATH") {
58        env.insert(
59            "PATH".to_string(),
60            "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_string(),
61        );
62    }
63}
64
65/// Resolve a path pattern: expand `~` to home dir, `.` and relative paths to absolute.
66pub fn resolve_path(path: &str, cwd: &std::path::Path) -> String {
67    if path.starts_with("~/") || path == "~" {
68        if let Some(home) = dirs::home_dir() {
69            return path.replacen('~', &home.to_string_lossy(), 1);
70        }
71    }
72
73    if path == "." {
74        return cwd.to_string_lossy().to_string();
75    }
76
77    if let Some(stripped) = path.strip_prefix("./") {
78        return cwd.join(stripped).to_string_lossy().to_string();
79    }
80
81    if path.starts_with('/') {
82        return path.to_string();
83    }
84
85    // Relative path — resolve against cwd
86    cwd.join(path).to_string_lossy().to_string()
87}
88
89/// Execute a command inside the sandbox.
90pub fn execute_sandboxed(
91    command: &str,
92    env: &HashMap<String, String>,
93) -> Result<SandboxResult, Box<dyn std::error::Error>> {
94    let config = SandboxConfig {
95        command: vec![command.to_string()],
96        env: env.clone(),
97        cwd: std::env::current_dir()?,
98        allow_write: vec![],
99        deny_read: vec![],
100        network_allow: vec![],
101        quiet: false,
102    };
103
104    execute_with_config(&config)
105}
106
107/// Execute with full sandbox config (used by CLI with profile).
108pub fn execute_with_config(
109    config: &SandboxConfig,
110) -> Result<SandboxResult, Box<dyn std::error::Error>> {
111    #[cfg(target_os = "macos")]
112    {
113        macos::execute(config)
114    }
115
116    #[cfg(not(target_os = "macos"))]
117    {
118        Err("Unsupported platform. safe-shell currently supports macOS only. Linux support coming soon — see https://github.com/claudexai/safe-shell/issues".into())
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn resolve_path_home() {
128        let cwd = std::path::Path::new("/project");
129        let resolved = resolve_path("~/.aws", cwd);
130        let home = dirs::home_dir().unwrap();
131        assert_eq!(resolved, format!("{}/.aws", home.display()));
132    }
133
134    #[test]
135    fn resolve_path_dot() {
136        let cwd = std::path::Path::new("/project");
137        let resolved = resolve_path("./node_modules", cwd);
138        assert_eq!(resolved, "/project/node_modules");
139    }
140
141    #[test]
142    fn resolve_path_dot_alone() {
143        let cwd = std::path::Path::new("/project");
144        let resolved = resolve_path(".", cwd);
145        assert_eq!(resolved, "/project");
146    }
147
148    #[test]
149    fn resolve_path_absolute() {
150        let cwd = std::path::Path::new("/project");
151        let resolved = resolve_path("/tmp", cwd);
152        assert_eq!(resolved, "/tmp");
153    }
154
155    #[test]
156    fn resolve_path_relative() {
157        let cwd = std::path::Path::new("/project");
158        let resolved = resolve_path("target", cwd);
159        assert_eq!(resolved, "/project/target");
160    }
161}