1use serde::{Deserialize, Serialize};
16use std::collections::HashSet;
17use std::path::PathBuf;
18
19#[non_exhaustive]
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SandboxType {
24 Wasm,
26 OsSandbox,
28 Combined,
30}
31
32impl Default for SandboxType {
33 fn default() -> Self {
34 if cfg!(target_os = "linux") {
36 Self::OsSandbox
37 } else {
38 Self::Wasm
39 }
40 }
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct NetworkPolicy {
46 #[serde(default)]
48 pub allow_network: bool,
49
50 #[serde(default)]
52 pub allowed_domains: Vec<String>,
53
54 #[serde(default)]
56 pub blocked_domains: Vec<String>,
57
58 #[serde(default = "default_max_connections")]
60 pub max_connections_per_minute: u32,
61}
62
63fn default_max_connections() -> u32 {
64 30
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69pub struct FilesystemPolicy {
70 #[serde(default)]
72 pub readable_paths: Vec<PathBuf>,
73
74 #[serde(default)]
76 pub writable_paths: Vec<PathBuf>,
77
78 #[serde(default)]
80 pub allow_create: bool,
81
82 #[serde(default)]
84 pub allow_delete: bool,
85
86 #[serde(default = "default_max_file_size")]
88 pub max_file_size: u64,
89}
90
91fn default_max_file_size() -> u64 {
92 8 * 1024 * 1024
93}
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct ProcessPolicy {
98 #[serde(default)]
100 pub allow_shell: bool,
101
102 #[serde(default)]
104 pub allowed_commands: Vec<String>,
105
106 #[serde(default)]
108 pub blocked_commands: Vec<String>,
109
110 #[serde(default = "default_max_exec_time")]
112 pub max_execution_seconds: u32,
113}
114
115fn default_max_exec_time() -> u32 {
116 30
117}
118
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121pub struct EnvPolicy {
122 #[serde(default)]
124 pub allowed_vars: Vec<String>,
125
126 #[serde(default)]
128 pub denied_vars: Vec<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SandboxPolicy {
138 pub agent_id: String,
140
141 #[serde(default)]
143 pub sandbox_type: SandboxType,
144
145 #[serde(default)]
147 pub network: NetworkPolicy,
148
149 #[serde(default)]
151 pub filesystem: FilesystemPolicy,
152
153 #[serde(default)]
155 pub process: ProcessPolicy,
156
157 #[serde(default)]
159 pub env: EnvPolicy,
160
161 #[serde(default)]
163 pub allowed_tools: Vec<String>,
164
165 #[serde(default)]
167 pub denied_tools: Vec<String>,
168
169 #[serde(default = "default_true")]
171 pub audit_logging: bool,
172}
173
174fn default_true() -> bool {
175 true
176}
177
178impl Default for SandboxPolicy {
179 fn default() -> Self {
180 Self {
181 agent_id: String::new(),
182 sandbox_type: SandboxType::default(),
183 network: NetworkPolicy::default(),
184 filesystem: FilesystemPolicy::default(),
185 process: ProcessPolicy::default(),
186 env: EnvPolicy::default(),
187 allowed_tools: Vec::new(),
188 denied_tools: Vec::new(),
189 audit_logging: true,
190 }
191 }
192}
193
194impl SandboxPolicy {
195 pub fn new(agent_id: impl Into<String>) -> Self {
197 Self {
198 agent_id: agent_id.into(),
199 ..Default::default()
200 }
201 }
202
203 pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
205 if self.denied_tools.iter().any(|t| t == tool_name) {
207 return false;
208 }
209 if self.allowed_tools.is_empty() {
211 return true;
212 }
213 self.allowed_tools.iter().any(|t| t == tool_name)
214 }
215
216 pub fn is_domain_allowed(&self, domain: &str) -> bool {
218 if !self.network.allow_network {
219 return false;
220 }
221 for blocked in &self.network.blocked_domains {
223 if domain_matches(domain, blocked) {
224 return false;
225 }
226 }
227 if self.network.allowed_domains.is_empty() {
229 return true;
230 }
231 self.network.allowed_domains.iter().any(|a| domain_matches(domain, a))
232 }
233
234 pub fn is_path_readable(&self, path: &std::path::Path) -> bool {
236 self.filesystem.readable_paths.iter().any(|allowed| {
237 path.starts_with(allowed)
238 })
239 }
240
241 pub fn is_path_writable(&self, path: &std::path::Path) -> bool {
243 self.filesystem.writable_paths.iter().any(|allowed| {
244 path.starts_with(allowed)
245 })
246 }
247
248 pub fn is_command_allowed(&self, command: &str) -> bool {
250 if !self.process.allow_shell {
251 return false;
252 }
253 if self.process.blocked_commands.iter().any(|b| b == command) {
255 return false;
256 }
257 if self.process.allowed_commands.is_empty() {
259 return true;
260 }
261 self.process.allowed_commands.iter().any(|a| a == command)
262 }
263
264 pub fn effective_tools(&self) -> HashSet<String> {
266 let mut tools: HashSet<String> = self.allowed_tools.iter().cloned().collect();
267 for denied in &self.denied_tools {
268 tools.remove(denied);
269 }
270 tools
271 }
272
273 pub fn effective_sandbox_type(&self) -> SandboxType {
278 if cfg!(target_os = "linux") {
279 return self.sandbox_type.clone();
280 }
281 match &self.sandbox_type {
283 SandboxType::OsSandbox | SandboxType::Combined => {
284 tracing::warn!(
285 agent = %self.agent_id,
286 "OS sandbox unavailable on this platform; \
287 falling back to WASM-only sandbox"
288 );
289 SandboxType::Wasm
290 }
291 other => other.clone(),
292 }
293 }
294}
295
296fn domain_matches(domain: &str, pattern: &str) -> bool {
298 let domain_lower = domain.to_lowercase();
299 let pattern_lower = pattern.to_lowercase();
300
301 if pattern_lower == "*" {
302 return true;
303 }
304 if let Some(suffix) = pattern_lower.strip_prefix("*.") {
305 return domain_lower.ends_with(&format!(".{suffix}"))
306 || domain_lower == suffix;
307 }
308 domain_lower == pattern_lower
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct SandboxAuditEntry {
314 pub timestamp: String,
316 pub agent_id: String,
318 pub action: String,
320 pub target: String,
322 pub allowed: bool,
324 pub reason: Option<String>,
326}
327
328impl SandboxAuditEntry {
329 pub fn allowed(
331 agent_id: impl Into<String>,
332 action: impl Into<String>,
333 target: impl Into<String>,
334 ) -> Self {
335 Self {
336 timestamp: chrono::Utc::now().to_rfc3339(),
337 agent_id: agent_id.into(),
338 action: action.into(),
339 target: target.into(),
340 allowed: true,
341 reason: None,
342 }
343 }
344
345 pub fn denied(
347 agent_id: impl Into<String>,
348 action: impl Into<String>,
349 target: impl Into<String>,
350 reason: impl Into<String>,
351 ) -> Self {
352 Self {
353 timestamp: chrono::Utc::now().to_rfc3339(),
354 agent_id: agent_id.into(),
355 action: action.into(),
356 target: target.into(),
357 allowed: false,
358 reason: Some(reason.into()),
359 }
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use std::path::Path;
367
368 #[test]
369 fn default_sandbox_type_is_not_none() {
370 let st = SandboxType::default();
371 assert!(matches!(st, SandboxType::OsSandbox | SandboxType::Wasm));
374 }
375
376 #[test]
377 fn default_policy_has_secure_defaults() {
378 let policy = SandboxPolicy::default();
379 assert!(!policy.network.allow_network);
380 assert!(policy.filesystem.readable_paths.is_empty());
381 assert!(policy.filesystem.writable_paths.is_empty());
382 assert!(!policy.process.allow_shell);
383 assert!(policy.audit_logging);
384 }
385
386 #[test]
387 fn tool_allowed_when_list_empty() {
388 let policy = SandboxPolicy::new("test-agent");
389 assert!(policy.is_tool_allowed("any_tool"));
390 }
391
392 #[test]
393 fn tool_denied_when_in_denied_list() {
394 let policy = SandboxPolicy {
395 agent_id: "test".into(),
396 denied_tools: vec!["dangerous_tool".into()],
397 ..Default::default()
398 };
399 assert!(!policy.is_tool_allowed("dangerous_tool"));
400 }
401
402 #[test]
403 fn tool_allowed_only_when_in_allowed_list() {
404 let policy = SandboxPolicy {
405 agent_id: "test".into(),
406 allowed_tools: vec!["read_file".into(), "grep".into()],
407 ..Default::default()
408 };
409 assert!(policy.is_tool_allowed("read_file"));
410 assert!(policy.is_tool_allowed("grep"));
411 assert!(!policy.is_tool_allowed("bash"));
412 }
413
414 #[test]
415 fn denied_takes_precedence_over_allowed() {
416 let policy = SandboxPolicy {
417 agent_id: "test".into(),
418 allowed_tools: vec!["bash".into()],
419 denied_tools: vec!["bash".into()],
420 ..Default::default()
421 };
422 assert!(!policy.is_tool_allowed("bash"));
423 }
424
425 #[test]
426 fn domain_not_allowed_when_network_disabled() {
427 let policy = SandboxPolicy::new("test");
428 assert!(!policy.is_domain_allowed("example.com"));
429 }
430
431 #[test]
432 fn domain_allowed_with_exact_match() {
433 let policy = SandboxPolicy {
434 agent_id: "test".into(),
435 network: NetworkPolicy {
436 allow_network: true,
437 allowed_domains: vec!["api.example.com".into()],
438 ..Default::default()
439 },
440 ..Default::default()
441 };
442 assert!(policy.is_domain_allowed("api.example.com"));
443 assert!(!policy.is_domain_allowed("evil.com"));
444 }
445
446 #[test]
447 fn domain_wildcard_match() {
448 let policy = SandboxPolicy {
449 agent_id: "test".into(),
450 network: NetworkPolicy {
451 allow_network: true,
452 allowed_domains: vec!["*.example.com".into()],
453 ..Default::default()
454 },
455 ..Default::default()
456 };
457 assert!(policy.is_domain_allowed("sub.example.com"));
458 assert!(policy.is_domain_allowed("example.com"));
459 assert!(!policy.is_domain_allowed("evil.com"));
460 }
461
462 #[test]
463 fn blocked_domain_takes_precedence() {
464 let policy = SandboxPolicy {
465 agent_id: "test".into(),
466 network: NetworkPolicy {
467 allow_network: true,
468 allowed_domains: vec!["*.example.com".into()],
469 blocked_domains: vec!["evil.example.com".into()],
470 ..Default::default()
471 },
472 ..Default::default()
473 };
474 assert!(!policy.is_domain_allowed("evil.example.com"));
475 assert!(policy.is_domain_allowed("good.example.com"));
476 }
477
478 #[test]
479 fn path_readable_check() {
480 let policy = SandboxPolicy {
481 agent_id: "test".into(),
482 filesystem: FilesystemPolicy {
483 readable_paths: vec![PathBuf::from("/home/user/workspace")],
484 ..Default::default()
485 },
486 ..Default::default()
487 };
488 assert!(policy.is_path_readable(Path::new("/home/user/workspace/file.rs")));
489 assert!(!policy.is_path_readable(Path::new("/etc/passwd")));
490 }
491
492 #[test]
493 fn path_writable_check() {
494 let policy = SandboxPolicy {
495 agent_id: "test".into(),
496 filesystem: FilesystemPolicy {
497 writable_paths: vec![PathBuf::from("/tmp/sandbox")],
498 ..Default::default()
499 },
500 ..Default::default()
501 };
502 assert!(policy.is_path_writable(Path::new("/tmp/sandbox/output.txt")));
503 assert!(!policy.is_path_writable(Path::new("/etc/config")));
504 }
505
506 #[test]
507 fn command_not_allowed_when_shell_disabled() {
508 let policy = SandboxPolicy::new("test");
509 assert!(!policy.is_command_allowed("ls"));
510 }
511
512 #[test]
513 fn command_allowed_when_shell_enabled() {
514 let policy = SandboxPolicy {
515 agent_id: "test".into(),
516 process: ProcessPolicy {
517 allow_shell: true,
518 ..Default::default()
519 },
520 ..Default::default()
521 };
522 assert!(policy.is_command_allowed("ls"));
523 }
524
525 #[test]
526 fn command_blocked_takes_precedence() {
527 let policy = SandboxPolicy {
528 agent_id: "test".into(),
529 process: ProcessPolicy {
530 allow_shell: true,
531 allowed_commands: vec!["rm".into()],
532 blocked_commands: vec!["rm".into()],
533 ..Default::default()
534 },
535 ..Default::default()
536 };
537 assert!(!policy.is_command_allowed("rm"));
538 }
539
540 #[test]
541 fn effective_tools_excludes_denied() {
542 let policy = SandboxPolicy {
543 agent_id: "test".into(),
544 allowed_tools: vec!["read".into(), "write".into(), "bash".into()],
545 denied_tools: vec!["bash".into()],
546 ..Default::default()
547 };
548 let effective = policy.effective_tools();
549 assert!(effective.contains("read"));
550 assert!(effective.contains("write"));
551 assert!(!effective.contains("bash"));
552 }
553
554 #[test]
555 fn audit_entry_allowed() {
556 let entry = SandboxAuditEntry::allowed("agent-1", "file_read", "/tmp/test.txt");
557 assert!(entry.allowed);
558 assert!(entry.reason.is_none());
559 }
560
561 #[test]
562 fn audit_entry_denied() {
563 let entry = SandboxAuditEntry::denied(
564 "agent-1",
565 "network_connect",
566 "evil.com",
567 "domain not in allowlist",
568 );
569 assert!(!entry.allowed);
570 assert_eq!(entry.reason.as_deref(), Some("domain not in allowlist"));
571 }
572
573 #[test]
574 fn domain_matches_star() {
575 assert!(domain_matches("anything.com", "*"));
576 }
577
578 #[test]
579 fn domain_matches_case_insensitive() {
580 assert!(domain_matches("API.Example.COM", "api.example.com"));
581 }
582
583 #[test]
584 fn sandbox_policy_serialization_roundtrip() {
585 let policy = SandboxPolicy {
586 agent_id: "test-agent".into(),
587 sandbox_type: SandboxType::Combined,
588 network: NetworkPolicy {
589 allow_network: true,
590 allowed_domains: vec!["*.example.com".into()],
591 blocked_domains: vec!["evil.example.com".into()],
592 max_connections_per_minute: 60,
593 },
594 filesystem: FilesystemPolicy {
595 readable_paths: vec![PathBuf::from("/workspace")],
596 writable_paths: vec![PathBuf::from("/tmp")],
597 allow_create: true,
598 allow_delete: false,
599 max_file_size: 4 * 1024 * 1024,
600 },
601 process: ProcessPolicy {
602 allow_shell: true,
603 allowed_commands: vec!["git".into(), "cargo".into()],
604 blocked_commands: vec!["rm".into()],
605 max_execution_seconds: 60,
606 },
607 env: EnvPolicy {
608 allowed_vars: vec!["HOME".into()],
609 denied_vars: vec!["AWS_SECRET_ACCESS_KEY".into()],
610 },
611 allowed_tools: vec!["read_file".into()],
612 denied_tools: vec!["bash".into()],
613 audit_logging: true,
614 };
615 let json = serde_json::to_string(&policy).unwrap();
616 let restored: SandboxPolicy = serde_json::from_str(&json).unwrap();
617 assert_eq!(restored.agent_id, "test-agent");
618 assert_eq!(restored.sandbox_type, SandboxType::Combined);
619 assert!(restored.network.allow_network);
620 assert!(restored.audit_logging);
621 }
622}