Skip to main content

perspt_policy/
engine.rs

1//! Starlark Policy Engine
2//!
3//! Evaluates Starlark rules from ~/.perspt/rules to control command execution.
4
5use 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/// Policy decision for a command
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PolicyDecision {
16    /// Allow the command to execute
17    Allow,
18    /// Require user confirmation before execution
19    Prompt(String),
20    /// Deny the command execution
21    Deny(String),
22}
23
24/// Policy engine that evaluates Starlark rules
25pub struct PolicyEngine {
26    /// Loaded policy modules
27    policies: Vec<String>,
28    /// Path to policy directory
29    policy_dir: PathBuf,
30}
31
32impl PolicyEngine {
33    /// Create a new policy engine
34    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        // Load policies if directory exists
42        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    /// Get the default policy directory
55    pub fn default_policy_dir() -> PathBuf {
56        // Use centralized path resolution with legacy fallback
57        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    /// Load all .star files from the policy directory
63    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    /// Load a single policy file. The stored form is the wrapped executable
90    /// (`<content>\nevaluate\n`) built exactly once here; `evaluate` re-parses
91    /// it per command because a Starlark `AstModule` is consumed by
92    /// evaluation, but it never re-wraps or re-validates.
93    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    /// Create the globals for Starlark evaluation
119    fn create_globals() -> Globals {
120        #[starlark_module]
121        fn policy_builtins(builder: &mut GlobalsBuilder) {
122            /// Check if a command matches a pattern
123            fn matches_pattern(command: &str, pattern: &str) -> anyhow::Result<bool> {
124                Ok(command.contains(pattern))
125            }
126
127            /// Log a message from policy
128            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    /// Evaluate a command against loaded policies
138    pub fn evaluate(&self, command: &str) -> PolicyDecision {
139        // If no policies loaded, use default behavior
140        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    /// Default policy when no rules are loaded.
195    ///
196    /// Input is the canonical command line (`CommandInvocation::command_line`),
197    /// so these substring patterns match real invocations. The `Allow`
198    /// fallthrough is deliberate: deny-by-default already lives in the layers
199    /// below — `classify_tier` treats unknown programs as mutations, the OS
200    /// process sandbox denies by default, and the capability kernel bounds
201    /// command scope. This layer exists to let operators *restrict further*
202    /// via Starlark, not to be the only gate.
203    fn default_policy(&self, command: &str) -> PolicyDecision {
204        // Always prompt for potentially dangerous commands
205        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        // Prompt for network access
217        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        // Prompt for git push operations
228        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    /// Check if a command is allowed without prompting
236    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}