claude_agent/security/sandbox/
mod.rs1mod 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}