fusabi_host/
sandbox.rs

1//! Sandbox configuration for secure script execution.
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6/// Policy for filesystem path access.
7#[derive(Debug, Clone, PartialEq)]
8pub enum PathPolicy {
9    /// Deny all filesystem access.
10    DenyAll,
11    /// Allow only specific paths.
12    AllowList(HashSet<PathBuf>),
13    /// Deny specific paths (allow all others).
14    DenyList(HashSet<PathBuf>),
15    /// Allow all filesystem access.
16    AllowAll,
17}
18
19impl Default for PathPolicy {
20    fn default() -> Self {
21        PathPolicy::DenyAll
22    }
23}
24
25impl PathPolicy {
26    /// Check if a path is allowed by this policy.
27    pub fn is_allowed(&self, path: &Path) -> bool {
28        match self {
29            PathPolicy::DenyAll => false,
30            PathPolicy::AllowAll => true,
31            PathPolicy::AllowList(allowed) => {
32                // Check if path or any of its parents are in the allowlist
33                let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
34                allowed.iter().any(|allowed_path| {
35                    canonical.starts_with(allowed_path)
36                })
37            }
38            PathPolicy::DenyList(denied) => {
39                let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
40                !denied.iter().any(|denied_path| {
41                    canonical.starts_with(denied_path)
42                })
43            }
44        }
45    }
46
47    /// Create an allowlist policy with the given paths.
48    pub fn allow<I, P>(paths: I) -> Self
49    where
50        I: IntoIterator<Item = P>,
51        P: Into<PathBuf>,
52    {
53        PathPolicy::AllowList(paths.into_iter().map(Into::into).collect())
54    }
55
56    /// Create a denylist policy with the given paths.
57    pub fn deny<I, P>(paths: I) -> Self
58    where
59        I: IntoIterator<Item = P>,
60        P: Into<PathBuf>,
61    {
62        PathPolicy::DenyList(paths.into_iter().map(Into::into).collect())
63    }
64}
65
66/// Policy for network access.
67#[derive(Debug, Clone, PartialEq)]
68pub enum NetPolicy {
69    /// Deny all network access.
70    DenyAll,
71    /// Allow only specific hosts/domains.
72    AllowList(HashSet<String>),
73    /// Deny specific hosts/domains (allow all others).
74    DenyList(HashSet<String>),
75    /// Allow all network access.
76    AllowAll,
77}
78
79impl Default for NetPolicy {
80    fn default() -> Self {
81        NetPolicy::DenyAll
82    }
83}
84
85impl NetPolicy {
86    /// Check if a host is allowed by this policy.
87    pub fn is_allowed(&self, host: &str) -> bool {
88        let host_lower = host.to_lowercase();
89        match self {
90            NetPolicy::DenyAll => false,
91            NetPolicy::AllowAll => true,
92            NetPolicy::AllowList(allowed) => {
93                allowed.iter().any(|a| {
94                    let a_lower = a.to_lowercase();
95                    // Support wildcard subdomains like *.example.com
96                    if a_lower.starts_with("*.") {
97                        let suffix = &a_lower[1..];
98                        host_lower.ends_with(suffix) || host_lower == &a_lower[2..]
99                    } else {
100                        host_lower == a_lower
101                    }
102                })
103            }
104            NetPolicy::DenyList(denied) => {
105                !denied.iter().any(|d| {
106                    let d_lower = d.to_lowercase();
107                    if d_lower.starts_with("*.") {
108                        let suffix = &d_lower[1..];
109                        host_lower.ends_with(suffix) || host_lower == &d_lower[2..]
110                    } else {
111                        host_lower == d_lower
112                    }
113                })
114            }
115        }
116    }
117
118    /// Create an allowlist policy with the given hosts.
119    pub fn allow<I, S>(hosts: I) -> Self
120    where
121        I: IntoIterator<Item = S>,
122        S: Into<String>,
123    {
124        NetPolicy::AllowList(hosts.into_iter().map(Into::into).collect())
125    }
126
127    /// Create a denylist policy with the given hosts.
128    pub fn deny<I, S>(hosts: I) -> Self
129    where
130        I: IntoIterator<Item = S>,
131        S: Into<String>,
132    {
133        NetPolicy::DenyList(hosts.into_iter().map(Into::into).collect())
134    }
135}
136
137/// Configuration for the sandbox environment.
138#[derive(Debug, Clone, Default)]
139pub struct SandboxConfig {
140    /// Policy for filesystem read access.
141    pub fs_read: PathPolicy,
142    /// Policy for filesystem write access.
143    pub fs_write: PathPolicy,
144    /// Policy for outgoing network requests.
145    pub net_outgoing: NetPolicy,
146    /// Policy for incoming network connections.
147    pub net_incoming: NetPolicy,
148    /// Allowed environment variable names (None = all denied).
149    pub env_vars: Option<HashSet<String>>,
150    /// Working directory for the script.
151    pub working_dir: Option<PathBuf>,
152    /// Whether to isolate temp directory.
153    pub isolate_temp: bool,
154}
155
156impl SandboxConfig {
157    /// Create a completely locked-down sandbox configuration.
158    pub fn locked() -> Self {
159        Self {
160            fs_read: PathPolicy::DenyAll,
161            fs_write: PathPolicy::DenyAll,
162            net_outgoing: NetPolicy::DenyAll,
163            net_incoming: NetPolicy::DenyAll,
164            env_vars: Some(HashSet::new()),
165            working_dir: None,
166            isolate_temp: true,
167        }
168    }
169
170    /// Create a permissive sandbox configuration (use with caution).
171    pub fn permissive() -> Self {
172        Self {
173            fs_read: PathPolicy::AllowAll,
174            fs_write: PathPolicy::AllowAll,
175            net_outgoing: NetPolicy::AllowAll,
176            net_incoming: NetPolicy::AllowAll,
177            env_vars: None,
178            working_dir: None,
179            isolate_temp: false,
180        }
181    }
182
183    /// Allow reading from specific paths.
184    pub fn with_read_paths<I, P>(mut self, paths: I) -> Self
185    where
186        I: IntoIterator<Item = P>,
187        P: Into<PathBuf>,
188    {
189        self.fs_read = PathPolicy::allow(paths);
190        self
191    }
192
193    /// Allow writing to specific paths.
194    pub fn with_write_paths<I, P>(mut self, paths: I) -> Self
195    where
196        I: IntoIterator<Item = P>,
197        P: Into<PathBuf>,
198    {
199        self.fs_write = PathPolicy::allow(paths);
200        self
201    }
202
203    /// Allow outgoing requests to specific hosts.
204    pub fn with_allowed_hosts<I, S>(mut self, hosts: I) -> Self
205    where
206        I: IntoIterator<Item = S>,
207        S: Into<String>,
208    {
209        self.net_outgoing = NetPolicy::allow(hosts);
210        self
211    }
212
213    /// Allow access to specific environment variables.
214    pub fn with_env_vars<I, S>(mut self, vars: I) -> Self
215    where
216        I: IntoIterator<Item = S>,
217        S: Into<String>,
218    {
219        self.env_vars = Some(vars.into_iter().map(Into::into).collect());
220        self
221    }
222
223    /// Set the working directory.
224    pub fn with_working_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
225        self.working_dir = Some(path.into());
226        self
227    }
228
229    /// Enable temp directory isolation.
230    pub fn with_temp_isolation(mut self) -> Self {
231        self.isolate_temp = true;
232        self
233    }
234
235    /// Check if reading from a path is allowed.
236    pub fn can_read(&self, path: &Path) -> bool {
237        self.fs_read.is_allowed(path)
238    }
239
240    /// Check if writing to a path is allowed.
241    pub fn can_write(&self, path: &Path) -> bool {
242        self.fs_write.is_allowed(path)
243    }
244
245    /// Check if connecting to a host is allowed.
246    pub fn can_connect(&self, host: &str) -> bool {
247        self.net_outgoing.is_allowed(host)
248    }
249
250    /// Check if an environment variable is accessible.
251    pub fn can_access_env(&self, name: &str) -> bool {
252        match &self.env_vars {
253            None => true,
254            Some(allowed) => allowed.contains(name),
255        }
256    }
257}
258
259/// A sandbox instance that enforces security policies during execution.
260#[derive(Debug)]
261pub struct Sandbox {
262    config: SandboxConfig,
263    temp_dir: Option<PathBuf>,
264}
265
266impl Sandbox {
267    /// Create a new sandbox with the given configuration.
268    pub fn new(config: SandboxConfig) -> crate::Result<Self> {
269        let temp_dir = if config.isolate_temp {
270            // Create an isolated temp directory
271            let dir = std::env::temp_dir().join(format!(
272                "fusabi-sandbox-{}",
273                std::process::id()
274            ));
275            std::fs::create_dir_all(&dir)?;
276            Some(dir)
277        } else {
278            None
279        };
280
281        Ok(Self { config, temp_dir })
282    }
283
284    /// Get the sandbox configuration.
285    pub fn config(&self) -> &SandboxConfig {
286        &self.config
287    }
288
289    /// Get the isolated temp directory, if any.
290    pub fn temp_dir(&self) -> Option<&Path> {
291        self.temp_dir.as_deref()
292    }
293
294    /// Check read permission and return an error if denied.
295    pub fn check_read(&self, path: &Path) -> crate::Result<()> {
296        if self.config.can_read(path) {
297            Ok(())
298        } else {
299            Err(crate::Error::sandbox_violation(format!(
300                "read access denied: {}",
301                path.display()
302            )))
303        }
304    }
305
306    /// Check write permission and return an error if denied.
307    pub fn check_write(&self, path: &Path) -> crate::Result<()> {
308        if self.config.can_write(path) {
309            Ok(())
310        } else {
311            Err(crate::Error::sandbox_violation(format!(
312                "write access denied: {}",
313                path.display()
314            )))
315        }
316    }
317
318    /// Check network connection permission and return an error if denied.
319    pub fn check_connect(&self, host: &str) -> crate::Result<()> {
320        if self.config.can_connect(host) {
321            Ok(())
322        } else {
323            Err(crate::Error::sandbox_violation(format!(
324                "network access denied: {}",
325                host
326            )))
327        }
328    }
329
330    /// Check environment variable access and return an error if denied.
331    pub fn check_env(&self, name: &str) -> crate::Result<()> {
332        if self.config.can_access_env(name) {
333            Ok(())
334        } else {
335            Err(crate::Error::sandbox_violation(format!(
336                "environment variable access denied: {}",
337                name
338            )))
339        }
340    }
341}
342
343impl Drop for Sandbox {
344    fn drop(&mut self) {
345        // Clean up isolated temp directory
346        if let Some(ref dir) = self.temp_dir {
347            let _ = std::fs::remove_dir_all(dir);
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_path_policy_deny_all() {
358        let policy = PathPolicy::DenyAll;
359        assert!(!policy.is_allowed(Path::new("/any/path")));
360    }
361
362    #[test]
363    fn test_path_policy_allow_all() {
364        let policy = PathPolicy::AllowAll;
365        assert!(policy.is_allowed(Path::new("/any/path")));
366    }
367
368    #[test]
369    fn test_path_policy_allowlist() {
370        let policy = PathPolicy::allow(["/tmp", "/home/user/data"]);
371        // Note: This test may not work perfectly without actual paths existing
372        // In production, paths would be canonicalized
373        assert!(!policy.is_allowed(Path::new("/etc/passwd")));
374    }
375
376    #[test]
377    fn test_net_policy_deny_all() {
378        let policy = NetPolicy::DenyAll;
379        assert!(!policy.is_allowed("example.com"));
380    }
381
382    #[test]
383    fn test_net_policy_allow_all() {
384        let policy = NetPolicy::AllowAll;
385        assert!(policy.is_allowed("example.com"));
386    }
387
388    #[test]
389    fn test_net_policy_allowlist() {
390        let policy = NetPolicy::allow(["example.com", "*.trusted.org"]);
391        assert!(policy.is_allowed("example.com"));
392        assert!(policy.is_allowed("api.trusted.org"));
393        assert!(policy.is_allowed("trusted.org"));
394        assert!(!policy.is_allowed("malicious.com"));
395    }
396
397    #[test]
398    fn test_net_policy_denylist() {
399        let policy = NetPolicy::deny(["evil.com", "*.malware.net"]);
400        assert!(policy.is_allowed("example.com"));
401        assert!(!policy.is_allowed("evil.com"));
402        assert!(!policy.is_allowed("download.malware.net"));
403    }
404
405    #[test]
406    fn test_sandbox_config_locked() {
407        let config = SandboxConfig::locked();
408        assert!(!config.can_read(Path::new("/etc/passwd")));
409        assert!(!config.can_write(Path::new("/tmp/file")));
410        assert!(!config.can_connect("example.com"));
411        assert!(!config.can_access_env("PATH"));
412    }
413
414    #[test]
415    fn test_sandbox_config_permissive() {
416        let config = SandboxConfig::permissive();
417        assert!(config.can_read(Path::new("/etc/passwd")));
418        assert!(config.can_connect("example.com"));
419        assert!(config.can_access_env("PATH"));
420    }
421
422    #[test]
423    fn test_sandbox_config_builder() {
424        let config = SandboxConfig::locked()
425            .with_allowed_hosts(["api.example.com"])
426            .with_env_vars(["HOME", "USER"]);
427
428        assert!(config.can_connect("api.example.com"));
429        assert!(!config.can_connect("other.com"));
430        assert!(config.can_access_env("HOME"));
431        assert!(!config.can_access_env("SECRET"));
432    }
433}