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