claude_agent/security/sandbox/
config.rs

1//! Sandbox configuration types matching Claude Code settings.
2//!
3//! Reference: <https://code.claude.com/docs/en/sandboxing>
4
5use 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}