1use 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
14pub struct PolicyEngine {
17 constitution: Constitution,
18 rules: PolicyRuleSet,
19 pattern_store: PatternStore,
20 rules_path: PathBuf,
21}
22
23impl PolicyEngine {
24 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 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 pub fn evaluate(&self, action: &Action) -> ActionDecision {
58 let constitution_decision = self.constitution.check_action(action);
60 if matches!(constitution_decision, ActionDecision::Blocked { .. }) {
61 return constitution_decision;
62 }
63
64 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 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 constitution_decision
93 }
94
95 pub fn evaluate_with_context(
97 &self,
98 action: &Action,
99 workflow_id: Option<&str>,
100 estimated_cost: Option<f64>,
101 ) -> ActionDecision {
102 let constitution_decision = self.constitution.check_action(action);
104 if matches!(constitution_decision, ActionDecision::Blocked { .. }) {
105 return constitution_decision;
106 }
107
108 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 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 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 constitution_decision
149 }
150
151 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 pub fn rules(&self) -> &PolicyRuleSet {
162 &self.rules
163 }
164
165 pub fn rules_mut(&mut self) -> &mut PolicyRuleSet {
167 &mut self.rules
168 }
169
170 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 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 pub fn reload_rules(&mut self) {
195 self.rules = load_rules_from_dir(&self.rules_path);
196 }
197
198 pub fn constitution(&self) -> &Constitution {
200 &self.constitution
201 }
202
203 pub fn pattern_store(&self) -> &PatternStore {
205 &self.pattern_store
206 }
207}
208
209fn 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
247pub 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 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 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 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 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 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 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}