Skip to main content

hivemind/core/
scope.rs

1//! Scope model for defining capability contracts.
2//!
3//! Scope is an explicit, enforceable capability contract that defines
4//! what operations are permitted during task execution.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8
9/// Filesystem permission level.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum FilePermission {
13    /// Read-only access.
14    Read,
15    /// Read and write access.
16    Write,
17    /// Explicitly denied access.
18    Deny,
19}
20
21/// A filesystem path rule within a scope.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct PathRule {
24    /// The path pattern (may include globs).
25    pub pattern: String,
26    /// The permission level for this path.
27    pub permission: FilePermission,
28}
29
30impl PathRule {
31    /// Creates a new path rule.
32    #[must_use]
33    pub fn new(pattern: impl Into<String>, permission: FilePermission) -> Self {
34        Self {
35            pattern: pattern.into(),
36            permission,
37        }
38    }
39
40    /// Creates a read-only path rule.
41    #[must_use]
42    pub fn read(pattern: impl Into<String>) -> Self {
43        Self::new(pattern, FilePermission::Read)
44    }
45
46    /// Creates a read-write path rule.
47    #[must_use]
48    pub fn write(pattern: impl Into<String>) -> Self {
49        Self::new(pattern, FilePermission::Write)
50    }
51
52    /// Creates a deny path rule.
53    #[must_use]
54    pub fn deny(pattern: impl Into<String>) -> Self {
55        Self::new(pattern, FilePermission::Deny)
56    }
57
58    /// Checks if this rule matches a given path.
59    #[must_use]
60    pub fn matches(&self, path: &str) -> bool {
61        if self.pattern.contains('*') {
62            glob_match(&self.pattern, path)
63        } else {
64            path.starts_with(&self.pattern)
65        }
66    }
67}
68
69/// Simple glob matching (supports * and **).
70fn glob_match(pattern: &str, path: &str) -> bool {
71    if pattern == "**" || pattern == "*" {
72        return true;
73    }
74    if pattern.contains("**") {
75        let parts: Vec<&str> = pattern.split("**").collect();
76        if parts.len() == 2 {
77            let prefix = parts[0].trim_end_matches('/');
78            let suffix = parts[1].trim_start_matches('/');
79            // Check prefix
80            if !prefix.is_empty() && !path.starts_with(prefix) {
81                return false;
82            }
83            // Check suffix (e.g., *.rs)
84            if !suffix.is_empty() {
85                if let Some(ext) = suffix.strip_prefix("*.") {
86                    return path.ends_with(&format!(".{ext}"));
87                }
88                return path.ends_with(suffix);
89            }
90            return true;
91        }
92    }
93    if let Some(prefix) = pattern.strip_suffix("/*") {
94        return path.starts_with(prefix);
95    }
96    if let Some(suffix) = pattern.strip_prefix("*/") {
97        return path.ends_with(suffix);
98    }
99    pattern == path
100}
101
102/// Filesystem scope defining allowed paths.
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct FilesystemScope {
105    /// Path rules (evaluated in order, first match wins).
106    pub rules: Vec<PathRule>,
107}
108
109impl FilesystemScope {
110    /// Creates an empty filesystem scope.
111    #[must_use]
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Adds a path rule.
117    #[must_use]
118    pub fn with_rule(mut self, rule: PathRule) -> Self {
119        self.rules.push(rule);
120        self
121    }
122
123    /// Gets the permission for a path.
124    #[must_use]
125    pub fn permission_for(&self, path: &str) -> Option<FilePermission> {
126        let mut has_read = false;
127        let mut has_write = false;
128
129        for rule in &self.rules {
130            if !rule.matches(path) {
131                continue;
132            }
133
134            match rule.permission {
135                FilePermission::Deny => return Some(FilePermission::Deny),
136                FilePermission::Write => has_write = true,
137                FilePermission::Read => has_read = true,
138            }
139        }
140
141        if has_write {
142            Some(FilePermission::Write)
143        } else if has_read {
144            Some(FilePermission::Read)
145        } else {
146            None
147        }
148    }
149
150    /// Checks if a path is allowed for writing.
151    #[must_use]
152    pub fn can_write(&self, path: &str) -> bool {
153        matches!(self.permission_for(path), Some(FilePermission::Write))
154    }
155
156    /// Checks if a path is allowed for reading.
157    #[must_use]
158    pub fn can_read(&self, path: &str) -> bool {
159        matches!(
160            self.permission_for(path),
161            Some(FilePermission::Read | FilePermission::Write)
162        )
163    }
164}
165
166/// Repository access mode.
167#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum RepoAccessMode {
170    /// Read-only access.
171    ReadOnly,
172    /// Read and write access.
173    #[default]
174    ReadWrite,
175}
176
177/// Repository scope defining repository access.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct RepositoryScope {
180    /// Repository name or path.
181    pub repo: String,
182    /// Access mode.
183    pub mode: RepoAccessMode,
184}
185
186impl RepositoryScope {
187    /// Creates a new repository scope.
188    #[must_use]
189    pub fn new(repo: impl Into<String>, mode: RepoAccessMode) -> Self {
190        Self {
191            repo: repo.into(),
192            mode,
193        }
194    }
195
196    /// Creates a read-only repository scope.
197    #[must_use]
198    pub fn read_only(repo: impl Into<String>) -> Self {
199        Self::new(repo, RepoAccessMode::ReadOnly)
200    }
201
202    /// Creates a read-write repository scope.
203    #[must_use]
204    pub fn read_write(repo: impl Into<String>) -> Self {
205        Self::new(repo, RepoAccessMode::ReadWrite)
206    }
207}
208
209/// Git operation permissions.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum GitPermission {
213    /// May create commits.
214    Commit,
215    /// May create branches.
216    Branch,
217    /// May push to remote.
218    Push,
219}
220
221/// Git scope defining git operation permissions.
222#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
223pub struct GitScope {
224    /// Allowed git operations.
225    pub permissions: HashSet<GitPermission>,
226}
227
228impl GitScope {
229    /// Creates an empty git scope (read-only).
230    #[must_use]
231    pub fn new() -> Self {
232        Self::default()
233    }
234
235    /// Creates a git scope that allows commits.
236    #[must_use]
237    pub fn with_commit() -> Self {
238        let mut scope = Self::new();
239        scope.permissions.insert(GitPermission::Commit);
240        scope
241    }
242
243    /// Creates a git scope that allows commits and branches.
244    #[must_use]
245    pub fn with_branch() -> Self {
246        let mut scope = Self::with_commit();
247        scope.permissions.insert(GitPermission::Branch);
248        scope
249    }
250
251    /// Checks if commits are allowed.
252    #[must_use]
253    pub fn can_commit(&self) -> bool {
254        self.permissions.contains(&GitPermission::Commit)
255    }
256
257    /// Checks if branching is allowed.
258    #[must_use]
259    pub fn can_branch(&self) -> bool {
260        self.permissions.contains(&GitPermission::Branch)
261    }
262}
263
264/// Execution scope defining allowed commands.
265#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ExecutionScope {
267    /// Allowed command patterns.
268    pub allowed: Vec<String>,
269    /// Denied command patterns.
270    pub denied: Vec<String>,
271}
272
273impl ExecutionScope {
274    /// Creates an empty execution scope.
275    #[must_use]
276    pub fn new() -> Self {
277        Self::default()
278    }
279
280    /// Allows a command pattern.
281    #[must_use]
282    pub fn allow(mut self, pattern: impl Into<String>) -> Self {
283        self.allowed.push(pattern.into());
284        self
285    }
286
287    /// Denies a command pattern.
288    #[must_use]
289    pub fn deny(mut self, pattern: impl Into<String>) -> Self {
290        self.denied.push(pattern.into());
291        self
292    }
293
294    /// Checks if a command is allowed.
295    #[must_use]
296    pub fn is_allowed(&self, command: &str) -> bool {
297        // Deny rules take precedence
298        for pattern in &self.denied {
299            if command.starts_with(pattern) || pattern == "*" {
300                return false;
301            }
302        }
303        // Check allow rules
304        if self.allowed.is_empty() {
305            return true; // No restrictions
306        }
307        for pattern in &self.allowed {
308            if command.starts_with(pattern) || pattern == "*" {
309                return true;
310            }
311        }
312        false
313    }
314}
315
316/// Complete scope contract for a task.
317#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
318pub struct Scope {
319    /// Filesystem access rules.
320    pub filesystem: FilesystemScope,
321    /// Repository access rules.
322    pub repositories: Vec<RepositoryScope>,
323    /// Git operation permissions.
324    pub git: GitScope,
325    /// Command execution permissions.
326    pub execution: ExecutionScope,
327}
328
329impl Scope {
330    /// Creates an empty scope.
331    #[must_use]
332    pub fn new() -> Self {
333        Self::default()
334    }
335
336    /// Sets the filesystem scope.
337    #[must_use]
338    pub fn with_filesystem(mut self, fs: FilesystemScope) -> Self {
339        self.filesystem = fs;
340        self
341    }
342
343    /// Adds a repository scope.
344    #[must_use]
345    pub fn with_repository(mut self, repo: RepositoryScope) -> Self {
346        self.repositories.push(repo);
347        self
348    }
349
350    /// Sets the git scope.
351    #[must_use]
352    pub fn with_git(mut self, git: GitScope) -> Self {
353        self.git = git;
354        self
355    }
356
357    /// Sets the execution scope.
358    #[must_use]
359    pub fn with_execution(mut self, exec: ExecutionScope) -> Self {
360        self.execution = exec;
361        self
362    }
363}
364
365/// Result of scope compatibility check.
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum ScopeCompatibility {
368    /// Scopes are compatible - safe to run in parallel.
369    Compatible,
370    /// Soft conflict - potentially unsafe, may need isolation.
371    SoftConflict,
372    /// Hard conflict - unsafe, must isolate or serialize.
373    HardConflict,
374}
375
376impl ScopeCompatibility {
377    /// Combines two compatibility results (worst case wins).
378    #[must_use]
379    pub fn combine(self, other: Self) -> Self {
380        match (self, other) {
381            (Self::HardConflict, _) | (_, Self::HardConflict) => Self::HardConflict,
382            (Self::SoftConflict, _) | (_, Self::SoftConflict) => Self::SoftConflict,
383            _ => Self::Compatible,
384        }
385    }
386}
387
388/// Checks compatibility between two scopes.
389#[must_use]
390pub fn check_compatibility(a: &Scope, b: &Scope) -> ScopeCompatibility {
391    let fs_compat = check_filesystem_compatibility(&a.filesystem, &b.filesystem);
392    let repo_compat = check_repository_compatibility(&a.repositories, &b.repositories);
393    let git_compat = check_git_compatibility(&a.git, &b.git);
394    let exec_compat = check_execution_compatibility(&a.execution, &b.execution);
395
396    fs_compat
397        .combine(repo_compat)
398        .combine(git_compat)
399        .combine(exec_compat)
400}
401
402fn check_filesystem_compatibility(a: &FilesystemScope, b: &FilesystemScope) -> ScopeCompatibility {
403    // Check for overlapping write paths
404    for rule_a in &a.rules {
405        for rule_b in &b.rules {
406            // Check if patterns might overlap
407            if patterns_might_overlap(&rule_a.pattern, &rule_b.pattern) {
408                // Deny rules always produce a hard conflict when overlapping.
409                if rule_a.permission == FilePermission::Deny
410                    || rule_b.permission == FilePermission::Deny
411                {
412                    return ScopeCompatibility::HardConflict;
413                }
414                match (rule_a.permission, rule_b.permission) {
415                    (FilePermission::Write, FilePermission::Write) => {
416                        return ScopeCompatibility::HardConflict;
417                    }
418                    (FilePermission::Read, FilePermission::Write)
419                    | (FilePermission::Write, FilePermission::Read) => {
420                        return ScopeCompatibility::SoftConflict;
421                    }
422                    _ => {}
423                }
424            }
425        }
426    }
427    ScopeCompatibility::Compatible
428}
429
430fn patterns_might_overlap(a: &str, b: &str) -> bool {
431    // Conservative check - if either contains glob, assume possible overlap
432    if a.contains('*') || b.contains('*') {
433        return true;
434    }
435    // Check if one is prefix of the other
436    a.starts_with(b) || b.starts_with(a)
437}
438
439fn check_repository_compatibility(
440    a: &[RepositoryScope],
441    b: &[RepositoryScope],
442) -> ScopeCompatibility {
443    for repo_a in a {
444        for repo_b in b {
445            if repo_a.repo == repo_b.repo {
446                match (repo_a.mode, repo_b.mode) {
447                    (RepoAccessMode::ReadWrite, RepoAccessMode::ReadWrite) => {
448                        return ScopeCompatibility::HardConflict;
449                    }
450                    (RepoAccessMode::ReadOnly, RepoAccessMode::ReadWrite)
451                    | (RepoAccessMode::ReadWrite, RepoAccessMode::ReadOnly) => {
452                        return ScopeCompatibility::SoftConflict;
453                    }
454                    _ => {}
455                }
456            }
457        }
458    }
459    ScopeCompatibility::Compatible
460}
461
462fn check_git_compatibility(a: &GitScope, b: &GitScope) -> ScopeCompatibility {
463    // If both can commit, that's a hard conflict (could create merge conflicts)
464    if a.can_commit() && b.can_commit() {
465        return ScopeCompatibility::HardConflict;
466    }
467    ScopeCompatibility::Compatible
468}
469
470fn check_execution_compatibility(a: &ExecutionScope, b: &ExecutionScope) -> ScopeCompatibility {
471    // Phase 1: conservative heuristics.
472    // If either side allows everything, treat as soft conflict.
473    // If both allow everything, treat as hard conflict.
474    let a_allows_all = a.allowed.iter().any(|p| p == "*") && a.denied.is_empty();
475    let b_allows_all = b.allowed.iter().any(|p| p == "*") && b.denied.is_empty();
476
477    if a_allows_all && b_allows_all {
478        return ScopeCompatibility::HardConflict;
479    }
480    if a_allows_all || b_allows_all {
481        return ScopeCompatibility::SoftConflict;
482    }
483
484    ScopeCompatibility::Compatible
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn path_rule_matching() {
493        let rule = PathRule::write("src/");
494        assert!(rule.matches("src/main.rs"));
495        assert!(rule.matches("src/lib.rs"));
496        assert!(!rule.matches("tests/test.rs"));
497    }
498
499    #[test]
500    fn glob_matching() {
501        let rule = PathRule::write("src/**/*.rs");
502        assert!(rule.matches("src/main.rs"));
503        assert!(rule.matches("src/core/mod.rs"));
504    }
505
506    #[test]
507    fn filesystem_scope_permissions() {
508        let scope = FilesystemScope::new()
509            .with_rule(PathRule::write("src/"))
510            .with_rule(PathRule::read("docs/"))
511            .with_rule(PathRule::deny("src/secret/"));
512
513        assert!(scope.can_write("src/main.rs"));
514        assert!(scope.can_read("docs/README.md"));
515        assert!(!scope.can_write("docs/README.md"));
516        assert!(!scope.can_read("src/secret/key.txt"));
517    }
518
519    #[test]
520    fn repository_scope_creation() {
521        let ro = RepositoryScope::read_only("repo1");
522        let rw = RepositoryScope::read_write("repo2");
523
524        assert_eq!(ro.mode, RepoAccessMode::ReadOnly);
525        assert_eq!(rw.mode, RepoAccessMode::ReadWrite);
526    }
527
528    #[test]
529    fn git_scope_permissions() {
530        let scope = GitScope::with_branch();
531        assert!(scope.can_commit());
532        assert!(scope.can_branch());
533
534        let empty = GitScope::new();
535        assert!(!empty.can_commit());
536    }
537
538    #[test]
539    fn execution_scope_allowed() {
540        let scope = ExecutionScope::new().allow("cargo").allow("npm").deny("rm");
541
542        assert!(scope.is_allowed("cargo build"));
543        assert!(scope.is_allowed("npm install"));
544        assert!(!scope.is_allowed("rm -rf /"));
545    }
546
547    #[test]
548    fn compatible_scopes() {
549        let a = Scope::new()
550            .with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/a/")));
551        let b = Scope::new()
552            .with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/b/")));
553
554        assert_eq!(check_compatibility(&a, &b), ScopeCompatibility::Compatible);
555    }
556
557    #[test]
558    fn hard_conflict_overlapping_writes() {
559        let a =
560            Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/")));
561        let b =
562            Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/")));
563
564        assert_eq!(
565            check_compatibility(&a, &b),
566            ScopeCompatibility::HardConflict
567        );
568    }
569
570    #[test]
571    fn deny_rules_produce_hard_conflict_on_overlap() {
572        let a =
573            Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::deny("src/")));
574        let b =
575            Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::read("src/")));
576
577        assert_eq!(
578            check_compatibility(&a, &b),
579            ScopeCompatibility::HardConflict
580        );
581    }
582
583    #[test]
584    fn soft_conflict_read_write() {
585        let a =
586            Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::read("src/")));
587        let b =
588            Scope::new().with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/")));
589
590        assert_eq!(
591            check_compatibility(&a, &b),
592            ScopeCompatibility::SoftConflict
593        );
594    }
595
596    #[test]
597    fn repository_conflict() {
598        let a = Scope::new().with_repository(RepositoryScope::read_write("repo"));
599        let b = Scope::new().with_repository(RepositoryScope::read_write("repo"));
600
601        assert_eq!(
602            check_compatibility(&a, &b),
603            ScopeCompatibility::HardConflict
604        );
605    }
606
607    #[test]
608    fn git_commit_conflict() {
609        let a = Scope::new().with_git(GitScope::with_commit());
610        let b = Scope::new().with_git(GitScope::with_commit());
611
612        assert_eq!(
613            check_compatibility(&a, &b),
614            ScopeCompatibility::HardConflict
615        );
616    }
617
618    #[test]
619    fn scope_serialization() {
620        let scope = Scope::new()
621            .with_filesystem(FilesystemScope::new().with_rule(PathRule::write("src/")))
622            .with_repository(RepositoryScope::read_write("my-repo"))
623            .with_git(GitScope::with_commit());
624
625        let json = serde_json::to_string(&scope).unwrap();
626        let restored: Scope = serde_json::from_str(&json).unwrap();
627
628        assert_eq!(scope, restored);
629    }
630}