Skip to main content

portcullis/
capability.rs

1//! Capability lattice for tool permissions.
2
3use std::collections::BTreeMap;
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8/// Tool permission levels in lattice ordering.
9///
10/// The ordering is: `Never < LowRisk < Always`
11///
12/// - `Never`: Never allow
13/// - `LowRisk`: Auto-approve for low-risk operations
14/// - `Always`: Always auto-approve
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
16#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
17#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
18pub enum CapabilityLevel {
19    /// Never allow
20    #[default]
21    Never = 0,
22    /// Auto-approve for low-risk operations
23    LowRisk = 1,
24    /// Always auto-approve
25    Always = 2,
26}
27
28impl std::fmt::Display for CapabilityLevel {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            CapabilityLevel::Never => write!(f, "never"),
32            CapabilityLevel::LowRisk => write!(f, "low_risk"),
33            CapabilityLevel::Always => write!(f, "always"),
34        }
35    }
36}
37
38/// Operations that can be gated by approval.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
41#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
42pub enum Operation {
43    /// Read files from disk
44    ReadFiles,
45    /// Write files to disk
46    WriteFiles,
47    /// Edit files in place
48    EditFiles,
49    /// Run shell commands
50    RunBash,
51    /// Glob search
52    GlobSearch,
53    /// Grep search
54    GrepSearch,
55    /// Web search
56    WebSearch,
57    /// Fetch URLs
58    WebFetch,
59    /// Git commit
60    GitCommit,
61    /// Git push
62    GitPush,
63    /// Create PR
64    CreatePr,
65    /// Manage sub-pods (create, list, monitor, cancel)
66    ManagePods,
67}
68
69/// Extension operation not covered by Verus proofs.
70///
71/// The 12 core operations above are frozen — they have 297 Verus verification
72/// conditions proving lattice laws, exposure monotonicity, and session safety.
73/// Extension operations participate in the same product lattice (meet = pointwise min,
74/// join = pointwise max) but are verified only by property tests, not SMT proofs.
75///
76/// Lattice laws hold by the universal property of products in **Lat**: if each
77/// factor is a lattice, the product is a lattice. Since `CapabilityLevel` is a
78/// 3-element chain (a lattice), `CapabilityLevel^E` for any finite set `E` is a lattice.
79#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
80#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
81pub struct ExtensionOperation(pub String);
82
83impl ExtensionOperation {
84    /// Create a new extension operation.
85    pub fn new(name: impl Into<String>) -> Self {
86        Self(name.into())
87    }
88}
89
90impl std::fmt::Display for ExtensionOperation {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "{}", self.0)
93    }
94}
95
96/// Approval obligations that gate autonomous capabilities.
97#[derive(Debug, Clone, PartialEq, Eq, Default)]
98#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99pub struct Obligations {
100    /// Operations that require explicit approval.
101    #[cfg_attr(feature = "serde", serde(default))]
102    pub approvals: std::collections::BTreeSet<Operation>,
103}
104
105impl Obligations {
106    /// Create obligations for a single operation.
107    pub fn for_operation(op: Operation) -> Self {
108        let mut approvals = std::collections::BTreeSet::new();
109        approvals.insert(op);
110        Self { approvals }
111    }
112
113    /// Check if an operation requires approval.
114    pub fn requires(&self, op: Operation) -> bool {
115        self.approvals.contains(&op)
116    }
117
118    /// Add an approval obligation.
119    pub fn insert(&mut self, op: Operation) {
120        self.approvals.insert(op);
121    }
122
123    /// Get the number of obligations.
124    pub fn len(&self) -> usize {
125        self.approvals.len()
126    }
127
128    /// Check if there are no obligations.
129    pub fn is_empty(&self) -> bool {
130        self.approvals.is_empty()
131    }
132
133    /// Union of obligations.
134    pub fn union(&self, other: &Self) -> Self {
135        let mut approvals = self.approvals.clone();
136        approvals.extend(other.approvals.iter().copied());
137        Self { approvals }
138    }
139
140    /// Intersection of obligations.
141    pub fn intersection(&self, other: &Self) -> Self {
142        let approvals = self
143            .approvals
144            .intersection(&other.approvals)
145            .copied()
146            .collect();
147        Self { approvals }
148    }
149
150    /// Check if obligations are less than or equal in the policy order.
151    ///
152    /// More obligations means a more constrained (smaller) policy.
153    pub fn leq(&self, other: &Self) -> bool {
154        self.approvals.is_superset(&other.approvals)
155    }
156}
157
158/// Capability lattice for tool permissions.
159///
160/// Each field represents a different tool category with its own permission level.
161#[derive(Debug, Clone, PartialEq, Eq)]
162#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
163pub struct CapabilityLattice {
164    /// Read files permission level
165    pub read_files: CapabilityLevel,
166    /// Write files permission level
167    pub write_files: CapabilityLevel,
168    /// Edit files permission level
169    pub edit_files: CapabilityLevel,
170    /// Run bash commands permission level
171    pub run_bash: CapabilityLevel,
172    /// Glob search permission level
173    pub glob_search: CapabilityLevel,
174    /// Grep search permission level
175    pub grep_search: CapabilityLevel,
176    /// Web search permission level
177    pub web_search: CapabilityLevel,
178    /// Web fetch permission level
179    pub web_fetch: CapabilityLevel,
180    /// Git commit permission level
181    pub git_commit: CapabilityLevel,
182    /// Git push permission level
183    pub git_push: CapabilityLevel,
184    /// Create PR permission level
185    pub create_pr: CapabilityLevel,
186    /// Manage sub-pods permission level
187    #[cfg_attr(feature = "serde", serde(default))]
188    pub manage_pods: CapabilityLevel,
189
190    /// Extension capability dimensions (not covered by Verus proofs).
191    ///
192    /// Meet = pointwise min, join = pointwise max, leq = pointwise ≤.
193    /// Unknown extensions default to `Never` (fail-closed).
194    ///
195    /// Excluded from Kani builds: BTreeMap's heap allocator is intractable
196    /// for bounded model checking. Extension lattice laws are covered by proptest.
197    #[cfg(not(kani))]
198    #[cfg_attr(
199        feature = "serde",
200        serde(default, skip_serializing_if = "BTreeMap::is_empty")
201    )]
202    pub extensions: BTreeMap<ExtensionOperation, CapabilityLevel>,
203}
204
205impl Default for CapabilityLattice {
206    fn default() -> Self {
207        Self {
208            read_files: CapabilityLevel::Always,
209            write_files: CapabilityLevel::LowRisk,
210            edit_files: CapabilityLevel::LowRisk,
211            run_bash: CapabilityLevel::Never,
212            glob_search: CapabilityLevel::Always,
213            grep_search: CapabilityLevel::Always,
214            web_search: CapabilityLevel::LowRisk,
215            web_fetch: CapabilityLevel::LowRisk,
216            git_commit: CapabilityLevel::LowRisk,
217            git_push: CapabilityLevel::Never,
218            create_pr: CapabilityLevel::LowRisk,
219            manage_pods: CapabilityLevel::Never,
220            #[cfg(not(kani))]
221            extensions: BTreeMap::new(),
222        }
223    }
224}
225
226/// Incompatibility constraint that enforces uninhabitable_state prevention.
227///
228/// Graded risk level for uninhabitable_state completeness.
229///
230/// This implements a graded topology (fuzzy subobject classifier) rather than
231/// a binary one, enabling proportional response to risk levels.
232///
233/// The ordering forms a bounded lattice: `Safe < Low < Medium < Uninhabitable`
234#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
235#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
236#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
237pub enum StateRisk {
238    /// No uninhabitable_state components present (0 of 3)
239    #[default]
240    #[cfg_attr(feature = "serde", serde(alias = "none"))]
241    Safe = 0,
242    /// One component present (1 of 3) - low risk
243    Low = 1,
244    /// Two components present (2 of 3) - medium risk, escalation likely
245    Medium = 2,
246    /// All three components present - uninhabitable_state, requires intervention
247    #[cfg_attr(feature = "serde", serde(alias = "complete"))]
248    Uninhabitable = 3,
249}
250
251impl StateRisk {
252    /// Returns true if this risk level requires approval obligations
253    pub fn requires_intervention(&self) -> bool {
254        *self == StateRisk::Uninhabitable
255    }
256
257    /// Meet operation: takes the minimum risk level
258    pub fn meet(&self, other: &Self) -> Self {
259        std::cmp::min(*self, *other)
260    }
261
262    /// Join operation: takes the maximum risk level
263    pub fn join(&self, other: &Self) -> Self {
264        std::cmp::max(*self, *other)
265    }
266}
267
268/// The "uninhabitable_state" is the combination of:
269/// 1. Private data access (read_files, glob_search, grep_search)
270/// 2. Untrusted content exposure (web_fetch, web_search)
271/// 3. Exfiltration vector (git_push, create_pr, run_bash)
272///
273/// When all three are present at autonomous levels (≥ LowRisk), this constraint
274/// adds approval obligations for the exfiltration vector.
275///
276/// Note: glob_search and grep_search are included as information disclosure vectors
277/// because they can reveal file structure and contents, which combined with
278/// untrusted content and exfiltration could enable prompt injection attacks.
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
280pub struct IncompatibilityConstraint {
281    /// Whether to enforce uninhabitable_state prevention
282    pub enforce_uninhabitable: bool,
283}
284
285impl IncompatibilityConstraint {
286    /// Create an enforcing constraint.
287    pub fn enforcing() -> Self {
288        Self {
289            enforce_uninhabitable: true,
290        }
291    }
292
293    /// Compute the graded uninhabitable_state risk level.
294    ///
295    /// Returns a `StateRisk` indicating how many of the three uninhabitable_state
296    /// components are present at autonomous levels (≥ LowRisk):
297    /// - `None`: 0 components
298    /// - `Low`: 1 component
299    /// - `Medium`: 2 components
300    /// - `Complete`: all 3 components (intervention required)
301    pub fn state_risk(&self, caps: &CapabilityLattice) -> StateRisk {
302        if !self.enforce_uninhabitable {
303            return StateRisk::Safe;
304        }
305
306        // Information disclosure: reading files OR searching file structure/contents
307        let has_private_access = caps.read_files >= CapabilityLevel::LowRisk
308            || caps.glob_search >= CapabilityLevel::LowRisk
309            || caps.grep_search >= CapabilityLevel::LowRisk;
310        let has_untrusted = caps.web_fetch >= CapabilityLevel::LowRisk
311            || caps.web_search >= CapabilityLevel::LowRisk;
312        let has_exfil = caps.git_push >= CapabilityLevel::LowRisk
313            || caps.create_pr >= CapabilityLevel::LowRisk
314            || caps.run_bash >= CapabilityLevel::LowRisk;
315
316        let count = has_private_access as u8 + has_untrusted as u8 + has_exfil as u8;
317        match count {
318            0 => StateRisk::Safe,
319            1 => StateRisk::Low,
320            2 => StateRisk::Medium,
321            _ => StateRisk::Uninhabitable,
322        }
323    }
324
325    /// Check if capabilities form a uninhabitable_state at autonomous levels.
326    ///
327    /// Returns true if:
328    /// 1. Private data access (read_files) >= LowRisk
329    /// 2. Untrusted content exposure (web_fetch OR web_search) >= LowRisk
330    /// 3. Exfiltration vector (git_push OR create_pr OR run_bash) >= LowRisk
331    pub fn is_uninhabitable(&self, caps: &CapabilityLattice) -> bool {
332        self.state_risk(caps) == StateRisk::Uninhabitable
333    }
334
335    /// Check if capabilities form a uninhabitable_state at autonomous levels.
336    /// (Legacy alias for backward compatibility)
337    #[deprecated(since = "0.2.0", note = "Use state_risk() for graded assessment")]
338    pub fn is_uninhabitable_legacy(&self, caps: &CapabilityLattice) -> bool {
339        if !self.enforce_uninhabitable {
340            return false;
341        }
342
343        // Information disclosure: reading files OR searching file structure/contents
344        let has_private_access = caps.read_files >= CapabilityLevel::LowRisk
345            || caps.glob_search >= CapabilityLevel::LowRisk
346            || caps.grep_search >= CapabilityLevel::LowRisk;
347        let has_untrusted = caps.web_fetch >= CapabilityLevel::LowRisk
348            || caps.web_search >= CapabilityLevel::LowRisk;
349        let has_exfil = caps.git_push >= CapabilityLevel::LowRisk
350            || caps.create_pr >= CapabilityLevel::LowRisk
351            || caps.run_bash >= CapabilityLevel::LowRisk;
352
353        has_private_access && has_untrusted && has_exfil
354    }
355
356    /// Compute approval obligations required to break a uninhabitable_state.
357    pub fn obligations_for(&self, caps: &CapabilityLattice) -> Obligations {
358        if !self.is_uninhabitable(caps) {
359            return Obligations::default();
360        }
361
362        let mut obligations = Obligations::default();
363        if caps.git_push >= CapabilityLevel::LowRisk {
364            obligations.insert(Operation::GitPush);
365        }
366        if caps.create_pr >= CapabilityLevel::LowRisk {
367            obligations.insert(Operation::CreatePr);
368        }
369        if caps.run_bash >= CapabilityLevel::LowRisk {
370            obligations.insert(Operation::RunBash);
371        }
372        obligations
373    }
374}
375
376impl CapabilityLattice {
377    /// Get the capability level for a given core operation.
378    pub fn level_for(&self, op: Operation) -> CapabilityLevel {
379        match op {
380            Operation::ReadFiles => self.read_files,
381            Operation::WriteFiles => self.write_files,
382            Operation::EditFiles => self.edit_files,
383            Operation::RunBash => self.run_bash,
384            Operation::GlobSearch => self.glob_search,
385            Operation::GrepSearch => self.grep_search,
386            Operation::WebSearch => self.web_search,
387            Operation::WebFetch => self.web_fetch,
388            Operation::GitCommit => self.git_commit,
389            Operation::GitPush => self.git_push,
390            Operation::CreatePr => self.create_pr,
391            Operation::ManagePods => self.manage_pods,
392        }
393    }
394
395    /// Get the capability level for an extension operation.
396    /// Returns `Never` (fail-closed) if the operation is not registered.
397    #[cfg(not(kani))]
398    pub fn extension_level(&self, op: &ExtensionOperation) -> CapabilityLevel {
399        self.extensions
400            .get(op)
401            .copied()
402            .unwrap_or(CapabilityLevel::Never)
403    }
404
405    /// Meet operation: minimum of each capability.
406    ///
407    /// For extension dimensions, missing keys default to `Never`.
408    /// Since `min(x, Never) = Never`, absent extensions are fail-closed.
409    pub fn meet(&self, other: &Self) -> Self {
410        #[cfg(not(kani))]
411        let ext = if self.extensions.is_empty() && other.extensions.is_empty() {
412            BTreeMap::new()
413        } else {
414            let mut ext = BTreeMap::new();
415            for key in self.extensions.keys().chain(other.extensions.keys()) {
416                let a = self.extension_level(key);
417                let b = other.extension_level(key);
418                let v = std::cmp::min(a, b);
419                if v != CapabilityLevel::Never {
420                    ext.insert(key.clone(), v);
421                }
422            }
423            ext
424        };
425
426        Self {
427            read_files: std::cmp::min(self.read_files, other.read_files),
428            write_files: std::cmp::min(self.write_files, other.write_files),
429            edit_files: std::cmp::min(self.edit_files, other.edit_files),
430            run_bash: std::cmp::min(self.run_bash, other.run_bash),
431            glob_search: std::cmp::min(self.glob_search, other.glob_search),
432            grep_search: std::cmp::min(self.grep_search, other.grep_search),
433            web_search: std::cmp::min(self.web_search, other.web_search),
434            web_fetch: std::cmp::min(self.web_fetch, other.web_fetch),
435            git_commit: std::cmp::min(self.git_commit, other.git_commit),
436            git_push: std::cmp::min(self.git_push, other.git_push),
437            create_pr: std::cmp::min(self.create_pr, other.create_pr),
438            manage_pods: std::cmp::min(self.manage_pods, other.manage_pods),
439            #[cfg(not(kani))]
440            extensions: ext,
441        }
442    }
443
444    /// Join operation: maximum of each capability (least upper bound).
445    ///
446    /// For extension dimensions, missing keys default to `Never`.
447    /// Since `max(x, Never) = x`, only keys present in at least one operand appear.
448    pub fn join(&self, other: &Self) -> Self {
449        #[cfg(not(kani))]
450        let ext = if self.extensions.is_empty() && other.extensions.is_empty() {
451            BTreeMap::new()
452        } else {
453            let mut ext = BTreeMap::new();
454            for key in self.extensions.keys().chain(other.extensions.keys()) {
455                let a = self.extension_level(key);
456                let b = other.extension_level(key);
457                let v = std::cmp::max(a, b);
458                if v != CapabilityLevel::Never {
459                    ext.insert(key.clone(), v);
460                }
461            }
462            ext
463        };
464
465        Self {
466            read_files: std::cmp::max(self.read_files, other.read_files),
467            write_files: std::cmp::max(self.write_files, other.write_files),
468            edit_files: std::cmp::max(self.edit_files, other.edit_files),
469            run_bash: std::cmp::max(self.run_bash, other.run_bash),
470            glob_search: std::cmp::max(self.glob_search, other.glob_search),
471            grep_search: std::cmp::max(self.grep_search, other.grep_search),
472            web_search: std::cmp::max(self.web_search, other.web_search),
473            web_fetch: std::cmp::max(self.web_fetch, other.web_fetch),
474            git_commit: std::cmp::max(self.git_commit, other.git_commit),
475            git_push: std::cmp::max(self.git_push, other.git_push),
476            create_pr: std::cmp::max(self.create_pr, other.create_pr),
477            manage_pods: std::cmp::max(self.manage_pods, other.manage_pods),
478            #[cfg(not(kani))]
479            extensions: ext,
480        }
481    }
482
483    /// Check if this lattice is less than or equal to another (partial order).
484    ///
485    /// For extension dimensions, missing keys default to `Never`.
486    /// Since `Never ≤ x` for all `x`, absent extensions satisfy leq trivially.
487    pub fn leq(&self, other: &Self) -> bool {
488        let core_leq = self.read_files <= other.read_files
489            && self.write_files <= other.write_files
490            && self.edit_files <= other.edit_files
491            && self.run_bash <= other.run_bash
492            && self.glob_search <= other.glob_search
493            && self.grep_search <= other.grep_search
494            && self.web_search <= other.web_search
495            && self.web_fetch <= other.web_fetch
496            && self.git_commit <= other.git_commit
497            && self.git_push <= other.git_push
498            && self.create_pr <= other.create_pr
499            && self.manage_pods <= other.manage_pods;
500
501        if !core_leq {
502            return false;
503        }
504
505        // Extension leq check — excluded from Kani (BTreeMap intractable for BMC)
506        #[cfg(not(kani))]
507        {
508            if !self.extensions.is_empty() || !other.extensions.is_empty() {
509                for key in self.extensions.keys().chain(other.extensions.keys()) {
510                    if self.extension_level(key) > other.extension_level(key) {
511                        return false;
512                    }
513                }
514            }
515        }
516
517        true
518    }
519
520    /// Create a permissive capability set (top of lattice).
521    ///
522    /// Note: This is the top of the CORE lattice only.
523    /// Extension dimensions are empty — they must be configured explicitly.
524    pub fn permissive() -> Self {
525        Self {
526            read_files: CapabilityLevel::Always,
527            write_files: CapabilityLevel::Always,
528            edit_files: CapabilityLevel::Always,
529            run_bash: CapabilityLevel::Always,
530            glob_search: CapabilityLevel::Always,
531            grep_search: CapabilityLevel::Always,
532            web_search: CapabilityLevel::Always,
533            web_fetch: CapabilityLevel::Always,
534            git_commit: CapabilityLevel::Always,
535            git_push: CapabilityLevel::Always,
536            create_pr: CapabilityLevel::Always,
537            manage_pods: CapabilityLevel::Always,
538            #[cfg(not(kani))]
539            extensions: BTreeMap::new(),
540        }
541    }
542
543    /// Create a restrictive capability set (near bottom of lattice).
544    pub fn restrictive() -> Self {
545        Self {
546            read_files: CapabilityLevel::Always,
547            write_files: CapabilityLevel::Never,
548            edit_files: CapabilityLevel::Never,
549            run_bash: CapabilityLevel::Never,
550            glob_search: CapabilityLevel::Always,
551            grep_search: CapabilityLevel::Always,
552            web_search: CapabilityLevel::Never,
553            web_fetch: CapabilityLevel::Never,
554            git_commit: CapabilityLevel::Never,
555            git_push: CapabilityLevel::Never,
556            create_pr: CapabilityLevel::Never,
557            manage_pods: CapabilityLevel::Never,
558            #[cfg(not(kani))]
559            extensions: BTreeMap::new(),
560        }
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_capability_level_ordering() {
570        assert!(CapabilityLevel::Never < CapabilityLevel::LowRisk);
571        assert!(CapabilityLevel::LowRisk < CapabilityLevel::Always);
572    }
573
574    #[test]
575    fn test_uninhabitable_detection() {
576        let caps = CapabilityLattice {
577            read_files: CapabilityLevel::Always,
578            web_fetch: CapabilityLevel::LowRisk,
579            git_push: CapabilityLevel::LowRisk,
580            ..Default::default()
581        };
582
583        let constraint = IncompatibilityConstraint::enforcing();
584        assert!(constraint.is_uninhabitable(&caps));
585    }
586
587    #[test]
588    fn test_uninhabitable_not_complete_without_all_three() {
589        let caps = CapabilityLattice {
590            read_files: CapabilityLevel::Always,
591            web_fetch: CapabilityLevel::Never,
592            web_search: CapabilityLevel::Never,
593            git_push: CapabilityLevel::LowRisk,
594            ..Default::default()
595        };
596
597        let constraint = IncompatibilityConstraint::enforcing();
598        assert!(!constraint.is_uninhabitable(&caps));
599    }
600
601    #[test]
602    fn test_uninhabitable_constraint_adds_obligations() {
603        let caps = CapabilityLattice {
604            read_files: CapabilityLevel::Always,
605            web_fetch: CapabilityLevel::LowRisk,
606            git_push: CapabilityLevel::LowRisk,
607            create_pr: CapabilityLevel::LowRisk,
608            ..Default::default()
609        };
610
611        let constraint = IncompatibilityConstraint::enforcing();
612        let obligations = constraint.obligations_for(&caps);
613
614        assert!(obligations.requires(Operation::GitPush));
615        assert!(obligations.requires(Operation::CreatePr));
616        assert!(!obligations.requires(Operation::WebFetch));
617    }
618
619    #[test]
620    fn test_meet_is_min() {
621        let a = CapabilityLattice {
622            write_files: CapabilityLevel::Always,
623            run_bash: CapabilityLevel::LowRisk,
624            ..Default::default()
625        };
626        let b = CapabilityLattice {
627            write_files: CapabilityLevel::LowRisk,
628            run_bash: CapabilityLevel::Always,
629            ..Default::default()
630        };
631        let result = a.meet(&b);
632
633        assert_eq!(result.write_files, CapabilityLevel::LowRisk);
634        assert_eq!(result.run_bash, CapabilityLevel::LowRisk);
635    }
636
637    #[test]
638    fn test_join_is_max() {
639        let a = CapabilityLattice {
640            write_files: CapabilityLevel::Never,
641            run_bash: CapabilityLevel::LowRisk,
642            ..Default::default()
643        };
644        let b = CapabilityLattice {
645            write_files: CapabilityLevel::LowRisk,
646            run_bash: CapabilityLevel::Never,
647            ..Default::default()
648        };
649        let result = a.join(&b);
650
651        assert_eq!(result.write_files, CapabilityLevel::LowRisk);
652        assert_eq!(result.run_bash, CapabilityLevel::LowRisk);
653    }
654
655    #[test]
656    fn test_uninhabitable_with_glob_search() {
657        // glob_search should count as information disclosure
658        let caps = CapabilityLattice {
659            read_files: CapabilityLevel::Never,    // No direct file reading
660            glob_search: CapabilityLevel::LowRisk, // But can see file structure
661            web_fetch: CapabilityLevel::LowRisk,
662            run_bash: CapabilityLevel::LowRisk, // Exfil vector
663            ..Default::default()
664        };
665
666        let constraint = IncompatibilityConstraint::enforcing();
667        assert!(constraint.is_uninhabitable(&caps));
668    }
669
670    #[test]
671    fn test_uninhabitable_with_grep_search() {
672        // grep_search should count as information disclosure
673        let caps = CapabilityLattice {
674            read_files: CapabilityLevel::Never,    // No direct file reading
675            grep_search: CapabilityLevel::LowRisk, // But can search file contents
676            web_fetch: CapabilityLevel::LowRisk,
677            git_push: CapabilityLevel::LowRisk, // Exfil vector
678            ..Default::default()
679        };
680
681        let constraint = IncompatibilityConstraint::enforcing();
682        assert!(constraint.is_uninhabitable(&caps));
683    }
684
685    #[test]
686    fn test_state_risk_with_search_only() {
687        // Just search capabilities should count as 1 component (private access)
688        // Must disable all other capabilities to avoid false positives
689        let caps = CapabilityLattice {
690            read_files: CapabilityLevel::Never,
691            write_files: CapabilityLevel::Never,
692            edit_files: CapabilityLevel::Never,
693            run_bash: CapabilityLevel::Never,
694            glob_search: CapabilityLevel::LowRisk, // Only this counts
695            grep_search: CapabilityLevel::LowRisk, // Only this counts
696            web_search: CapabilityLevel::Never,
697            web_fetch: CapabilityLevel::Never,
698            git_commit: CapabilityLevel::Never,
699            git_push: CapabilityLevel::Never,
700            create_pr: CapabilityLevel::Never,
701            manage_pods: CapabilityLevel::Never,
702            extensions: BTreeMap::new(),
703        };
704
705        let constraint = IncompatibilityConstraint::enforcing();
706        assert_eq!(constraint.state_risk(&caps), StateRisk::Low);
707    }
708}