Skip to main content

hivemind/core/
enforcement.rs

1//! Scope enforcement - post-execution violation detection.
2//!
3//! Phase 1 enforcement is detection-based, not prevention-based.
4//! Violations are detected after execution and made fatal.
5
6use super::diff::{ChangeType, Diff, FileChange};
7use super::scope::{FilePermission, Scope};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use uuid::Uuid;
11
12/// A scope violation detected post-execution.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ScopeViolation {
15    /// Type of violation.
16    pub violation_type: ViolationType,
17    /// Path that was violated (if applicable).
18    pub path: Option<String>,
19    /// Description of the violation.
20    pub description: String,
21    /// Whether this violation is fatal.
22    pub fatal: bool,
23}
24
25impl ScopeViolation {
26    /// Creates a filesystem violation.
27    pub fn filesystem(path: impl Into<String>, description: impl Into<String>) -> Self {
28        Self {
29            violation_type: ViolationType::Filesystem,
30            path: Some(path.into()),
31            description: description.into(),
32            fatal: true,
33        }
34    }
35
36    /// Creates a git violation.
37    pub fn git(description: impl Into<String>) -> Self {
38        Self {
39            violation_type: ViolationType::Git,
40            path: None,
41            description: description.into(),
42            fatal: true,
43        }
44    }
45
46    /// Creates an execution violation.
47    pub fn execution(description: impl Into<String>) -> Self {
48        Self {
49            violation_type: ViolationType::Execution,
50            path: None,
51            description: description.into(),
52            fatal: true,
53        }
54    }
55}
56
57/// Type of scope violation.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ViolationType {
61    /// Filesystem access violation.
62    Filesystem,
63    /// Git operation violation.
64    Git,
65    /// Command execution violation.
66    Execution,
67}
68
69/// Result of scope verification.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct VerificationResult {
72    /// Unique verification ID.
73    pub id: Uuid,
74    /// Task this verification is for.
75    pub task_id: Uuid,
76    /// Attempt this verification is for.
77    pub attempt_id: Uuid,
78    /// Whether verification passed.
79    pub passed: bool,
80    /// Violations detected.
81    pub violations: Vec<ScopeViolation>,
82    /// Verification timestamp.
83    pub verified_at: chrono::DateTime<chrono::Utc>,
84}
85
86impl VerificationResult {
87    /// Creates a passing result.
88    pub fn pass(task_id: Uuid, attempt_id: Uuid) -> Self {
89        Self {
90            id: Uuid::new_v4(),
91            task_id,
92            attempt_id,
93            passed: true,
94            violations: Vec::new(),
95            verified_at: chrono::Utc::now(),
96        }
97    }
98
99    /// Creates a failing result with violations.
100    pub fn fail(task_id: Uuid, attempt_id: Uuid, violations: Vec<ScopeViolation>) -> Self {
101        Self {
102            id: Uuid::new_v4(),
103            task_id,
104            attempt_id,
105            passed: false,
106            violations,
107            verified_at: chrono::Utc::now(),
108        }
109    }
110
111    /// Returns true if any violations are fatal.
112    pub fn has_fatal_violations(&self) -> bool {
113        self.violations.iter().any(|v| v.fatal)
114    }
115}
116
117/// Scope enforcer for post-execution verification.
118pub struct ScopeEnforcer {
119    scope: Scope,
120}
121
122impl ScopeEnforcer {
123    /// Creates a new scope enforcer.
124    pub fn new(scope: Scope) -> Self {
125        Self { scope }
126    }
127
128    /// Verifies that a diff does not violate the scope.
129    pub fn verify_diff(&self, diff: &Diff, task_id: Uuid, attempt_id: Uuid) -> VerificationResult {
130        let mut violations = Vec::new();
131
132        for change in &diff.changes {
133            if let Some(violation) = self.check_file_change(change) {
134                violations.push(violation);
135            }
136        }
137
138        if violations.is_empty() {
139            VerificationResult::pass(task_id, attempt_id)
140        } else {
141            VerificationResult::fail(task_id, attempt_id, violations)
142        }
143    }
144
145    /// Checks a single file change against the scope.
146    fn check_file_change(&self, change: &FileChange) -> Option<ScopeViolation> {
147        let path_str = change.path.to_string_lossy();
148
149        match change.change_type {
150            ChangeType::Created | ChangeType::Modified => {
151                // Write operations require write permission
152                if !self.scope.filesystem.can_write(&path_str) {
153                    return Some(ScopeViolation::filesystem(
154                        path_str.to_string(),
155                        format!(
156                            "Write to '{}' not allowed by scope (change type: {:?})",
157                            path_str, change.change_type
158                        ),
159                    ));
160                }
161            }
162            ChangeType::Deleted => {
163                // Deletion requires write permission
164                if !self.scope.filesystem.can_write(&path_str) {
165                    return Some(ScopeViolation::filesystem(
166                        path_str.to_string(),
167                        format!("Delete of '{path_str}' not allowed by scope"),
168                    ));
169                }
170            }
171        }
172
173        // Check for denied paths
174        if self.scope.filesystem.permission_for(&path_str) == Some(FilePermission::Deny) {
175            return Some(ScopeViolation::filesystem(
176                path_str.to_string(),
177                format!("Access to '{path_str}' is explicitly denied"),
178            ));
179        }
180
181        None
182    }
183
184    /// Verifies git operations against the scope.
185    pub fn verify_git_operations(
186        &self,
187        commits_created: bool,
188        branches_created: bool,
189        task_id: Uuid,
190        attempt_id: Uuid,
191    ) -> VerificationResult {
192        let mut violations = Vec::new();
193
194        if commits_created && !self.scope.git.can_commit() {
195            violations.push(ScopeViolation::git(
196                "Commits were created but git commit permission not granted",
197            ));
198        }
199
200        if branches_created && !self.scope.git.can_branch() {
201            violations.push(ScopeViolation::git(
202                "Branches were created but git branch permission not granted",
203            ));
204        }
205
206        if violations.is_empty() {
207            VerificationResult::pass(task_id, attempt_id)
208        } else {
209            VerificationResult::fail(task_id, attempt_id, violations)
210        }
211    }
212
213    /// Performs full verification including diff and git operations.
214    pub fn verify_all(
215        &self,
216        diff: &Diff,
217        commits_created: bool,
218        branches_created: bool,
219        task_id: Uuid,
220        attempt_id: Uuid,
221    ) -> VerificationResult {
222        let mut all_violations = Vec::new();
223
224        // Check diff
225        let diff_result = self.verify_diff(diff, task_id, attempt_id);
226        all_violations.extend(diff_result.violations);
227
228        // Check git
229        let git_result =
230            self.verify_git_operations(commits_created, branches_created, task_id, attempt_id);
231        all_violations.extend(git_result.violations);
232
233        if all_violations.is_empty() {
234            VerificationResult::pass(task_id, attempt_id)
235        } else {
236            VerificationResult::fail(task_id, attempt_id, all_violations)
237        }
238    }
239
240    /// Returns the scope being enforced.
241    pub fn scope(&self) -> &Scope {
242        &self.scope
243    }
244}
245
246/// Checks if a path matches any pattern in a list.
247pub fn path_matches_any(path: &Path, patterns: &[String]) -> bool {
248    let path_str = path.to_string_lossy();
249    for pattern in patterns {
250        if pattern.contains('*') {
251            // Simple glob matching
252            if pattern == "*" {
253                return true;
254            }
255            if let Some(prefix) = pattern.strip_suffix("/*") {
256                if path_str.starts_with(prefix) {
257                    return true;
258                }
259            }
260            if let Some(suffix) = pattern.strip_prefix("*/") {
261                if path_str.ends_with(suffix) {
262                    return true;
263                }
264            }
265        } else if path_str.starts_with(pattern) {
266            return true;
267        }
268    }
269    false
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::core::diff::{ChangeType, Diff, FileChange};
276    use crate::core::scope::{FilesystemScope, GitScope, PathRule, Scope};
277    use std::path::PathBuf;
278
279    fn test_scope() -> Scope {
280        Scope::new()
281            .with_filesystem(
282                FilesystemScope::new()
283                    .with_rule(PathRule::write("src/"))
284                    .with_rule(PathRule::read("docs/"))
285                    .with_rule(PathRule::deny("secrets/")),
286            )
287            .with_git(GitScope::with_commit())
288    }
289
290    fn test_diff_with_changes(changes: Vec<FileChange>) -> Diff {
291        Diff {
292            id: Uuid::new_v4(),
293            task_id: None,
294            attempt_id: None,
295            baseline_id: Uuid::new_v4(),
296            changes,
297            computed_at: chrono::Utc::now(),
298        }
299    }
300
301    #[test]
302    fn allowed_write_passes() {
303        let enforcer = ScopeEnforcer::new(test_scope());
304        let task_id = Uuid::new_v4();
305        let attempt_id = Uuid::new_v4();
306
307        let diff = test_diff_with_changes(vec![FileChange {
308            path: PathBuf::from("src/main.rs"),
309            change_type: ChangeType::Modified,
310            old_hash: Some("old".to_string()),
311            new_hash: Some("new".to_string()),
312        }]);
313
314        let result = enforcer.verify_diff(&diff, task_id, attempt_id);
315        assert!(result.passed);
316        assert!(result.violations.is_empty());
317    }
318
319    #[test]
320    fn disallowed_write_fails() {
321        let enforcer = ScopeEnforcer::new(test_scope());
322        let task_id = Uuid::new_v4();
323        let attempt_id = Uuid::new_v4();
324
325        let diff = test_diff_with_changes(vec![FileChange {
326            path: PathBuf::from("docs/README.md"),
327            change_type: ChangeType::Modified,
328            old_hash: Some("old".to_string()),
329            new_hash: Some("new".to_string()),
330        }]);
331
332        let result = enforcer.verify_diff(&diff, task_id, attempt_id);
333        assert!(!result.passed);
334        assert_eq!(result.violations.len(), 1);
335        assert_eq!(
336            result.violations[0].violation_type,
337            ViolationType::Filesystem
338        );
339    }
340
341    #[test]
342    fn denied_path_fails() {
343        let enforcer = ScopeEnforcer::new(test_scope());
344        let task_id = Uuid::new_v4();
345        let attempt_id = Uuid::new_v4();
346
347        let diff = test_diff_with_changes(vec![FileChange {
348            path: PathBuf::from("secrets/api_key.txt"),
349            change_type: ChangeType::Created,
350            old_hash: None,
351            new_hash: Some("new".to_string()),
352        }]);
353
354        let result = enforcer.verify_diff(&diff, task_id, attempt_id);
355        assert!(!result.passed);
356    }
357
358    #[test]
359    fn git_commit_allowed() {
360        let enforcer = ScopeEnforcer::new(test_scope());
361        let task_id = Uuid::new_v4();
362        let attempt_id = Uuid::new_v4();
363
364        let result = enforcer.verify_git_operations(true, false, task_id, attempt_id);
365        assert!(result.passed);
366    }
367
368    #[test]
369    fn git_commit_disallowed() {
370        let scope = Scope::new().with_git(GitScope::new()); // No commit permission
371        let enforcer = ScopeEnforcer::new(scope);
372        let task_id = Uuid::new_v4();
373        let attempt_id = Uuid::new_v4();
374
375        let result = enforcer.verify_git_operations(true, false, task_id, attempt_id);
376        assert!(!result.passed);
377        assert_eq!(result.violations[0].violation_type, ViolationType::Git);
378    }
379
380    #[test]
381    fn git_branch_disallowed() {
382        let scope = Scope::new().with_git(GitScope::with_commit()); // Commit but not branch
383        let enforcer = ScopeEnforcer::new(scope);
384        let task_id = Uuid::new_v4();
385        let attempt_id = Uuid::new_v4();
386
387        let result = enforcer.verify_git_operations(false, true, task_id, attempt_id);
388        assert!(!result.passed);
389    }
390
391    #[test]
392    fn full_verification() {
393        let enforcer = ScopeEnforcer::new(test_scope());
394        let task_id = Uuid::new_v4();
395        let attempt_id = Uuid::new_v4();
396
397        let diff = test_diff_with_changes(vec![FileChange {
398            path: PathBuf::from("src/lib.rs"),
399            change_type: ChangeType::Created,
400            old_hash: None,
401            new_hash: Some("new".to_string()),
402        }]);
403
404        let result = enforcer.verify_all(&diff, true, false, task_id, attempt_id);
405        assert!(result.passed);
406    }
407
408    #[test]
409    fn full_verification_with_violations() {
410        let enforcer = ScopeEnforcer::new(test_scope());
411        let task_id = Uuid::new_v4();
412        let attempt_id = Uuid::new_v4();
413
414        let diff = test_diff_with_changes(vec![
415            FileChange {
416                path: PathBuf::from("src/lib.rs"),
417                change_type: ChangeType::Created,
418                old_hash: None,
419                new_hash: Some("new".to_string()),
420            },
421            FileChange {
422                path: PathBuf::from("config/settings.json"),
423                change_type: ChangeType::Modified,
424                old_hash: Some("old".to_string()),
425                new_hash: Some("new".to_string()),
426            },
427        ]);
428
429        let result = enforcer.verify_all(&diff, true, true, task_id, attempt_id);
430        assert!(!result.passed);
431        // Should have violations for: config/ write and branch creation
432        assert!(result.violations.len() >= 2);
433    }
434
435    #[test]
436    fn violation_serialization() {
437        let violation = ScopeViolation::filesystem("test.txt", "Write not allowed");
438        let json = serde_json::to_string(&violation).unwrap();
439        let restored: ScopeViolation = serde_json::from_str(&json).unwrap();
440
441        assert_eq!(violation, restored);
442    }
443
444    #[test]
445    fn verification_result_serialization() {
446        let result = VerificationResult::pass(Uuid::new_v4(), Uuid::new_v4());
447        let json = serde_json::to_string(&result).unwrap();
448        assert!(json.contains("\"passed\":true"));
449    }
450
451    #[test]
452    fn path_matching() {
453        let patterns = vec!["src/".to_string(), "tests/*".to_string()];
454
455        assert!(path_matches_any(Path::new("src/main.rs"), &patterns));
456        assert!(path_matches_any(Path::new("tests/test1.rs"), &patterns));
457        assert!(!path_matches_any(Path::new("docs/README.md"), &patterns));
458    }
459}