Skip to main content

mur_core/constitution/
mod.rs

1//! Constitution system — the core safety mechanism for MUR Commander.
2//!
3//! The constitution defines what the agent is allowed to do, what requires
4//! human approval, and what is absolutely forbidden. It is tamper-proof
5//! via Ed25519 signatures and SHA-256 checksums.
6
7pub mod signing;
8
9use crate::types::{Action, ActionDecision};
10use serde::{Deserialize, Serialize};
11use std::path::Path;
12use thiserror::Error;
13
14/// Errors that can occur when loading or checking the constitution.
15#[derive(Debug, Error)]
16pub enum ConstitutionError {
17    #[error("Failed to read constitution file: {0}")]
18    ReadError(#[from] std::io::Error),
19
20    #[error("Failed to parse constitution TOML: {0}")]
21    ParseError(#[from] toml::de::Error),
22
23    #[error("Constitution integrity check failed: {0}")]
24    IntegrityError(String),
25
26    #[error("Constitution signing error: {0}")]
27    SigningError(String),
28}
29
30/// The parsed constitution configuration from TOML.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ConstitutionConfig {
33    pub identity: Identity,
34    pub boundaries: Boundaries,
35    pub resource_limits: ResourceLimits,
36    pub model_permissions: ModelPermissions,
37}
38
39/// Identity section — version and signing metadata.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Identity {
42    pub version: String,
43    /// SHA-256 hex digest of the constitution content (excluding identity section).
44    #[serde(default)]
45    pub checksum: String,
46    /// Who signed this constitution.
47    #[serde(default)]
48    pub signed_by: String,
49    /// Base64-encoded Ed25519 signature.
50    #[serde(default)]
51    pub signature: String,
52}
53
54/// Boundary rules — forbidden, requires_approval, auto_allowed patterns.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Boundaries {
57    /// Patterns that are absolutely forbidden.
58    pub forbidden: Vec<String>,
59    /// Patterns that require user approval.
60    pub requires_approval: Vec<String>,
61    /// Patterns that are automatically allowed.
62    pub auto_allowed: Vec<String>,
63}
64
65/// Resource limits to prevent runaway execution.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ResourceLimits {
68    /// Maximum API cost (USD) per workflow run.
69    pub max_api_cost_per_run: f64,
70    /// Maximum API cost (USD) per day.
71    pub max_api_cost_per_day: f64,
72    /// Maximum execution time in seconds per workflow.
73    pub max_execution_time: u64,
74    /// Maximum number of concurrent workflows.
75    pub max_concurrent_workflows: u32,
76    /// Maximum file write size (e.g. "10MB").
77    pub max_file_write_size: String,
78    /// Directories the agent is allowed to access.
79    pub allowed_directories: Vec<String>,
80    /// Directories the agent must never access.
81    pub blocked_directories: Vec<String>,
82}
83
84/// Per-model-role permission flags.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ModelPermission {
87    pub can_execute: bool,
88    pub can_read: bool,
89    #[serde(default)]
90    pub sandbox_only: bool,
91}
92
93/// Model permissions section.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ModelPermissions {
96    pub thinking_model: ModelPermission,
97    pub coding_model: ModelPermission,
98    pub task_model: ModelPermission,
99}
100
101/// The loaded constitution with parsed config.
102#[derive(Debug, Clone)]
103pub struct Constitution {
104    /// Parsed configuration from the TOML file.
105    pub config: ConstitutionConfig,
106    /// Raw TOML content (used for checksum/signature verification).
107    raw_content: String,
108}
109
110impl Constitution {
111    /// Load a constitution from a TOML file path.
112    pub fn load(path: &Path) -> Result<Self, ConstitutionError> {
113        let raw_content = std::fs::read_to_string(path)?;
114        let config: ConstitutionConfig = toml::from_str(&raw_content)?;
115        Ok(Self {
116            config,
117            raw_content,
118        })
119    }
120
121    /// Load a constitution from a raw TOML string (useful for testing).
122    pub fn from_toml(content: &str) -> Result<Self, ConstitutionError> {
123        let config: ConstitutionConfig = toml::from_str(content)?;
124        Ok(Self {
125            config,
126            raw_content: content.to_string(),
127        })
128    }
129
130    /// Get the raw TOML content.
131    pub fn raw_content(&self) -> &str {
132        &self.raw_content
133    }
134
135    /// Check whether a given action is allowed by the constitution.
136    ///
137    /// Decision priority:
138    /// 1. Forbidden patterns → Blocked
139    /// 2. Requires approval patterns → NeedsApproval
140    /// 3. Auto-allowed patterns → Allowed
141    /// 4. Unknown actions → NeedsApproval (safe default)
142    pub fn check_action(&self, action: &Action) -> ActionDecision {
143        let command = action.command.to_lowercase();
144        let description = action.description.to_lowercase();
145
146        // 1. Check forbidden patterns first (highest priority)
147        for pattern in &self.config.boundaries.forbidden {
148            if matches_pattern(&command, pattern) || matches_pattern(&description, pattern) {
149                return ActionDecision::Blocked {
150                    reason: format!("Forbidden by constitution: matches '{}'", pattern),
151                };
152            }
153        }
154
155        // 2. Check requires_approval patterns
156        for pattern in &self.config.boundaries.requires_approval {
157            if matches_pattern(&command, pattern) || matches_pattern(&description, pattern) {
158                return ActionDecision::NeedsApproval {
159                    prompt: format!(
160                        "Action requires approval (matches '{}'): {}",
161                        pattern, action.description
162                    ),
163                };
164            }
165        }
166
167        // 3. Check auto_allowed patterns
168        for pattern in &self.config.boundaries.auto_allowed {
169            if matches_pattern(&command, pattern) || matches_pattern(&description, pattern) {
170                return ActionDecision::Allowed;
171            }
172        }
173
174        // 4. Default: unknown actions require approval (safe default)
175        ActionDecision::NeedsApproval {
176            prompt: format!(
177                "Unknown action, requesting approval: {}",
178                action.description
179            ),
180        }
181    }
182
183    /// Check whether a cost amount exceeds the per-run limit.
184    pub fn check_cost_per_run(&self, cost: f64) -> bool {
185        cost <= self.config.resource_limits.max_api_cost_per_run
186    }
187
188    /// Check whether a cost amount exceeds the daily limit.
189    pub fn check_cost_per_day(&self, cost: f64) -> bool {
190        cost <= self.config.resource_limits.max_api_cost_per_day
191    }
192
193    /// Check if a directory path is allowed by the constitution.
194    pub fn is_directory_allowed(&self, path: &str) -> bool {
195        // First check blocked directories (highest priority)
196        for blocked in &self.config.resource_limits.blocked_directories {
197            let expanded = expand_tilde(blocked);
198            if path.starts_with(&expanded) {
199                return false;
200            }
201        }
202
203        // Then check allowed directories
204        for allowed in &self.config.resource_limits.allowed_directories {
205            let expanded = expand_tilde(allowed);
206            if path.starts_with(&expanded) {
207                return true;
208            }
209        }
210
211        false
212    }
213}
214
215/// Simple glob-like pattern matching for constitution rules.
216///
217/// Supports:
218/// - `*` as a wildcard (matches any substring)
219/// - Case-insensitive matching
220/// - Word-boundary-aware matching (the first non-wildcard segment must
221///   start at a word boundary to prevent "undeploy" matching "deploy *")
222fn matches_pattern(text: &str, pattern: &str) -> bool {
223    let text_lower = text.to_lowercase();
224    let pattern_lower = pattern.to_lowercase();
225
226    if pattern_lower.contains('*') {
227        // Split on wildcards and check if all parts appear in order
228        let parts: Vec<&str> = pattern_lower.split('*').collect();
229        let mut search_from = 0;
230        let mut is_first_part = true;
231        for part in &parts {
232            if part.is_empty() {
233                is_first_part = false;
234                continue;
235            }
236            match find_word_start(&text_lower[search_from..], part, is_first_part) {
237                Some(pos) => search_from += pos + part.len(),
238                None => return false,
239            }
240            is_first_part = false;
241        }
242        true
243    } else {
244        // Word-boundary-aware substring match
245        find_word_start(&text_lower, &pattern_lower, true).is_some()
246    }
247}
248
249/// Find a substring starting at a word boundary.
250///
251/// A word boundary means the match is either at position 0, or preceded by
252/// a non-alphanumeric character (space, slash, hyphen, etc.).
253/// If `require_word_boundary` is false, acts as a plain `find`.
254fn find_word_start(text: &str, needle: &str, require_word_boundary: bool) -> Option<usize> {
255    let mut start = 0;
256    while let Some(pos) = text[start..].find(needle) {
257        let abs_pos = start + pos;
258        if !require_word_boundary || abs_pos == 0 || !text.as_bytes()[abs_pos - 1].is_ascii_alphanumeric() {
259            return Some(abs_pos);
260        }
261        // Skip this match, try from the next character
262        start = abs_pos + 1;
263        if start >= text.len() {
264            break;
265        }
266    }
267    None
268}
269
270/// Expand `~` to the user's home directory.
271fn expand_tilde(path: &str) -> String {
272    if path.starts_with('~') {
273        if let Some(home) = dirs_home() {
274            return path.replacen('~', &home, 1);
275        }
276    }
277    path.to_string()
278}
279
280fn dirs_home() -> Option<String> {
281    directories::BaseDirs::new().map(|d| d.home_dir().to_string_lossy().to_string())
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::types::{ActionType, Action};
288    use chrono::Utc;
289    use uuid::Uuid;
290
291    fn make_action(command: &str, description: &str) -> Action {
292        Action {
293            id: Uuid::new_v4(),
294            action_type: ActionType::Execute,
295            description: description.to_string(),
296            command: command.to_string(),
297            working_dir: None,
298            created_at: Utc::now(),
299        }
300    }
301
302    fn sample_constitution() -> Constitution {
303        let toml_str = r#"
304[identity]
305version = "1.0.0"
306checksum = ""
307signed_by = ""
308signature = ""
309
310[boundaries]
311forbidden = [
312    "rm -rf /",
313    "sudo rm *",
314    "DROP DATABASE",
315    "format disk",
316    "send email without approval",
317    "access /etc/shadow",
318    "modify constitution without re-sign",
319]
320
321requires_approval = [
322    "git push",
323    "deploy *",
324    "send notification",
325    "modify system config",
326    "install package",
327    "network request to unknown host",
328    "spend > $1 on API calls",
329]
330
331auto_allowed = [
332    "read files in project directory",
333    "run tests",
334    "git status",
335    "git diff",
336    "git log",
337    "search patterns",
338    "local HTTP on localhost",
339]
340
341[resource_limits]
342max_api_cost_per_run = 5.0
343max_api_cost_per_day = 50.0
344max_execution_time = 3600
345max_concurrent_workflows = 3
346max_file_write_size = "10MB"
347allowed_directories = ["~/Projects", "~/.mur", "/tmp"]
348blocked_directories = ["/etc", "/System", "~/.ssh"]
349
350[model_permissions]
351thinking_model = { can_execute = false, can_read = true }
352coding_model = { can_execute = true, can_read = true, sandbox_only = true }
353task_model = { can_execute = true, can_read = true }
354"#;
355        Constitution::from_toml(toml_str).expect("Failed to parse sample constitution")
356    }
357
358    #[test]
359    fn test_load_constitution_from_str() {
360        let c = sample_constitution();
361        assert_eq!(c.config.identity.version, "1.0.0");
362        assert_eq!(c.config.boundaries.forbidden.len(), 7);
363        assert_eq!(c.config.boundaries.requires_approval.len(), 7);
364        assert_eq!(c.config.boundaries.auto_allowed.len(), 7);
365    }
366
367    #[test]
368    fn test_check_action_forbidden() {
369        let c = sample_constitution();
370        let action = make_action("rm -rf /", "Delete everything");
371        let decision = c.check_action(&action);
372        assert!(matches!(decision, ActionDecision::Blocked { .. }));
373    }
374
375    #[test]
376    fn test_check_action_forbidden_drop_database() {
377        let c = sample_constitution();
378        let action = make_action("DROP DATABASE production", "Drop the production database");
379        let decision = c.check_action(&action);
380        assert!(matches!(decision, ActionDecision::Blocked { .. }));
381    }
382
383    #[test]
384    fn test_check_action_requires_approval() {
385        let c = sample_constitution();
386        let action = make_action("git push origin main", "Push to remote");
387        let decision = c.check_action(&action);
388        assert!(matches!(decision, ActionDecision::NeedsApproval { .. }));
389    }
390
391    #[test]
392    fn test_check_action_requires_approval_deploy() {
393        let c = sample_constitution();
394        let action = make_action("deploy production", "Deploy to production");
395        let decision = c.check_action(&action);
396        assert!(matches!(decision, ActionDecision::NeedsApproval { .. }));
397    }
398
399    #[test]
400    fn test_check_action_auto_allowed() {
401        let c = sample_constitution();
402        let action = make_action("git status", "Check git status");
403        let decision = c.check_action(&action);
404        assert!(matches!(decision, ActionDecision::Allowed));
405    }
406
407    #[test]
408    fn test_check_action_auto_allowed_tests() {
409        let c = sample_constitution();
410        let action = make_action("cargo test", "run tests");
411        let decision = c.check_action(&action);
412        assert!(matches!(decision, ActionDecision::Allowed));
413    }
414
415    #[test]
416    fn test_check_action_unknown_defaults_to_approval() {
417        let c = sample_constitution();
418        let action = make_action("some-unknown-command", "Do something weird");
419        let decision = c.check_action(&action);
420        assert!(matches!(decision, ActionDecision::NeedsApproval { .. }));
421    }
422
423    #[test]
424    fn test_cost_limits() {
425        let c = sample_constitution();
426        assert!(c.check_cost_per_run(4.99));
427        assert!(c.check_cost_per_run(5.0));
428        assert!(!c.check_cost_per_run(5.01));
429
430        assert!(c.check_cost_per_day(49.99));
431        assert!(!c.check_cost_per_day(50.01));
432    }
433
434    #[test]
435    fn test_matches_pattern_simple() {
436        assert!(matches_pattern("git push origin main", "git push"));
437        assert!(!matches_pattern("git pull origin main", "git push"));
438    }
439
440    #[test]
441    fn test_matches_pattern_wildcard() {
442        assert!(matches_pattern("deploy production", "deploy *"));
443        assert!(matches_pattern("deploy staging", "deploy *"));
444        assert!(!matches_pattern("undeploy staging", "deploy *"));
445    }
446
447    #[test]
448    fn test_matches_pattern_case_insensitive() {
449        assert!(matches_pattern("DROP DATABASE foo", "drop database"));
450        assert!(matches_pattern("drop database foo", "DROP DATABASE"));
451    }
452
453    #[test]
454    fn test_resource_limits_parsing() {
455        let c = sample_constitution();
456        assert_eq!(c.config.resource_limits.max_api_cost_per_run, 5.0);
457        assert_eq!(c.config.resource_limits.max_execution_time, 3600);
458        assert_eq!(c.config.resource_limits.max_concurrent_workflows, 3);
459    }
460
461    #[test]
462    fn test_model_permissions() {
463        let c = sample_constitution();
464        assert!(!c.config.model_permissions.thinking_model.can_execute);
465        assert!(c.config.model_permissions.thinking_model.can_read);
466        assert!(c.config.model_permissions.coding_model.can_execute);
467        assert!(c.config.model_permissions.coding_model.sandbox_only);
468    }
469}