1use anyhow::{Context, Result};
6use starlark::environment::{Globals, GlobalsBuilder, Module};
7use starlark::eval::Evaluator;
8use starlark::starlark_module;
9use starlark::syntax::{AstModule, Dialect};
10use starlark::values::none::NoneType;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PolicyDecision {
16 Allow,
18 Prompt(String),
20 Deny(String),
22}
23
24pub struct PolicyEngine {
26 policies: Vec<String>,
28 policy_dir: PathBuf,
30}
31
32impl PolicyEngine {
33 pub fn new() -> Result<Self> {
35 let policy_dir = Self::default_policy_dir();
36 let mut engine = Self {
37 policies: Vec::new(),
38 policy_dir: policy_dir.clone(),
39 };
40
41 if policy_dir.exists() {
43 engine.load_policies()?;
44 } else {
45 log::info!(
46 "Policy directory {:?} does not exist, using defaults",
47 policy_dir
48 );
49 }
50
51 Ok(engine)
52 }
53
54 pub fn default_policy_dir() -> PathBuf {
56 perspt_core::paths::resolve_policy_dir()
58 .or_else(perspt_core::paths::policy_dir)
59 .unwrap_or_else(|| PathBuf::from(".").join(".perspt").join("rules"))
60 }
61
62 pub fn load_policies(&mut self) -> Result<()> {
64 if !self.policy_dir.exists() {
65 return Ok(());
66 }
67
68 for entry in std::fs::read_dir(&self.policy_dir)? {
69 let entry = entry?;
70 let path = entry.path();
71
72 if path.extension().is_some_and(|ext| ext == "star") {
73 match self.load_policy_file(&path) {
74 Ok(module) => {
75 self.policies.push(module);
76 log::info!("Loaded policy: {:?}", path);
77 }
78 Err(e) => {
79 log::warn!("Failed to load policy {:?}: {}", path, e);
80 }
81 }
82 }
83 }
84
85 log::info!("Loaded {} policies", self.policies.len());
86 Ok(())
87 }
88
89 fn load_policy_file(&self, path: &Path) -> Result<String> {
94 let content = std::fs::read_to_string(path)
95 .context(format!("Failed to read policy file: {:?}", path))?;
96
97 let executable = format!("{content}\nevaluate\n");
98 let ast = AstModule::parse(
99 path.to_string_lossy().as_ref(),
100 executable.clone(),
101 &Dialect::Standard,
102 )
103 .map_err(|e| anyhow::anyhow!("Parse error: {}", e))?;
104
105 let globals = Self::create_globals();
106
107 Module::with_temp_heap(|module| {
108 {
109 let mut eval = Evaluator::new(&module);
110 eval.eval_module(ast, &globals)
111 .map_err(|e| anyhow::anyhow!("Eval error: {}", e))?;
112 }
113
114 Ok(executable)
115 })
116 }
117
118 fn create_globals() -> Globals {
120 #[starlark_module]
121 fn policy_builtins(builder: &mut GlobalsBuilder) {
122 fn matches_pattern(command: &str, pattern: &str) -> anyhow::Result<bool> {
124 Ok(command.contains(pattern))
125 }
126
127 fn log_policy(message: &str) -> anyhow::Result<NoneType> {
129 log::info!("[Policy] {}", message);
130 Ok(NoneType)
131 }
132 }
133
134 GlobalsBuilder::standard().with(policy_builtins).build()
135 }
136
137 pub fn evaluate(&self, command: &str) -> PolicyDecision {
139 if self.policies.is_empty() {
141 return self.default_policy(command);
142 }
143 let mut decision = self.default_policy(command);
144 for policy in &self.policies {
145 let evaluated: anyhow::Result<PolicyDecision> = Module::with_temp_heap(|module| {
146 let mut evaluator = Evaluator::new(&module);
147 evaluator.set_max_callstack_size(32)?;
148 evaluator.set_max_heap_size(8 * 1024 * 1024)?;
149 evaluator.set_max_tick_count(25_000)?;
150 let ast = AstModule::parse("policy.star", policy.clone(), &Dialect::Standard)
151 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
152 let function = evaluator
153 .eval_module(ast, &Self::create_globals())
154 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
155 let argument = module.heap().alloc(command);
156 let value = evaluator
157 .eval_function(function, &[argument], &[])
158 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
159 match value.unpack_str() {
160 Some("allow") => Ok(PolicyDecision::Allow),
161 Some("prompt") => Ok(PolicyDecision::Prompt(
162 "Starlark policy requires approval".into(),
163 )),
164 Some("deny") => Ok(PolicyDecision::Deny(
165 "Starlark policy denied command".into(),
166 )),
167 Some(other) => {
168 anyhow::bail!("Starlark policy returned unknown decision {other:?}")
169 }
170 None => match value.unpack_bool() {
171 Some(true) => Ok(PolicyDecision::Allow),
172 Some(false) => Ok(PolicyDecision::Deny(
173 "Starlark policy denied command".into(),
174 )),
175 None => anyhow::bail!(
176 "Starlark policy must return allow, prompt, deny, or bool"
177 ),
178 },
179 }
180 });
181 let policy_decision = match evaluated {
182 Ok(decision) => decision,
183 Err(error) => {
184 return PolicyDecision::Deny(format!(
185 "Starlark policy evaluation failed: {error}"
186 ));
187 }
188 };
189 decision = stricter_decision(decision, policy_decision);
190 }
191 decision
192 }
193
194 fn default_policy(&self, command: &str) -> PolicyDecision {
204 let dangerous_patterns = ["rm -rf", "sudo", "chmod 777", "> /dev/", "mkfs", "dd if="];
206
207 for pattern in &dangerous_patterns {
208 if command.contains(pattern) {
209 return PolicyDecision::Deny(format!(
210 "Command contains dangerous pattern: {}",
211 pattern
212 ));
213 }
214 }
215
216 let network_patterns = ["curl", "wget", "nc ", "ssh ", "scp "];
218 for pattern in &network_patterns {
219 if command.contains(pattern) {
220 return PolicyDecision::Prompt(format!(
221 "Command requires network access: {}",
222 command
223 ));
224 }
225 }
226
227 if command.contains("git push") || command.contains("git force") {
229 return PolicyDecision::Prompt("Git push operation requires confirmation".to_string());
230 }
231
232 PolicyDecision::Allow
233 }
234
235 pub fn is_safe(&self, command: &str) -> bool {
237 matches!(self.evaluate(command), PolicyDecision::Allow)
238 }
239}
240
241fn stricter_decision(left: PolicyDecision, right: PolicyDecision) -> PolicyDecision {
242 match (left, right) {
243 (decision @ PolicyDecision::Deny(_), _) | (_, decision @ PolicyDecision::Deny(_)) => {
244 decision
245 }
246 (decision @ PolicyDecision::Prompt(_), _) | (_, decision @ PolicyDecision::Prompt(_)) => {
247 decision
248 }
249 _ => PolicyDecision::Allow,
250 }
251}
252
253impl Default for PolicyEngine {
254 fn default() -> Self {
255 Self::new().unwrap_or_else(|_| Self {
256 policies: Vec::new(),
257 policy_dir: PathBuf::from("."),
258 })
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_default_policy_allows_safe_commands() {
268 let engine = PolicyEngine::default();
269 assert!(matches!(
270 engine.evaluate("cargo build"),
271 PolicyDecision::Allow
272 ));
273 assert!(matches!(engine.evaluate("ls -la"), PolicyDecision::Allow));
274 }
275
276 #[test]
277 fn test_default_policy_denies_dangerous() {
278 let engine = PolicyEngine::default();
279 assert!(matches!(
280 engine.evaluate("rm -rf /"),
281 PolicyDecision::Deny(_)
282 ));
283 assert!(matches!(
284 engine.evaluate("sudo rm file"),
285 PolicyDecision::Deny(_)
286 ));
287 }
288
289 #[test]
290 fn test_default_policy_prompts_network() {
291 let engine = PolicyEngine::default();
292 assert!(matches!(
293 engine.evaluate("curl https://example.com"),
294 PolicyDecision::Prompt(_)
295 ));
296 }
297
298 #[test]
299 fn loaded_starlark_policy_is_actually_evaluated() {
300 let directory = tempfile::tempdir().unwrap();
301 let path = directory.path().join("deny.star");
302 std::fs::write(
303 &path,
304 "def evaluate(command):\n return 'deny' if 'forbidden' in command else 'allow'\n",
305 )
306 .unwrap();
307 let mut engine = PolicyEngine {
308 policies: Vec::new(),
309 policy_dir: directory.path().to_path_buf(),
310 };
311 engine.load_policies().unwrap();
312 assert_eq!(engine.evaluate("ls"), PolicyDecision::Allow);
313 assert!(matches!(
314 engine.evaluate("forbidden operation"),
315 PolicyDecision::Deny(_)
316 ));
317 }
318}