Skip to main content

mur_core/policy/
engine.rs

1//! Policy engine — evaluates rules and constitution together before actions.
2//!
3//! The engine combines the constitution's boundary checks with fine-grained
4//! policy rules and pattern injection.
5
6use crate::constitution::Constitution;
7use crate::types::{Action, ActionDecision};
8
9use super::patterns::PatternStore;
10use super::rules::{PolicyRuleSet, RuleAction, RuleContext};
11use anyhow::Result;
12use std::path::{Path, PathBuf};
13
14/// The policy engine evaluates actions against both the constitution
15/// and the policy rule set.
16pub struct PolicyEngine {
17    constitution: Constitution,
18    rules: PolicyRuleSet,
19    pattern_store: PatternStore,
20    rules_path: PathBuf,
21}
22
23impl PolicyEngine {
24    /// Create a new policy engine from a constitution and a rules directory.
25    pub fn new(constitution: Constitution, rules_dir: &Path, patterns_dir: &Path) -> Self {
26        let rules = load_rules_from_dir(rules_dir);
27        Self {
28            constitution,
29            rules,
30            pattern_store: PatternStore::new(patterns_dir),
31            rules_path: rules_dir.to_path_buf(),
32        }
33    }
34
35    /// Create a policy engine with an explicit rule set (for testing).
36    pub fn with_rules(
37        constitution: Constitution,
38        rules: PolicyRuleSet,
39        patterns_dir: &Path,
40    ) -> Self {
41        Self {
42            constitution,
43            rules,
44            pattern_store: PatternStore::new(patterns_dir),
45            rules_path: PathBuf::new(),
46        }
47    }
48
49    /// Evaluate an action against the constitution and policy rules.
50    ///
51    /// Decision priority:
52    /// 1. Constitution forbidden → Blocked (always wins)
53    /// 2. Policy rules can *downgrade* (block or require approval) but cannot
54    ///    *upgrade* a constitution `NeedsApproval` to `Allowed`.
55    /// 3. Constitution requires_approval / auto_allowed
56    /// 4. Default: NeedsApproval
57    pub fn evaluate(&self, action: &Action) -> ActionDecision {
58        // 1. Constitution forbidden check first (cannot be overridden)
59        let constitution_decision = self.constitution.check_action(action);
60        if matches!(constitution_decision, ActionDecision::Blocked { .. }) {
61            return constitution_decision;
62        }
63
64        // 2. Check policy rules
65        let ctx = RuleContext {
66            action_type: serde_json::to_value(&action.action_type).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default(),
67            command: action.command.clone(),
68            description: action.description.clone(),
69            workflow_id: None,
70            estimated_cost: None,
71        };
72
73        if let Some(rule_action) = self.rules.evaluate(&ctx) {
74            return match rule_action {
75                RuleAction::Allow => {
76                    // Rules cannot upgrade NeedsApproval → Allowed.
77                    // If the constitution requires approval, preserve that.
78                    if matches!(constitution_decision, ActionDecision::NeedsApproval { .. }) {
79                        constitution_decision
80                    } else {
81                        ActionDecision::Allowed
82                    }
83                }
84                RuleAction::Deny { reason } => ActionDecision::Blocked { reason },
85                RuleAction::RequireApproval { prompt } => {
86                    ActionDecision::NeedsApproval { prompt }
87                }
88            };
89        }
90
91        // 3. Fall back to constitution decision
92        constitution_decision
93    }
94
95    /// Evaluate an action with workflow context.
96    pub fn evaluate_with_context(
97        &self,
98        action: &Action,
99        workflow_id: Option<&str>,
100        estimated_cost: Option<f64>,
101    ) -> ActionDecision {
102        // 1. Constitution forbidden check
103        let constitution_decision = self.constitution.check_action(action);
104        if matches!(constitution_decision, ActionDecision::Blocked { .. }) {
105            return constitution_decision;
106        }
107
108        // 2. Check cost limits
109        if let Some(cost) = estimated_cost {
110            if !self.constitution.check_cost_per_run(cost) {
111                return ActionDecision::Blocked {
112                    reason: format!(
113                        "Estimated cost ${:.2} exceeds per-run limit",
114                        cost
115                    ),
116                };
117            }
118        }
119
120        // 3. Check policy rules with full context
121        let ctx = RuleContext {
122            action_type: serde_json::to_value(&action.action_type).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default(),
123            command: action.command.clone(),
124            description: action.description.clone(),
125            workflow_id: workflow_id.map(|s| s.to_string()),
126            estimated_cost,
127        };
128
129        if let Some(rule_action) = self.rules.evaluate(&ctx) {
130            return match rule_action {
131                RuleAction::Allow => {
132                    // Rules cannot upgrade NeedsApproval → Allowed.
133                    // If the constitution requires approval, preserve that.
134                    if matches!(constitution_decision, ActionDecision::NeedsApproval { .. }) {
135                        constitution_decision
136                    } else {
137                        ActionDecision::Allowed
138                    }
139                }
140                RuleAction::Deny { reason } => ActionDecision::Blocked { reason },
141                RuleAction::RequireApproval { prompt } => {
142                    ActionDecision::NeedsApproval { prompt }
143                }
144            };
145        }
146
147        // 4. Fall back to constitution
148        constitution_decision
149    }
150
151    /// Get pattern context for an action (for AI model injection).
152    pub fn get_pattern_context(
153        &self,
154        action_type: &str,
155        action_command: &str,
156    ) -> Result<Option<String>> {
157        self.pattern_store.format_context(action_type, action_command)
158    }
159
160    /// Get a reference to the current rule set.
161    pub fn rules(&self) -> &PolicyRuleSet {
162        &self.rules
163    }
164
165    /// Get a mutable reference to the current rule set.
166    pub fn rules_mut(&mut self) -> &mut PolicyRuleSet {
167        &mut self.rules
168    }
169
170    /// Save the current rules back to the rules directory.
171    pub fn save_rules(&self) -> Result<()> {
172        if self.rules_path.as_os_str().is_empty() {
173            anyhow::bail!("Cannot save rules: rules path is not set");
174        }
175        std::fs::create_dir_all(&self.rules_path)?;
176        // Remove existing rule files to prevent duplication on reload
177        if let Ok(entries) = std::fs::read_dir(&self.rules_path) {
178            for entry in entries.flatten() {
179                let path = entry.path();
180                if let Some(ext) = path.extension() {
181                    if ext == "yaml" || ext == "yml" {
182                        let _ = std::fs::remove_file(&path);
183                    }
184                }
185            }
186        }
187        let path = self.rules_path.join("rules.yaml");
188        let yaml = serde_yaml::to_string(&self.rules)?;
189        std::fs::write(path, yaml)?;
190        Ok(())
191    }
192
193    /// Reload rules from the rules directory.
194    pub fn reload_rules(&mut self) {
195        self.rules = load_rules_from_dir(&self.rules_path);
196    }
197
198    /// Get a reference to the constitution.
199    pub fn constitution(&self) -> &Constitution {
200        &self.constitution
201    }
202
203    /// Get a reference to the pattern store.
204    pub fn pattern_store(&self) -> &PatternStore {
205        &self.pattern_store
206    }
207}
208
209/// Load all rule sets from YAML files in a directory and merge them.
210fn load_rules_from_dir(dir: &Path) -> PolicyRuleSet {
211    let mut combined = PolicyRuleSet { rules: vec![] };
212
213    if !dir.exists() {
214        return combined;
215    }
216
217    let patterns = [
218        dir.join("*.yaml").to_string_lossy().to_string(),
219        dir.join("*.yml").to_string_lossy().to_string(),
220    ];
221
222    for pat in &patterns {
223        if let Ok(entries) = glob::glob(pat) {
224            for entry in entries.flatten() {
225                match std::fs::read_to_string(&entry) {
226                    Ok(content) => {
227                        match PolicyRuleSet::from_yaml(&content) {
228                            Ok(rule_set) => {
229                                combined.rules.extend(rule_set.rules);
230                            }
231                            Err(e) => {
232                                tracing::warn!("Failed to parse policy rules {:?}: {}", entry, e);
233                            }
234                        }
235                    }
236                    Err(e) => {
237                        tracing::warn!("Failed to read policy rules {:?}: {}", entry, e);
238                    }
239                }
240            }
241        }
242    }
243
244    combined
245}
246
247/// Default directories for policy engine.
248pub fn default_rules_dir() -> PathBuf {
249    directories::BaseDirs::new()
250        .map(|d| d.home_dir().join(".mur").join("policies"))
251        .unwrap_or_else(|| PathBuf::from(".mur/policies"))
252}
253
254pub fn default_patterns_dir() -> PathBuf {
255    directories::BaseDirs::new()
256        .map(|d| d.home_dir().join(".mur").join("patterns"))
257        .unwrap_or_else(|| PathBuf::from(".mur/patterns"))
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::types::ActionType;
264
265    fn sample_constitution() -> Constitution {
266        let toml_str = r#"
267[identity]
268version = "1.0.0"
269checksum = ""
270signed_by = ""
271signature = ""
272
273[boundaries]
274forbidden = ["rm -rf /", "DROP DATABASE"]
275requires_approval = ["git push", "deploy *"]
276auto_allowed = ["git status", "run tests", "read files"]
277
278[resource_limits]
279max_api_cost_per_run = 5.0
280max_api_cost_per_day = 50.0
281max_execution_time = 3600
282max_concurrent_workflows = 3
283max_file_write_size = "10MB"
284allowed_directories = ["~/Projects"]
285blocked_directories = ["/etc"]
286
287[model_permissions]
288thinking_model = { can_execute = false, can_read = true }
289coding_model = { can_execute = true, can_read = true }
290task_model = { can_execute = true, can_read = true }
291"#;
292        Constitution::from_toml(toml_str).unwrap()
293    }
294
295    fn make_action(command: &str, description: &str) -> Action {
296        Action {
297            id: uuid::Uuid::new_v4(),
298            action_type: ActionType::Execute,
299            description: description.to_string(),
300            command: command.to_string(),
301            working_dir: None,
302            created_at: chrono::Utc::now(),
303        }
304    }
305
306    #[test]
307    fn test_constitution_forbidden_wins() {
308        let dir = tempfile::TempDir::new().unwrap();
309        let engine = PolicyEngine::with_rules(
310            sample_constitution(),
311            PolicyRuleSet { rules: vec![] },
312            dir.path(),
313        );
314
315        let action = make_action("rm -rf /", "Delete everything");
316        let decision = engine.evaluate(&action);
317        assert!(matches!(decision, ActionDecision::Blocked { .. }));
318    }
319
320    #[test]
321    fn test_policy_rule_cannot_upgrade_needs_approval() {
322        use super::super::rules::{PolicyRule, RuleCondition};
323
324        let dir = tempfile::TempDir::new().unwrap();
325        let rules = PolicyRuleSet {
326            rules: vec![PolicyRule {
327                name: "allow-push".into(),
328                description: "Allow git push to staging".into(),
329                priority: 1,
330                condition: RuleCondition {
331                    action_type: None,
332                    command_pattern: Some("git push origin staging".into()),
333                    description_pattern: None,
334                    workflow_id: None,
335                    time_range: None,
336                    cost_above: None,
337                },
338                action: RuleAction::Allow,
339                enabled: true,
340            }],
341        };
342
343        let engine = PolicyEngine::with_rules(sample_constitution(), rules, dir.path());
344
345        // git push origin staging → constitution requires approval for "git push *",
346        // policy rule says Allow, but rules cannot upgrade NeedsApproval → stays NeedsApproval
347        let action = make_action("git push origin staging", "Push to staging");
348        let decision = engine.evaluate(&action);
349        assert!(matches!(decision, ActionDecision::NeedsApproval { .. }));
350
351        // git push origin main → no policy rule matches, falls through to constitution
352        let action2 = make_action("git push origin main", "Push to main");
353        let decision2 = engine.evaluate(&action2);
354        assert!(matches!(decision2, ActionDecision::NeedsApproval { .. }));
355    }
356
357    #[test]
358    fn test_policy_rule_can_allow_when_constitution_allows() {
359        use super::super::rules::{PolicyRule, RuleCondition};
360
361        let dir = tempfile::TempDir::new().unwrap();
362        let rules = PolicyRuleSet {
363            rules: vec![PolicyRule {
364                name: "allow-status".into(),
365                description: "Explicitly allow git status".into(),
366                priority: 1,
367                condition: RuleCondition {
368                    action_type: None,
369                    command_pattern: Some("git status".into()),
370                    description_pattern: None,
371                    workflow_id: None,
372                    time_range: None,
373                    cost_above: None,
374                },
375                action: RuleAction::Allow,
376                enabled: true,
377            }],
378        };
379
380        let engine = PolicyEngine::with_rules(sample_constitution(), rules, dir.path());
381
382        // git status → constitution auto_allowed, rule also allows → stays Allowed
383        let action = make_action("git status", "Check status");
384        let decision = engine.evaluate(&action);
385        assert!(matches!(decision, ActionDecision::Allowed));
386    }
387
388    #[test]
389    fn test_policy_rule_can_block_when_constitution_allows() {
390        use super::super::rules::{PolicyRule, RuleCondition};
391
392        let dir = tempfile::TempDir::new().unwrap();
393        let rules = PolicyRuleSet {
394            rules: vec![PolicyRule {
395                name: "deny-status".into(),
396                description: "Block git status for some reason".into(),
397                priority: 1,
398                condition: RuleCondition {
399                    action_type: None,
400                    command_pattern: Some("git status".into()),
401                    description_pattern: None,
402                    workflow_id: None,
403                    time_range: None,
404                    cost_above: None,
405                },
406                action: RuleAction::Deny {
407                    reason: "Blocked by policy".into(),
408                },
409                enabled: true,
410            }],
411        };
412
413        let engine = PolicyEngine::with_rules(sample_constitution(), rules, dir.path());
414
415        // git status → constitution allows, but policy rule blocks → Blocked
416        let action = make_action("git status", "Check status");
417        let decision = engine.evaluate(&action);
418        assert!(matches!(decision, ActionDecision::Blocked { .. }));
419    }
420
421    #[test]
422    fn test_fallback_to_constitution() {
423        let dir = tempfile::TempDir::new().unwrap();
424        let engine = PolicyEngine::with_rules(
425            sample_constitution(),
426            PolicyRuleSet { rules: vec![] },
427            dir.path(),
428        );
429
430        let action = make_action("git status", "Check status");
431        let decision = engine.evaluate(&action);
432        assert!(matches!(decision, ActionDecision::Allowed));
433    }
434
435    #[test]
436    fn test_cost_limit_enforcement() {
437        let dir = tempfile::TempDir::new().unwrap();
438        let engine = PolicyEngine::with_rules(
439            sample_constitution(),
440            PolicyRuleSet { rules: vec![] },
441            dir.path(),
442        );
443
444        let action = make_action("api call", "Call expensive model");
445        let decision = engine.evaluate_with_context(&action, None, Some(10.0));
446        assert!(matches!(decision, ActionDecision::Blocked { .. }));
447
448        let decision2 = engine.evaluate_with_context(&action, None, Some(3.0));
449        // Under limit, falls through to constitution (unknown action → needs approval)
450        assert!(matches!(decision2, ActionDecision::NeedsApproval { .. }));
451    }
452
453    #[test]
454    fn test_load_rules_from_dir() {
455        let dir = tempfile::TempDir::new().unwrap();
456        let yaml = r#"
457rules:
458  - name: test-rule
459    priority: 1
460    condition:
461      command_pattern: "test*"
462    action: allow
463    enabled: true
464"#;
465        std::fs::write(dir.path().join("test-rules.yaml"), yaml).unwrap();
466
467        let rules = load_rules_from_dir(dir.path());
468        assert_eq!(rules.rules.len(), 1);
469        assert_eq!(rules.rules[0].name, "test-rule");
470    }
471
472    #[test]
473    fn test_pattern_context_integration() {
474        let patterns_dir = tempfile::TempDir::new().unwrap();
475        let rules_dir = tempfile::TempDir::new().unwrap();
476
477        let yaml = r#"
478id: deploy-safety
479name: Deploy Safety
480description: Always run health checks after deploy
481inject: on_match
482match_actions: ["deploy*"]
483"#;
484        std::fs::write(patterns_dir.path().join("deploy-safety.yaml"), yaml).unwrap();
485
486        let engine = PolicyEngine::with_rules(
487            sample_constitution(),
488            PolicyRuleSet { rules: vec![] },
489            patterns_dir.path(),
490        );
491
492        let ctx = engine
493            .get_pattern_context("execute", "deploy production")
494            .unwrap();
495        assert!(ctx.is_some());
496        assert!(ctx.unwrap().contains("Deploy Safety"));
497
498        let no_ctx = engine.get_pattern_context("read", "git log").unwrap();
499        // No patterns match "git log"
500        assert!(no_ctx.is_none());
501    }
502
503    #[test]
504    fn test_save_and_reload_rules() {
505        let dir = tempfile::TempDir::new().unwrap();
506        let patterns_dir = tempfile::TempDir::new().unwrap();
507
508        let mut engine = PolicyEngine::new(
509            sample_constitution(),
510            dir.path(),
511            patterns_dir.path(),
512        );
513
514        use super::super::rules::{PolicyRule, RuleCondition};
515        engine.rules_mut().add_rule(PolicyRule {
516            name: "saved-rule".into(),
517            description: "".into(),
518            priority: 50,
519            condition: RuleCondition {
520                action_type: None,
521                command_pattern: Some("*".into()),
522                description_pattern: None,
523                workflow_id: None,
524                time_range: None,
525                cost_above: None,
526            },
527            action: RuleAction::Allow,
528            enabled: true,
529        });
530
531        engine.save_rules().unwrap();
532        engine.reload_rules();
533        assert_eq!(engine.rules().rules.len(), 1);
534        assert_eq!(engine.rules().rules[0].name, "saved-rule");
535    }
536}