claude_agent/security/sandbox/
config.rs1use std::collections::HashSet;
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct SandboxConfig {
13 #[serde(default)]
14 pub enabled: bool,
15
16 #[serde(default = "default_auto_allow_bash")]
17 pub auto_allow_bash_if_sandboxed: bool,
18
19 #[serde(default)]
20 pub excluded_commands: HashSet<String>,
21
22 #[serde(default = "default_allow_unsandboxed")]
23 pub allow_unsandboxed_commands: bool,
24
25 #[serde(default)]
26 pub network: NetworkConfig,
27
28 #[serde(default)]
29 pub enable_weaker_nested_sandbox: bool,
30
31 #[serde(skip)]
32 pub working_dir: PathBuf,
33
34 #[serde(skip)]
35 pub allowed_paths: Vec<PathBuf>,
36
37 #[serde(skip)]
38 pub denied_paths: Vec<String>,
39
40 #[serde(default)]
41 pub allowed_domains: HashSet<String>,
42
43 #[serde(default)]
44 pub blocked_domains: HashSet<String>,
45}
46
47fn default_auto_allow_bash() -> bool {
48 true
49}
50
51fn default_allow_unsandboxed() -> bool {
52 true
53}
54
55impl Default for SandboxConfig {
56 fn default() -> Self {
57 Self {
58 enabled: false,
59 auto_allow_bash_if_sandboxed: true,
60 excluded_commands: HashSet::new(),
61 allow_unsandboxed_commands: true,
62 network: NetworkConfig::default(),
63 enable_weaker_nested_sandbox: false,
64 working_dir: PathBuf::new(),
65 allowed_paths: Vec::new(),
66 denied_paths: Vec::new(),
67 allowed_domains: HashSet::new(),
68 blocked_domains: HashSet::new(),
69 }
70 }
71}
72
73impl SandboxConfig {
74 pub fn new(working_dir: PathBuf) -> Self {
75 Self {
76 enabled: true,
77 working_dir,
78 ..Default::default()
79 }
80 }
81
82 pub fn disabled() -> Self {
83 Self::default()
84 }
85
86 pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
87 self.working_dir = dir;
88 self
89 }
90
91 pub fn with_auto_allow_bash(mut self, enabled: bool) -> Self {
92 self.auto_allow_bash_if_sandboxed = enabled;
93 self
94 }
95
96 pub fn with_allowed_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
97 self.allowed_paths = paths.into_iter().collect();
98 self
99 }
100
101 pub fn with_denied_paths(mut self, patterns: impl IntoIterator<Item = String>) -> Self {
102 self.denied_paths = patterns.into_iter().collect();
103 self
104 }
105
106 pub fn with_excluded_commands(mut self, commands: impl IntoIterator<Item = String>) -> Self {
107 self.excluded_commands = commands.into_iter().collect();
108 self
109 }
110
111 pub fn with_network(mut self, network: NetworkConfig) -> Self {
112 self.network = network;
113 self
114 }
115
116 pub fn with_allowed_domains(mut self, domains: impl IntoIterator<Item = String>) -> Self {
117 self.allowed_domains = domains.into_iter().collect();
118 self
119 }
120
121 pub fn with_blocked_domains(mut self, domains: impl IntoIterator<Item = String>) -> Self {
122 self.blocked_domains = domains.into_iter().collect();
123 self
124 }
125
126 pub fn allow_domain(mut self, domain: impl Into<String>) -> Self {
127 self.allowed_domains.insert(domain.into());
128 self
129 }
130
131 pub fn deny_domain(mut self, domain: impl Into<String>) -> Self {
132 self.blocked_domains.insert(domain.into());
133 self
134 }
135
136 pub fn to_network_sandbox(&self) -> super::NetworkSandbox {
137 super::NetworkSandbox::new()
138 .with_allowed_domains(self.allowed_domains.iter().cloned())
139 .with_blocked_domains(self.blocked_domains.iter().cloned())
140 }
141
142 pub fn is_command_excluded(&self, command: &str) -> bool {
143 let base_command = command.split_whitespace().next().unwrap_or(command);
144 self.excluded_commands.contains(base_command)
145 }
146
147 pub fn should_auto_allow_bash(&self) -> bool {
148 self.enabled && self.auto_allow_bash_if_sandboxed
149 }
150
151 pub fn can_bypass_sandbox(&self, explicitly_requested: bool) -> bool {
152 explicitly_requested && self.allow_unsandboxed_commands
153 }
154}
155
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub struct NetworkConfig {
159 #[serde(default)]
160 pub allow_unix_sockets: Vec<String>,
161
162 #[serde(default)]
163 pub allow_local_binding: bool,
164
165 #[serde(default)]
166 pub http_proxy_port: Option<u16>,
167
168 #[serde(default)]
169 pub socks_proxy_port: Option<u16>,
170}
171
172impl NetworkConfig {
173 pub fn new() -> Self {
174 Self::default()
175 }
176
177 pub fn with_proxy(http_port: Option<u16>, socks_port: Option<u16>) -> Self {
178 Self {
179 http_proxy_port: http_port,
180 socks_proxy_port: socks_port,
181 ..Default::default()
182 }
183 }
184
185 pub fn with_unix_sockets(mut self, paths: impl IntoIterator<Item = String>) -> Self {
186 self.allow_unix_sockets = paths.into_iter().collect();
187 self
188 }
189
190 pub fn with_local_binding(mut self, allow: bool) -> Self {
191 self.allow_local_binding = allow;
192 self
193 }
194
195 pub fn has_proxy(&self) -> bool {
196 self.http_proxy_port.is_some() || self.socks_proxy_port.is_some()
197 }
198
199 pub fn http_proxy_url(&self) -> Option<String> {
200 self.http_proxy_port
201 .map(|port| format!("http://127.0.0.1:{}", port))
202 }
203
204 pub fn socks_proxy_url(&self) -> Option<String> {
205 self.socks_proxy_port
206 .map(|port| format!("socks5://127.0.0.1:{}", port))
207 }
208
209 pub fn no_proxy_value(&self) -> String {
210 "localhost,127.0.0.1,::1".to_string()
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn test_sandbox_config_defaults() {
220 let config = SandboxConfig::default();
221 assert!(!config.enabled);
222 assert!(config.auto_allow_bash_if_sandboxed);
223 assert!(config.allow_unsandboxed_commands);
224 assert!(!config.enable_weaker_nested_sandbox);
225 }
226
227 #[test]
228 fn test_sandbox_config_enabled() {
229 let config = SandboxConfig::new(PathBuf::from("/tmp/sandbox"));
230 assert!(config.enabled);
231 assert!(config.should_auto_allow_bash());
232 }
233
234 #[test]
235 fn test_excluded_commands() {
236 let config =
237 SandboxConfig::disabled().with_excluded_commands(vec!["docker".into(), "git".into()]);
238
239 assert!(config.is_command_excluded("docker"));
240 assert!(config.is_command_excluded("docker run nginx"));
241 assert!(config.is_command_excluded("git"));
242 assert!(config.is_command_excluded("git status"));
243 assert!(!config.is_command_excluded("ls"));
244 }
245
246 #[test]
247 fn test_bypass_sandbox() {
248 let config = SandboxConfig::new(PathBuf::from("/tmp"));
249 assert!(config.can_bypass_sandbox(true));
250 assert!(!config.can_bypass_sandbox(false));
251
252 let strict_config = SandboxConfig::new(PathBuf::from("/tmp"));
253 let strict_config = SandboxConfig {
254 allow_unsandboxed_commands: false,
255 ..strict_config
256 };
257 assert!(!strict_config.can_bypass_sandbox(true));
258 }
259
260 #[test]
261 fn test_network_config() {
262 let network = NetworkConfig::with_proxy(Some(8080), Some(1080));
263 assert!(network.has_proxy());
264 assert_eq!(
265 network.http_proxy_url(),
266 Some("http://127.0.0.1:8080".into())
267 );
268 assert_eq!(
269 network.socks_proxy_url(),
270 Some("socks5://127.0.0.1:1080".into())
271 );
272 }
273
274 #[test]
275 fn test_unix_sockets() {
276 let network = NetworkConfig::new().with_unix_sockets(vec!["~/.ssh/agent-socket".into()]);
277 assert_eq!(network.allow_unix_sockets.len(), 1);
278 }
279
280 #[test]
281 fn test_serde() {
282 let json = r#"{
283 "enabled": true,
284 "autoAllowBashIfSandboxed": true,
285 "excludedCommands": ["docker", "git"],
286 "allowUnsandboxedCommands": false,
287 "network": {
288 "allowUnixSockets": ["~/.ssh/agent"],
289 "httpProxyPort": 8080
290 }
291 }"#;
292
293 let config: SandboxConfig = serde_json::from_str(json).unwrap();
294 assert!(config.enabled);
295 assert!(config.auto_allow_bash_if_sandboxed);
296 assert!(config.excluded_commands.contains("docker"));
297 assert!(!config.allow_unsandboxed_commands);
298 assert_eq!(config.network.http_proxy_port, Some(8080));
299 }
300}