Skip to main content

clawft_plugin/
sandbox.rs

1//! Per-agent sandbox policy definitions.
2//!
3//! The [`SandboxPolicy`] struct defines the runtime security restrictions for
4//! an agent or plugin. It maps from per-agent config (`~/.clawft/agents/<id>/config.toml`)
5//! to enforceable sandbox rules.
6//!
7//! The [`SandboxType`] enum determines which isolation mechanism is used:
8//! - `Wasm` -- WASM sandbox (cross-platform, default for WASM plugins)
9//! - `OsSandbox` -- seccomp + landlock on Linux (default for native on Linux)
10//! - `Combined` -- both WASM + OS sandbox layers
11//!
12//! **Secure by default**: The default sandbox type is NOT `None`. WASM plugins
13//! get `Wasm`, native execution on Linux gets `OsSandbox`.
14
15use serde::{Deserialize, Serialize};
16use std::collections::HashSet;
17use std::path::PathBuf;
18
19/// Sandbox isolation mechanism.
20#[non_exhaustive]
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SandboxType {
24    /// WASM sandbox via wasmtime WASI capabilities (cross-platform).
25    Wasm,
26    /// OS-level sandbox: seccomp + landlock on Linux.
27    OsSandbox,
28    /// Both WASM and OS-level sandbox layers.
29    Combined,
30}
31
32impl Default for SandboxType {
33    fn default() -> Self {
34        // Secure by default: use OS sandbox on Linux, WASM elsewhere.
35        if cfg!(target_os = "linux") {
36            Self::OsSandbox
37        } else {
38            Self::Wasm
39        }
40    }
41}
42
43/// Network access policy for a sandboxed agent.
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct NetworkPolicy {
46    /// Whether network access is allowed at all.
47    #[serde(default)]
48    pub allow_network: bool,
49
50    /// Allowed domain patterns (exact or wildcard `*.example.com`).
51    #[serde(default)]
52    pub allowed_domains: Vec<String>,
53
54    /// Blocked domain patterns (takes precedence over allowed).
55    #[serde(default)]
56    pub blocked_domains: Vec<String>,
57
58    /// Maximum outbound connections per minute.
59    #[serde(default = "default_max_connections")]
60    pub max_connections_per_minute: u32,
61}
62
63fn default_max_connections() -> u32 {
64    30
65}
66
67/// Filesystem access policy for a sandboxed agent.
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69pub struct FilesystemPolicy {
70    /// Paths the agent can read from.
71    #[serde(default)]
72    pub readable_paths: Vec<PathBuf>,
73
74    /// Paths the agent can write to.
75    #[serde(default)]
76    pub writable_paths: Vec<PathBuf>,
77
78    /// Whether the agent can create new files.
79    #[serde(default)]
80    pub allow_create: bool,
81
82    /// Whether the agent can delete files.
83    #[serde(default)]
84    pub allow_delete: bool,
85
86    /// Maximum individual file size in bytes (default: 8MB).
87    #[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/// Process execution policy for a sandboxed agent.
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct ProcessPolicy {
98    /// Whether the agent can execute shell commands.
99    #[serde(default)]
100    pub allow_shell: bool,
101
102    /// Allowed command names (empty = none allowed unless `allow_shell` is true).
103    #[serde(default)]
104    pub allowed_commands: Vec<String>,
105
106    /// Blocked command patterns (takes precedence over allowed).
107    #[serde(default)]
108    pub blocked_commands: Vec<String>,
109
110    /// Maximum execution time per command in seconds.
111    #[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/// Environment variable access policy.
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121pub struct EnvPolicy {
122    /// Allowed environment variable names.
123    #[serde(default)]
124    pub allowed_vars: Vec<String>,
125
126    /// Variables that are never accessible (hardcoded deny list).
127    #[serde(default)]
128    pub denied_vars: Vec<String>,
129}
130
131/// Per-agent sandbox policy.
132///
133/// Created from an agent's configuration and enforced at runtime by the
134/// sandbox enforcement layer. Each agent's tool restrictions map to a
135/// `SandboxPolicy`.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SandboxPolicy {
138    /// Agent or plugin identifier.
139    pub agent_id: String,
140
141    /// Sandbox isolation type.
142    #[serde(default)]
143    pub sandbox_type: SandboxType,
144
145    /// Network access policy.
146    #[serde(default)]
147    pub network: NetworkPolicy,
148
149    /// Filesystem access policy.
150    #[serde(default)]
151    pub filesystem: FilesystemPolicy,
152
153    /// Process execution policy.
154    #[serde(default)]
155    pub process: ProcessPolicy,
156
157    /// Environment variable access policy.
158    #[serde(default)]
159    pub env: EnvPolicy,
160
161    /// Tools this agent is allowed to use (empty = all tools allowed).
162    #[serde(default)]
163    pub allowed_tools: Vec<String>,
164
165    /// Tools explicitly denied to this agent.
166    #[serde(default)]
167    pub denied_tools: Vec<String>,
168
169    /// Whether audit logging is enabled for this agent.
170    #[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    /// Create a new sandbox policy for the given agent.
196    pub fn new(agent_id: impl Into<String>) -> Self {
197        Self {
198            agent_id: agent_id.into(),
199            ..Default::default()
200        }
201    }
202
203    /// Check whether a specific tool is allowed by this policy.
204    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
205        // Denied tools always take precedence.
206        if self.denied_tools.iter().any(|t| t == tool_name) {
207            return false;
208        }
209        // If allowed_tools is empty, all tools are allowed.
210        if self.allowed_tools.is_empty() {
211            return true;
212        }
213        self.allowed_tools.iter().any(|t| t == tool_name)
214    }
215
216    /// Check whether a domain is allowed by the network policy.
217    pub fn is_domain_allowed(&self, domain: &str) -> bool {
218        if !self.network.allow_network {
219            return false;
220        }
221        // Check blocked domains first (takes precedence).
222        for blocked in &self.network.blocked_domains {
223            if domain_matches(domain, blocked) {
224                return false;
225            }
226        }
227        // If no allowed domains specified, all are allowed.
228        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    /// Check whether a file path is readable.
235    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    /// Check whether a file path is writable.
242    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    /// Check whether a command is allowed by the process policy.
249    pub fn is_command_allowed(&self, command: &str) -> bool {
250        if !self.process.allow_shell {
251            return false;
252        }
253        // Blocked commands take precedence.
254        if self.process.blocked_commands.iter().any(|b| b == command) {
255            return false;
256        }
257        // If allowed_commands is empty but allow_shell is true, all allowed.
258        if self.process.allowed_commands.is_empty() {
259            return true;
260        }
261        self.process.allowed_commands.iter().any(|a| a == command)
262    }
263
264    /// Collect the set of all effective tool names that are allowed.
265    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    /// Return the platform-appropriate sandbox type.
274    ///
275    /// On macOS, downgrades `OsSandbox` and `Combined` to `Wasm` with a
276    /// warning, since seccomp/landlock are Linux-only.
277    pub fn effective_sandbox_type(&self) -> SandboxType {
278        if cfg!(target_os = "linux") {
279            return self.sandbox_type.clone();
280        }
281        // Non-Linux: WASM-only fallback.
282        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
296/// Check whether a domain matches a pattern (exact or wildcard).
297fn 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/// Audit log entry for a sandbox decision.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct SandboxAuditEntry {
314    /// Timestamp (ISO 8601).
315    pub timestamp: String,
316    /// Agent identifier.
317    pub agent_id: String,
318    /// Action attempted (e.g., "file_read", "network_connect", "tool_invoke").
319    pub action: String,
320    /// Target of the action (e.g., file path, URL, tool name).
321    pub target: String,
322    /// Whether the action was allowed.
323    pub allowed: bool,
324    /// Reason for denial (if denied).
325    pub reason: Option<String>,
326}
327
328impl SandboxAuditEntry {
329    /// Create a new audit entry for an allowed action.
330    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    /// Create a new audit entry for a denied action.
346    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        // On Linux, should be OsSandbox; on other platforms, Wasm.
372        // Either way, it is NOT "None".
373        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}