1pub mod signing;
8
9use crate::types::{Action, ActionDecision};
10use serde::{Deserialize, Serialize};
11use std::path::Path;
12use thiserror::Error;
13
14#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Identity {
42 pub version: String,
43 #[serde(default)]
45 pub checksum: String,
46 #[serde(default)]
48 pub signed_by: String,
49 #[serde(default)]
51 pub signature: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Boundaries {
57 pub forbidden: Vec<String>,
59 pub requires_approval: Vec<String>,
61 pub auto_allowed: Vec<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ResourceLimits {
68 pub max_api_cost_per_run: f64,
70 pub max_api_cost_per_day: f64,
72 pub max_execution_time: u64,
74 pub max_concurrent_workflows: u32,
76 pub max_file_write_size: String,
78 pub allowed_directories: Vec<String>,
80 pub blocked_directories: Vec<String>,
82}
83
84#[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#[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#[derive(Debug, Clone)]
103pub struct Constitution {
104 pub config: ConstitutionConfig,
106 raw_content: String,
108}
109
110impl Constitution {
111 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 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 pub fn raw_content(&self) -> &str {
132 &self.raw_content
133 }
134
135 pub fn check_action(&self, action: &Action) -> ActionDecision {
143 let command = action.command.to_lowercase();
144 let description = action.description.to_lowercase();
145
146 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 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 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 ActionDecision::NeedsApproval {
176 prompt: format!(
177 "Unknown action, requesting approval: {}",
178 action.description
179 ),
180 }
181 }
182
183 pub fn check_cost_per_run(&self, cost: f64) -> bool {
185 cost <= self.config.resource_limits.max_api_cost_per_run
186 }
187
188 pub fn check_cost_per_day(&self, cost: f64) -> bool {
190 cost <= self.config.resource_limits.max_api_cost_per_day
191 }
192
193 pub fn is_directory_allowed(&self, path: &str) -> bool {
195 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 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
215fn 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 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 find_word_start(&text_lower, &pattern_lower, true).is_some()
246 }
247}
248
249fn 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 start = abs_pos + 1;
263 if start >= text.len() {
264 break;
265 }
266 }
267 None
268}
269
270fn 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}