1use serde::{Deserialize, Serialize};
15
16use crate::command::{classify_tier, CommandInvocation, CommandTier};
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct ActorId(pub String);
21
22impl ActorId {
23 pub fn new(id: impl Into<String>) -> Self {
24 Self(id.into())
25 }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum EffectKind {
32 ReadFile,
33 Search,
34 List,
35 LspQuery,
36 WriteArtifact,
37 ApplyPatch,
38 MoveFile,
39 DeleteFile,
40 RunVerifier,
41 RunFormatter,
42 RunTest,
43 RunBuild,
44 MutateDependencies,
45 RunRepoScript,
46 RunShell,
47 GitRead,
48 GitWrite,
49 NetworkFetch,
50 AskUser,
51 SpawnAgent,
52 UpdateGraph,
53 UpdatePolicy,
54}
55
56impl EffectKind {
57 pub fn is_read_only(self) -> bool {
59 matches!(
60 self,
61 EffectKind::ReadFile
62 | EffectKind::Search
63 | EffectKind::List
64 | EffectKind::LspQuery
65 | EffectKind::GitRead
66 )
67 }
68
69 pub fn is_privileged(self) -> bool {
71 matches!(self, EffectKind::UpdateGraph | EffectKind::UpdatePolicy)
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum RiskClass {
79 None,
80 Low,
81 Medium,
82 High,
83 Critical,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct PathPattern(pub String);
89
90impl PathPattern {
91 pub fn matches(&self, path: &str) -> bool {
92 glob_match(&self.0, path)
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct CommandPattern(pub String);
99
100impl CommandPattern {
101 pub fn matches(&self, program: &str) -> bool {
102 glob_match(&self.0, program)
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct NetworkPattern(pub String);
109
110impl NetworkPattern {
111 pub fn matches(&self, target: &str) -> bool {
112 glob_match(&self.0, target)
113 }
114}
115
116fn glob_match(pattern: &str, value: &str) -> bool {
118 if pattern == "*" {
119 return true;
120 }
121 if let Some(prefix) = pattern.strip_suffix('*') {
122 return value.starts_with(prefix);
123 }
124 if let Some(suffix) = pattern.strip_prefix('*') {
125 return value.ends_with(suffix);
126 }
127 pattern == value
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct RiskBudget {
133 pub name: String,
134 pub limit: f64,
136 pub spent: f64,
138}
139
140impl RiskBudget {
141 pub fn new(name: impl Into<String>, limit: f64) -> Self {
142 Self {
143 name: name.into(),
144 limit,
145 spent: 0.0,
146 }
147 }
148
149 pub fn admits(&self, cost: f64) -> bool {
151 self.spent + cost <= self.limit
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum ApprovalPolicy {
159 Auto,
161 Ask,
163 SessionApproved,
165 Deny,
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct Capability {
172 pub capability_id: String,
173 pub holder: ActorId,
174 pub effects: Vec<EffectKind>,
175 pub path_scope: Vec<PathPattern>,
176 pub command_scope: Vec<CommandPattern>,
177 pub network_scope: Vec<NetworkPattern>,
178 pub max_calls: Option<u32>,
180 pub expires_at: Option<i64>,
182 pub may_delegate: bool,
184 pub risk_budget: Option<RiskBudget>,
185 pub approval_policy: ApprovalPolicy,
186}
187
188impl Capability {
189 pub fn new(holder: ActorId, effects: Vec<EffectKind>) -> Self {
190 Self {
191 capability_id: uuid::Uuid::new_v4().to_string(),
192 holder,
193 effects,
194 path_scope: Vec::new(),
195 command_scope: Vec::new(),
196 network_scope: Vec::new(),
197 max_calls: None,
198 expires_at: None,
199 may_delegate: false,
200 risk_budget: None,
201 approval_policy: ApprovalPolicy::Auto,
202 }
203 }
204
205 pub fn with_paths(mut self, patterns: Vec<&str>) -> Self {
206 self.path_scope = patterns
207 .into_iter()
208 .map(|p| PathPattern(p.to_string()))
209 .collect();
210 self
211 }
212
213 pub fn delegable(mut self) -> Self {
214 self.may_delegate = true;
215 self
216 }
217
218 pub fn grants(&self, effect: EffectKind) -> bool {
219 self.effects.contains(&effect)
220 }
221
222 pub fn attenuates(&self, source: &Capability) -> bool {
225 if !self.effects.iter().all(|e| source.effects.contains(e)) {
227 return false;
228 }
229 let scope_subset = self.path_scope.iter().all(|p| {
231 source.path_scope.iter().any(|sp| sp == p)
232 || source.path_scope.iter().any(|sp| sp.0 == "*")
233 });
234 if !source.path_scope.is_empty() && !scope_subset {
235 return false;
236 }
237 if let (Some(child), Some(parent)) = (self.max_calls, source.max_calls) {
239 if child > parent {
240 return false;
241 }
242 }
243 if self.max_calls.is_none() && source.max_calls.is_some() {
244 return false; }
246 if let (Some(child), Some(parent)) = (self.expires_at, source.expires_at) {
248 if child > parent {
249 return false;
250 }
251 }
252 if self.expires_at.is_none() && source.expires_at.is_some() {
253 return false;
254 }
255 if self.may_delegate && !source.may_delegate {
257 return false;
258 }
259 true
260 }
261
262 pub fn delegate(&self, child: Capability) -> Option<Capability> {
265 if !self.may_delegate {
266 return None;
267 }
268 if child.attenuates(self) {
270 Some(child)
271 } else {
272 None
273 }
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct StateWitness {
281 pub resource: String,
282 pub content_hash: String,
283}
284
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287pub struct EffectProposal {
288 pub proposal_id: String,
289 pub actor: ActorId,
290 pub node_id: String,
291 pub generation: u32,
292 pub effect: EffectKind,
293 pub path: Option<String>,
295 pub command: Option<CommandInvocation>,
297 pub network_target: Option<String>,
299 pub risk: RiskClass,
300 pub risk_cost: f64,
302 pub idempotency_key: String,
303 pub preconditions: Vec<StateWitness>,
304}
305
306impl EffectProposal {
307 pub fn new(actor: ActorId, node_id: impl Into<String>, effect: EffectKind) -> Self {
308 Self {
309 proposal_id: uuid::Uuid::new_v4().to_string(),
310 actor,
311 node_id: node_id.into(),
312 generation: 0,
313 effect,
314 path: None,
315 command: None,
316 network_target: None,
317 risk: RiskClass::Low,
318 risk_cost: 0.0,
319 idempotency_key: uuid::Uuid::new_v4().to_string(),
320 preconditions: Vec::new(),
321 }
322 }
323
324 pub fn with_path(mut self, path: impl Into<String>) -> Self {
325 self.path = Some(path.into());
326 self
327 }
328
329 pub fn with_command(mut self, command: CommandInvocation) -> Self {
330 self.command = Some(command);
331 self
332 }
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(tag = "decision", rename_all = "snake_case")]
338pub enum AdmissibilityDecision {
339 Allow,
340 Deny { reason: DenyReason },
341 NeedsApproval,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "snake_case")]
347pub enum DenyReason {
348 NoCapability,
349 EffectOutOfScope,
350 PathOutOfScope,
351 CommandOutOfScope,
352 NetworkOutOfScope,
353 CallBudgetExhausted,
354 Expired,
355 RiskBudgetExhausted,
356 StateWitnessMismatch,
357 ShellNotPermitted,
358 MutationNotPermitted,
359 PolicyDenied,
360 PrivilegeEscalation,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(rename_all = "snake_case")]
366pub enum RecoveryClass {
367 Retryable,
368 NeedsApproval,
369 NeedsCapability,
370 Fatal,
371}
372
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
375pub struct AdmissibilityWitness {
376 pub proposal_id: String,
377 pub actor: ActorId,
378 pub capability_id: Option<String>,
379 pub authority_ok: bool,
380 pub contract_ok: bool,
381 pub effect_ok: bool,
382 pub barrier_increment_ok: bool,
383 pub risk_budget_ok: bool,
384 pub decision: AdmissibilityDecision,
385 pub recovery_class: Option<RecoveryClass>,
386}
387
388#[derive(Debug, Clone, Default)]
390pub struct KernelState {
391 pub witnesses: std::collections::HashMap<String, String>,
393}
394
395impl KernelState {
396 pub fn new() -> Self {
397 Self::default()
398 }
399
400 pub fn set_witness(&mut self, resource: impl Into<String>, hash: impl Into<String>) {
401 self.witnesses.insert(resource.into(), hash.into());
402 }
403}
404
405fn deny(
406 proposal: &EffectProposal,
407 cap: Option<&Capability>,
408 reason: DenyReason,
409 recovery: RecoveryClass,
410) -> AdmissibilityWitness {
411 AdmissibilityWitness {
412 proposal_id: proposal.proposal_id.clone(),
413 actor: proposal.actor.clone(),
414 capability_id: cap.map(|c| c.capability_id.clone()),
415 authority_ok: cap.is_some(),
416 contract_ok: false,
417 effect_ok: false,
418 barrier_increment_ok: false,
419 risk_budget_ok: false,
420 decision: AdmissibilityDecision::Deny { reason },
421 recovery_class: Some(recovery),
422 }
423}
424
425pub fn check_admissibility(
432 proposal: &EffectProposal,
433 capabilities: &[Capability],
434 state: &KernelState,
435) -> AdmissibilityWitness {
436 let cap = capabilities
441 .iter()
442 .find(|c| c.holder == proposal.actor && c.grants(proposal.effect));
443
444 let cap = match cap {
445 Some(c) => c,
446 None => {
447 return deny(
448 proposal,
449 None,
450 DenyReason::NoCapability,
451 RecoveryClass::NeedsCapability,
452 )
453 }
454 };
455
456 if let Some(expiry) = cap.expires_at {
458 if let Some(now) = state
462 .witnesses
463 .get("__now")
464 .and_then(|s| s.parse::<i64>().ok())
465 {
466 if now > expiry {
467 return deny(
468 proposal,
469 Some(cap),
470 DenyReason::Expired,
471 RecoveryClass::NeedsCapability,
472 );
473 }
474 }
475 }
476
477 if cap.max_calls == Some(0) {
479 return deny(
480 proposal,
481 Some(cap),
482 DenyReason::CallBudgetExhausted,
483 RecoveryClass::NeedsCapability,
484 );
485 }
486
487 if let Some(path) = &proposal.path {
489 if !cap.path_scope.is_empty() && !cap.path_scope.iter().any(|p| p.matches(path)) {
490 return deny(
491 proposal,
492 Some(cap),
493 DenyReason::PathOutOfScope,
494 RecoveryClass::NeedsApproval,
495 );
496 }
497 }
498
499 if let Some(command) = &proposal.command {
501 if command.requires_shell() && !cap.grants(EffectKind::RunShell) {
502 return deny(
503 proposal,
504 Some(cap),
505 DenyReason::ShellNotPermitted,
506 RecoveryClass::NeedsApproval,
507 );
508 }
509 let tier = classify_tier(command);
510 let mutation_effect = matches!(
511 proposal.effect,
512 EffectKind::WriteArtifact
513 | EffectKind::ApplyPatch
514 | EffectKind::MoveFile
515 | EffectKind::DeleteFile
516 | EffectKind::MutateDependencies
517 );
518 if tier == CommandTier::Mutation && !mutation_effect && proposal.effect.is_read_only() {
519 return deny(
520 proposal,
521 Some(cap),
522 DenyReason::MutationNotPermitted,
523 RecoveryClass::NeedsApproval,
524 );
525 }
526 if !cap.command_scope.is_empty()
527 && !cap
528 .command_scope
529 .iter()
530 .any(|p| p.matches(command.program_name()))
531 {
532 return deny(
533 proposal,
534 Some(cap),
535 DenyReason::CommandOutOfScope,
536 RecoveryClass::NeedsApproval,
537 );
538 }
539 }
540
541 if let Some(target) = &proposal.network_target {
543 if !cap.network_scope.iter().any(|p| p.matches(target)) {
544 return deny(
545 proposal,
546 Some(cap),
547 DenyReason::NetworkOutOfScope,
548 RecoveryClass::NeedsApproval,
549 );
550 }
551 }
552
553 for w in &proposal.preconditions {
555 match state.witnesses.get(&w.resource) {
556 Some(current) if current == &w.content_hash => {}
557 _ => {
558 return deny(
559 proposal,
560 Some(cap),
561 DenyReason::StateWitnessMismatch,
562 RecoveryClass::Retryable,
563 )
564 }
565 }
566 }
567
568 let risk_ok = cap
570 .risk_budget
571 .as_ref()
572 .map(|b| b.admits(proposal.risk_cost))
573 .unwrap_or(true);
574 if !risk_ok {
575 return deny(
576 proposal,
577 Some(cap),
578 DenyReason::RiskBudgetExhausted,
579 RecoveryClass::NeedsApproval,
580 );
581 }
582
583 let decision = match cap.approval_policy {
585 ApprovalPolicy::Deny => {
586 return deny(
587 proposal,
588 Some(cap),
589 DenyReason::PolicyDenied,
590 RecoveryClass::Fatal,
591 )
592 }
593 ApprovalPolicy::Ask => AdmissibilityDecision::NeedsApproval,
594 ApprovalPolicy::Auto | ApprovalPolicy::SessionApproved => AdmissibilityDecision::Allow,
595 };
596 let recovery = match decision {
597 AdmissibilityDecision::NeedsApproval => Some(RecoveryClass::NeedsApproval),
598 _ => None,
599 };
600
601 AdmissibilityWitness {
602 proposal_id: proposal.proposal_id.clone(),
603 actor: proposal.actor.clone(),
604 capability_id: Some(cap.capability_id.clone()),
605 authority_ok: true,
606 contract_ok: true,
607 effect_ok: true,
608 barrier_increment_ok: true,
609 risk_budget_ok: risk_ok,
610 decision,
611 recovery_class: recovery,
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::command::canonicalize;
619
620 fn actor() -> ActorId {
621 ActorId::new("implementer")
622 }
623
624 #[test]
625 fn read_only_actor_cannot_write() {
626 let caps = vec![Capability::new(
627 actor(),
628 vec![EffectKind::ReadFile, EffectKind::Search],
629 )];
630 let proposal =
631 EffectProposal::new(actor(), "n1", EffectKind::WriteArtifact).with_path("src/x.rs");
632 let w = check_admissibility(&proposal, &caps, &KernelState::new());
633 assert!(matches!(
634 w.decision,
635 AdmissibilityDecision::Deny {
636 reason: DenyReason::NoCapability
637 }
638 ));
639 }
640
641 #[test]
642 fn write_in_scope_is_allowed() {
643 let caps = vec![
644 Capability::new(actor(), vec![EffectKind::WriteArtifact]).with_paths(vec!["src/*"])
645 ];
646 let proposal =
647 EffectProposal::new(actor(), "n1", EffectKind::WriteArtifact).with_path("src/x.rs");
648 let w = check_admissibility(&proposal, &caps, &KernelState::new());
649 assert_eq!(w.decision, AdmissibilityDecision::Allow);
650 }
651
652 #[test]
653 fn write_out_of_path_scope_is_denied() {
654 let caps = vec![
655 Capability::new(actor(), vec![EffectKind::WriteArtifact]).with_paths(vec!["src/*"])
656 ];
657 let proposal =
658 EffectProposal::new(actor(), "n1", EffectKind::WriteArtifact).with_path("/etc/passwd");
659 let w = check_admissibility(&proposal, &caps, &KernelState::new());
660 assert!(matches!(
661 w.decision,
662 AdmissibilityDecision::Deny {
663 reason: DenyReason::PathOutOfScope
664 }
665 ));
666 }
667
668 #[test]
669 fn shell_command_denied_without_shell_capability() {
670 let mut cap = Capability::new(actor(), vec![EffectKind::RunVerifier]);
671 cap.command_scope = vec![CommandPattern("*".into())];
672 let proposal = EffectProposal::new(actor(), "n1", EffectKind::RunVerifier)
673 .with_command(canonicalize("cat x | grep y", "/r"));
674 let w = check_admissibility(&proposal, &[cap], &KernelState::new());
675 assert!(matches!(
676 w.decision,
677 AdmissibilityDecision::Deny {
678 reason: DenyReason::ShellNotPermitted
679 }
680 ));
681 }
682
683 #[test]
684 fn sed_in_place_denied_under_read_only_effect() {
685 let mut cap = Capability::new(actor(), vec![EffectKind::ReadFile]);
686 cap.command_scope = vec![CommandPattern("*".into())];
687 let proposal = EffectProposal::new(actor(), "n1", EffectKind::ReadFile)
688 .with_command(canonicalize("sed -i s/a/b/ f", "/r"));
689 let w = check_admissibility(&proposal, &[cap], &KernelState::new());
690 assert!(matches!(
691 w.decision,
692 AdmissibilityDecision::Deny {
693 reason: DenyReason::MutationNotPermitted
694 }
695 ));
696 }
697
698 #[test]
699 fn stale_state_witness_is_denied() {
700 let caps =
701 vec![Capability::new(actor(), vec![EffectKind::ApplyPatch]).with_paths(vec!["*"])];
702 let mut proposal =
703 EffectProposal::new(actor(), "n1", EffectKind::ApplyPatch).with_path("src/x.rs");
704 proposal.preconditions = vec![StateWitness {
705 resource: "src/x.rs".into(),
706 content_hash: "old".into(),
707 }];
708 let mut state = KernelState::new();
709 state.set_witness("src/x.rs", "new"); let w = check_admissibility(&proposal, &caps, &state);
711 assert!(matches!(
712 w.decision,
713 AdmissibilityDecision::Deny {
714 reason: DenyReason::StateWitnessMismatch
715 }
716 ));
717 }
718
719 #[test]
720 fn risk_budget_exhaustion_is_denied() {
721 let mut cap = Capability::new(actor(), vec![EffectKind::ApplyPatch]).with_paths(vec!["*"]);
722 cap.risk_budget = Some(RiskBudget {
723 name: "session".into(),
724 limit: 1.0,
725 spent: 0.8,
726 });
727 let mut proposal =
728 EffectProposal::new(actor(), "n1", EffectKind::ApplyPatch).with_path("x");
729 proposal.risk_cost = 0.5;
730 let w = check_admissibility(&proposal, &[cap], &KernelState::new());
731 assert!(matches!(
732 w.decision,
733 AdmissibilityDecision::Deny {
734 reason: DenyReason::RiskBudgetExhausted
735 }
736 ));
737 }
738
739 #[test]
740 fn ask_policy_needs_approval() {
741 let mut cap = Capability::new(actor(), vec![EffectKind::RunShell]).with_paths(vec!["*"]);
742 cap.approval_policy = ApprovalPolicy::Ask;
743 cap.command_scope = vec![CommandPattern("*".into())];
744 let proposal = EffectProposal::new(actor(), "n1", EffectKind::RunShell)
745 .with_command(canonicalize("echo hi | tee x", "/r"));
746 let w = check_admissibility(&proposal, &[cap], &KernelState::new());
747 assert_eq!(w.decision, AdmissibilityDecision::NeedsApproval);
748 }
749
750 #[test]
751 fn attenuation_only_shrinks_authority() {
752 let parent = Capability::new(
753 actor(),
754 vec![EffectKind::ReadFile, EffectKind::WriteArtifact],
755 )
756 .with_paths(vec!["*"])
757 .delegable();
758 let mut child = Capability::new(ActorId::new("sub"), vec![EffectKind::ReadFile])
760 .with_paths(vec!["src/*"]);
761 child.max_calls = Some(3);
762 assert!(child.attenuates(&parent));
763 assert!(parent.delegate(child).is_some());
764
765 let bad = Capability::new(ActorId::new("sub"), vec![EffectKind::UpdatePolicy]);
767 assert!(!bad.attenuates(&parent));
768 assert!(parent.delegate(bad).is_none());
769 }
770
771 #[test]
772 fn non_delegable_capability_cannot_delegate() {
773 let parent = Capability::new(actor(), vec![EffectKind::ReadFile]); let child = Capability::new(ActorId::new("sub"), vec![EffectKind::ReadFile]);
775 assert!(parent.delegate(child).is_none());
776 }
777
778 #[test]
779 fn payload_cannot_mint_authority() {
780 let proposal = EffectProposal::new(ActorId::new("ghost"), "n1", EffectKind::UpdatePolicy);
783 let w = check_admissibility(&proposal, &[], &KernelState::new());
784 assert!(matches!(
785 w.decision,
786 AdmissibilityDecision::Deny {
787 reason: DenyReason::NoCapability
788 }
789 ));
790 }
791}