sandbox_runtime/config/
schema.rs

1//! Configuration schema types matching the TypeScript Zod schemas.
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{ConfigError, SandboxError};
6
7/// MITM proxy configuration for routing specific domains through a man-in-the-middle proxy.
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9#[serde(rename_all = "camelCase")]
10pub struct MitmProxyConfig {
11    /// Unix socket path for the MITM proxy.
12    pub socket_path: String,
13    /// Domains to route through the MITM proxy.
14    pub domains: Vec<String>,
15}
16
17/// Network restriction configuration.
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19#[serde(rename_all = "camelCase")]
20pub struct NetworkConfig {
21    /// Domains allowed for network access (e.g., "github.com", "*.npmjs.org").
22    #[serde(default)]
23    pub allowed_domains: Vec<String>,
24
25    /// Domains explicitly denied for network access.
26    #[serde(default)]
27    pub denied_domains: Vec<String>,
28
29    /// Specific Unix sockets to allow (macOS only).
30    #[serde(default)]
31    pub allow_unix_sockets: Option<Vec<String>>,
32
33    /// Allow all Unix sockets (Linux only).
34    #[serde(default)]
35    pub allow_all_unix_sockets: Option<bool>,
36
37    /// Allow binding to localhost.
38    #[serde(default)]
39    pub allow_local_binding: Option<bool>,
40
41    /// External HTTP proxy port.
42    #[serde(default)]
43    pub http_proxy_port: Option<u16>,
44
45    /// External SOCKS proxy port.
46    #[serde(default)]
47    pub socks_proxy_port: Option<u16>,
48
49    /// MITM proxy configuration.
50    #[serde(default)]
51    pub mitm_proxy: Option<MitmProxyConfig>,
52}
53
54/// Filesystem restriction configuration.
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56#[serde(rename_all = "camelCase")]
57pub struct FilesystemConfig {
58    /// Paths/patterns denied for reading.
59    #[serde(default)]
60    pub deny_read: Vec<String>,
61
62    /// Paths allowed for writing.
63    #[serde(default)]
64    pub allow_write: Vec<String>,
65
66    /// Paths denied for writing (overrides allow_write).
67    #[serde(default)]
68    pub deny_write: Vec<String>,
69
70    /// Allow writes to .git/config.
71    #[serde(default)]
72    pub allow_git_config: Option<bool>,
73}
74
75/// Ripgrep configuration for dangerous file discovery on Linux.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct RipgrepConfig {
79    /// Path to the ripgrep command.
80    pub command: String,
81    /// Additional arguments.
82    #[serde(default)]
83    pub args: Option<Vec<String>>,
84}
85
86impl Default for RipgrepConfig {
87    fn default() -> Self {
88        Self {
89            command: "rg".to_string(),
90            args: None,
91        }
92    }
93}
94
95/// Custom seccomp filter configuration.
96#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97#[serde(rename_all = "camelCase")]
98pub struct SeccompConfig {
99    /// Path to custom BPF filter.
100    pub bpf_path: Option<String>,
101    /// Path to custom apply-seccomp binary.
102    pub apply_path: Option<String>,
103}
104
105/// Main sandbox runtime configuration.
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107#[serde(rename_all = "camelCase")]
108pub struct SandboxRuntimeConfig {
109    /// Network restriction configuration.
110    #[serde(default)]
111    pub network: NetworkConfig,
112
113    /// Filesystem restriction configuration.
114    #[serde(default)]
115    pub filesystem: FilesystemConfig,
116
117    /// Violation filtering by command pattern.
118    #[serde(default)]
119    pub ignore_violations: Option<std::collections::HashMap<String, Vec<String>>>,
120
121    /// Enable weaker nested sandbox mode.
122    #[serde(default)]
123    pub enable_weaker_nested_sandbox: Option<bool>,
124
125    /// Ripgrep configuration.
126    #[serde(default)]
127    pub ripgrep: Option<RipgrepConfig>,
128
129    /// Search depth for mandatory deny discovery (Linux, default: 3).
130    #[serde(default)]
131    pub mandatory_deny_search_depth: Option<u32>,
132
133    /// Allow pseudo-terminal (macOS only).
134    #[serde(default)]
135    pub allow_pty: Option<bool>,
136
137    /// Custom seccomp configuration.
138    #[serde(default)]
139    pub seccomp: Option<SeccompConfig>,
140}
141
142/// Dangerous files that should never be writable.
143pub const DANGEROUS_FILES: &[&str] = &[
144    ".gitconfig",
145    ".bashrc",
146    ".bash_profile",
147    ".bash_login",
148    ".profile",
149    ".zshrc",
150    ".zprofile",
151    ".zshenv",
152    ".zlogin",
153    ".mcp.json",
154    ".mcp-settings.json",
155    ".npmrc",
156    ".yarnrc",
157    ".yarnrc.yml",
158];
159
160/// Dangerous directories that should never be writable.
161pub const DANGEROUS_DIRECTORIES: &[&str] = &[
162    ".git/hooks",
163    ".git",
164    ".vscode",
165    ".idea",
166    ".claude/commands",
167];
168
169impl SandboxRuntimeConfig {
170    /// Validate the configuration.
171    pub fn validate(&self) -> Result<(), SandboxError> {
172        // Validate allowed domains
173        for domain in &self.network.allowed_domains {
174            validate_domain_pattern(domain)?;
175        }
176
177        // Validate denied domains
178        for domain in &self.network.denied_domains {
179            validate_domain_pattern(domain)?;
180        }
181
182        // Validate MITM proxy domains
183        if let Some(ref mitm) = self.network.mitm_proxy {
184            for domain in &mitm.domains {
185                validate_domain_pattern(domain)?;
186            }
187        }
188
189        Ok(())
190    }
191}
192
193/// Validate a domain pattern.
194fn validate_domain_pattern(pattern: &str) -> Result<(), SandboxError> {
195    // Check for empty pattern
196    if pattern.is_empty() {
197        return Err(ConfigError::InvalidDomainPattern {
198            pattern: pattern.to_string(),
199            reason: "domain pattern cannot be empty".to_string(),
200        }
201        .into());
202    }
203
204    // Check for just wildcard
205    if pattern == "*" {
206        return Err(ConfigError::InvalidDomainPattern {
207            pattern: pattern.to_string(),
208            reason: "wildcard-only patterns are not allowed".to_string(),
209        }
210        .into());
211    }
212
213    // Check for too broad patterns like *.com
214    if pattern.starts_with("*.") {
215        let suffix = &pattern[2..];
216        // Check if suffix is a TLD or too short
217        if !suffix.contains('.') && suffix.len() <= 4 {
218            return Err(ConfigError::InvalidDomainPattern {
219                pattern: pattern.to_string(),
220                reason: "pattern is too broad (matches entire TLD)".to_string(),
221            }
222            .into());
223        }
224    }
225
226    // Check for port numbers
227    if pattern.contains(':') {
228        return Err(ConfigError::InvalidDomainPattern {
229            pattern: pattern.to_string(),
230            reason: "domain patterns cannot include port numbers".to_string(),
231        }
232        .into());
233    }
234
235    // Check for invalid characters
236    let check_part = if pattern.starts_with("*.") {
237        &pattern[2..]
238    } else {
239        pattern
240    };
241
242    for ch in check_part.chars() {
243        if !ch.is_ascii_alphanumeric() && ch != '.' && ch != '-' && ch != '_' {
244            return Err(ConfigError::InvalidDomainPattern {
245                pattern: pattern.to_string(),
246                reason: format!("invalid character '{}' in domain pattern", ch),
247            }
248            .into());
249        }
250    }
251
252    Ok(())
253}
254
255/// Check if a hostname matches a domain pattern.
256pub fn matches_domain_pattern(hostname: &str, pattern: &str) -> bool {
257    let hostname_lower = hostname.to_lowercase();
258    let pattern_lower = pattern.to_lowercase();
259
260    if pattern_lower.starts_with("*.") {
261        // Wildcard pattern: *.example.com matches api.example.com but NOT example.com
262        let base_domain = &pattern_lower[2..];
263        hostname_lower.ends_with(&format!(".{}", base_domain))
264    } else {
265        // Exact match
266        hostname_lower == pattern_lower
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_domain_pattern_matching() {
276        // Exact match
277        assert!(matches_domain_pattern("example.com", "example.com"));
278        assert!(matches_domain_pattern("EXAMPLE.COM", "example.com"));
279        assert!(!matches_domain_pattern("api.example.com", "example.com"));
280
281        // Wildcard match
282        assert!(matches_domain_pattern("api.example.com", "*.example.com"));
283        assert!(matches_domain_pattern("deep.api.example.com", "*.example.com"));
284        assert!(!matches_domain_pattern("example.com", "*.example.com"));
285
286        // Case insensitivity
287        assert!(matches_domain_pattern("API.EXAMPLE.COM", "*.example.com"));
288    }
289
290    #[test]
291    fn test_domain_pattern_validation() {
292        // Valid patterns
293        assert!(validate_domain_pattern("example.com").is_ok());
294        assert!(validate_domain_pattern("*.example.com").is_ok());
295        assert!(validate_domain_pattern("localhost").is_ok());
296        assert!(validate_domain_pattern("api.github.com").is_ok());
297
298        // Invalid patterns
299        assert!(validate_domain_pattern("").is_err());
300        assert!(validate_domain_pattern("*").is_err());
301        assert!(validate_domain_pattern("*.com").is_err());
302        assert!(validate_domain_pattern("example.com:8080").is_err());
303    }
304}