1use std::collections::BTreeMap;
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8#[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 #[default]
21 Never = 0,
22 LowRisk = 1,
24 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#[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 ReadFiles,
45 WriteFiles,
47 EditFiles,
49 RunBash,
51 GlobSearch,
53 GrepSearch,
55 WebSearch,
57 WebFetch,
59 GitCommit,
61 GitPush,
63 CreatePr,
65 ManagePods,
67}
68
69#[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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
98#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99pub struct Obligations {
100 #[cfg_attr(feature = "serde", serde(default))]
102 pub approvals: std::collections::BTreeSet<Operation>,
103}
104
105impl Obligations {
106 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 pub fn requires(&self, op: Operation) -> bool {
115 self.approvals.contains(&op)
116 }
117
118 pub fn insert(&mut self, op: Operation) {
120 self.approvals.insert(op);
121 }
122
123 pub fn len(&self) -> usize {
125 self.approvals.len()
126 }
127
128 pub fn is_empty(&self) -> bool {
130 self.approvals.is_empty()
131 }
132
133 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 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 pub fn leq(&self, other: &Self) -> bool {
154 self.approvals.is_superset(&other.approvals)
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
162#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
163pub struct CapabilityLattice {
164 pub read_files: CapabilityLevel,
166 pub write_files: CapabilityLevel,
168 pub edit_files: CapabilityLevel,
170 pub run_bash: CapabilityLevel,
172 pub glob_search: CapabilityLevel,
174 pub grep_search: CapabilityLevel,
176 pub web_search: CapabilityLevel,
178 pub web_fetch: CapabilityLevel,
180 pub git_commit: CapabilityLevel,
182 pub git_push: CapabilityLevel,
184 pub create_pr: CapabilityLevel,
186 #[cfg_attr(feature = "serde", serde(default))]
188 pub manage_pods: CapabilityLevel,
189
190 #[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#[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 #[default]
240 #[cfg_attr(feature = "serde", serde(alias = "none"))]
241 Safe = 0,
242 Low = 1,
244 Medium = 2,
246 #[cfg_attr(feature = "serde", serde(alias = "complete"))]
248 Uninhabitable = 3,
249}
250
251impl StateRisk {
252 pub fn requires_intervention(&self) -> bool {
254 *self == StateRisk::Uninhabitable
255 }
256
257 pub fn meet(&self, other: &Self) -> Self {
259 std::cmp::min(*self, *other)
260 }
261
262 pub fn join(&self, other: &Self) -> Self {
264 std::cmp::max(*self, *other)
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
280pub struct IncompatibilityConstraint {
281 pub enforce_uninhabitable: bool,
283}
284
285impl IncompatibilityConstraint {
286 pub fn enforcing() -> Self {
288 Self {
289 enforce_uninhabitable: true,
290 }
291 }
292
293 pub fn state_risk(&self, caps: &CapabilityLattice) -> StateRisk {
302 if !self.enforce_uninhabitable {
303 return StateRisk::Safe;
304 }
305
306 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 pub fn is_uninhabitable(&self, caps: &CapabilityLattice) -> bool {
332 self.state_risk(caps) == StateRisk::Uninhabitable
333 }
334
335 #[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 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 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 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 #[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 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 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 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 #[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 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 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 let caps = CapabilityLattice {
659 read_files: CapabilityLevel::Never, glob_search: CapabilityLevel::LowRisk, web_fetch: CapabilityLevel::LowRisk,
662 run_bash: CapabilityLevel::LowRisk, ..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 let caps = CapabilityLattice {
674 read_files: CapabilityLevel::Never, grep_search: CapabilityLevel::LowRisk, web_fetch: CapabilityLevel::LowRisk,
677 git_push: CapabilityLevel::LowRisk, ..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 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, grep_search: CapabilityLevel::LowRisk, 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}