claude_agent/security/sandbox/
mod.rs

1//! OS-level sandboxing for secure command execution.
2//!
3//! Provides filesystem and network isolation using:
4//! - Linux: Landlock LSM (5.13+)
5//! - macOS: Seatbelt (sandbox-exec)
6//!
7//! Reference: <https://code.claude.com/docs/en/sandboxing>
8
9mod config;
10mod error;
11mod network;
12
13#[cfg(target_os = "linux")]
14mod landlock;
15#[cfg(target_os = "macos")]
16mod macos;
17
18pub use config::{NetworkConfig, SandboxConfig};
19pub use error::{SandboxError, SandboxResult};
20pub use network::{DomainCheck, NetworkSandbox};
21
22use std::collections::HashMap;
23use std::path::Path;
24#[cfg(test)]
25use std::path::PathBuf;
26use tracing::warn;
27
28pub trait SandboxRuntime: Send + Sync {
29    fn is_available(&self) -> bool;
30    fn apply(&self) -> SandboxResult<()>;
31    fn wrap_command(&self, command: &str) -> SandboxResult<String>;
32    fn environment_vars(&self) -> HashMap<String, String>;
33}
34
35pub struct Sandbox {
36    config: SandboxConfig,
37    runtime: Option<Box<dyn SandboxRuntime>>,
38}
39
40impl Sandbox {
41    pub fn new(config: SandboxConfig) -> Self {
42        let runtime = Self::create_runtime(&config);
43        Self { config, runtime }
44    }
45
46    pub fn disabled() -> Self {
47        Self {
48            config: SandboxConfig::disabled(),
49            runtime: None,
50        }
51    }
52
53    fn create_runtime(config: &SandboxConfig) -> Option<Box<dyn SandboxRuntime>> {
54        if !config.enabled {
55            return None;
56        }
57
58        #[cfg(target_os = "linux")]
59        {
60            let sandbox = landlock::LandlockSandbox::new(config.clone());
61            if sandbox.is_available() {
62                return Some(Box::new(sandbox));
63            }
64            warn!(
65                "Sandbox requested but Landlock not available (requires Linux 5.13+). \
66                 Commands will execute without filesystem isolation."
67            );
68        }
69
70        #[cfg(target_os = "macos")]
71        {
72            let sandbox = macos::SeatbeltSandbox::new(config);
73            if sandbox.is_available() {
74                return Some(Box::new(sandbox));
75            }
76            warn!(
77                "Sandbox requested but Seatbelt not available. \
78                 Commands will execute without filesystem isolation."
79            );
80        }
81
82        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
83        warn!(
84            "Sandbox requested but no sandbox implementation available for this platform. \
85             Commands will execute without filesystem isolation."
86        );
87
88        None
89    }
90
91    pub fn is_enabled(&self) -> bool {
92        self.config.enabled && self.runtime.is_some()
93    }
94
95    pub fn is_available(&self) -> bool {
96        self.runtime.as_ref().is_some_and(|r| r.is_available())
97    }
98
99    pub fn config(&self) -> &SandboxConfig {
100        &self.config
101    }
102
103    pub fn apply(&self) -> SandboxResult<()> {
104        match &self.runtime {
105            Some(runtime) => runtime.apply(),
106            None if self.config.enabled => Err(SandboxError::NotAvailable(
107                "no sandbox runtime available".into(),
108            )),
109            None => Ok(()),
110        }
111    }
112
113    pub fn wrap_command(&self, command: &str) -> SandboxResult<String> {
114        if self.config.is_command_excluded(command) {
115            if self.config.allow_unsandboxed_commands {
116                return Ok(command.to_string());
117            }
118            return Err(SandboxError::InvalidConfig(format!(
119                "command '{}' is excluded but unsandboxed commands not allowed",
120                command.split_whitespace().next().unwrap_or(command)
121            )));
122        }
123
124        match &self.runtime {
125            Some(runtime) => runtime.wrap_command(command),
126            None => Ok(command.to_string()),
127        }
128    }
129
130    pub fn environment_vars(&self) -> HashMap<String, String> {
131        let mut env = HashMap::new();
132
133        if let Some(runtime) = &self.runtime {
134            env.extend(runtime.environment_vars());
135        }
136
137        let network = &self.config.network;
138        if network.has_proxy() {
139            if let Some(url) = network.http_proxy_url() {
140                env.insert("HTTP_PROXY".into(), url.clone());
141                env.insert("HTTPS_PROXY".into(), url.clone());
142                env.insert("http_proxy".into(), url.clone());
143                env.insert("https_proxy".into(), url);
144            }
145            if let Some(url) = network.socks_proxy_url() {
146                env.insert("ALL_PROXY".into(), url.clone());
147                env.insert("all_proxy".into(), url);
148            }
149            let no_proxy = network.no_proxy_value();
150            env.insert("NO_PROXY".into(), no_proxy.clone());
151            env.insert("no_proxy".into(), no_proxy);
152        }
153
154        env
155    }
156
157    pub fn should_auto_allow_bash(&self) -> bool {
158        self.is_enabled() && self.config.should_auto_allow_bash()
159    }
160
161    pub fn can_bypass(&self, explicitly_requested: bool) -> bool {
162        self.config.can_bypass_sandbox(explicitly_requested)
163    }
164}
165
166impl Default for Sandbox {
167    fn default() -> Self {
168        Self::disabled()
169    }
170}
171
172pub fn is_sandbox_supported() -> bool {
173    #[cfg(target_os = "linux")]
174    {
175        landlock::is_landlock_supported()
176    }
177    #[cfg(target_os = "macos")]
178    {
179        macos::is_seatbelt_supported()
180    }
181    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
182    {
183        false
184    }
185}
186
187pub fn create_sandbox(working_dir: &Path, auto_allow_bash: bool) -> Sandbox {
188    let config =
189        SandboxConfig::new(working_dir.to_path_buf()).with_auto_allow_bash(auto_allow_bash);
190    Sandbox::new(config)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_disabled_sandbox() {
199        let sandbox = Sandbox::disabled();
200        assert!(!sandbox.is_enabled());
201        assert!(sandbox.apply().is_ok());
202    }
203
204    #[test]
205    fn test_wrap_command_disabled() {
206        let sandbox = Sandbox::disabled();
207        let wrapped = sandbox.wrap_command("echo test").unwrap();
208        assert_eq!(wrapped, "echo test");
209    }
210
211    #[test]
212    fn test_excluded_command() {
213        let config =
214            SandboxConfig::new(PathBuf::from("/tmp")).with_excluded_commands(vec!["docker".into()]);
215        let sandbox = Sandbox::new(config);
216
217        let result = sandbox.wrap_command("docker run nginx");
218        assert!(result.is_err() || result.unwrap() == "docker run nginx");
219    }
220
221    #[test]
222    fn test_proxy_environment() {
223        let config = SandboxConfig::disabled()
224            .with_network(NetworkConfig::with_proxy(Some(8080), Some(1080)));
225        let sandbox = Sandbox::new(config);
226
227        let env = sandbox.environment_vars();
228        assert_eq!(env.get("HTTP_PROXY"), Some(&"http://127.0.0.1:8080".into()));
229        assert_eq!(
230            env.get("ALL_PROXY"),
231            Some(&"socks5://127.0.0.1:1080".into())
232        );
233    }
234
235    #[test]
236    fn test_auto_allow_bash() {
237        let config = SandboxConfig::new(PathBuf::from("/tmp"));
238        assert!(config.should_auto_allow_bash());
239
240        let config = SandboxConfig::new(PathBuf::from("/tmp")).with_auto_allow_bash(false);
241        assert!(!config.should_auto_allow_bash());
242    }
243
244    #[test]
245    fn test_bypass_sandbox() {
246        let config = SandboxConfig::new(PathBuf::from("/tmp"));
247        let sandbox = Sandbox::new(config);
248
249        assert!(sandbox.can_bypass(true));
250        assert!(!sandbox.can_bypass(false));
251    }
252}