1use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
15use serde::{Deserialize, Serialize};
16
17use crate::command::{classify_tier, CommandInvocation, CommandTier};
18use crate::error::SdkError;
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub struct ActorId(pub String);
23
24impl ActorId {
25 pub fn new(id: impl Into<String>) -> Self {
26 Self(id.into())
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum EffectKind {
34 ReadFile,
35 SystemProbe,
37 DataRead,
39 ToolSearch,
40 ToolProgram,
41 Search,
42 List,
43 LspQuery,
44 WriteArtifact,
45 ApplyPatch,
46 MoveFile,
47 DeleteFile,
48 RunVerifier,
49 RunFormatter,
50 RunTest,
51 RunBuild,
52 MutateDependencies,
53 RunRepoScript,
54 RunShell,
55 GitRead,
56 GitWrite,
57 NetworkFetch,
58 AskUser,
59 SpawnAgent,
60 UpdateGraph,
61 UpdatePolicy,
62}
63
64impl EffectKind {
65 pub fn is_read_only(self) -> bool {
67 matches!(
68 self,
69 EffectKind::ReadFile
70 | EffectKind::SystemProbe
71 | EffectKind::DataRead
72 | EffectKind::ToolSearch
73 | EffectKind::ToolProgram
74 | EffectKind::Search
75 | EffectKind::List
76 | EffectKind::LspQuery
77 | EffectKind::GitRead
78 )
79 }
80
81 pub fn is_privileged(self) -> bool {
83 matches!(self, EffectKind::UpdateGraph | EffectKind::UpdatePolicy)
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum RiskClass {
91 None,
92 Low,
93 Medium,
94 High,
95 Critical,
96}
97
98pub use crate::scope::{CommandPattern, NetworkPattern, PathPattern};
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct RiskBudget {
103 pub name: String,
104 pub limit: f64,
106 pub spent: f64,
108}
109
110impl RiskBudget {
111 pub fn new(name: impl Into<String>, limit: f64) -> Self {
112 Self {
113 name: name.into(),
114 limit,
115 spent: 0.0,
116 }
117 }
118
119 pub fn admits(&self, cost: f64) -> bool {
121 self.spent + cost <= self.limit
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum ApprovalPolicy {
129 Auto,
131 Ask,
133 SessionApproved,
135 Deny,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum CapabilityRole {
144 Session,
145 ChatTool,
148 Explorer,
149 Worker,
150 Reviewer,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157pub struct GrantPolicy {
158 pub policy_id: String,
159 pub workspace_root: String,
160 pub effect_ceiling: Vec<EffectKind>,
161 pub path_ceiling: Vec<PathPattern>,
162 pub command_ceiling: Vec<CommandPattern>,
163 pub network_ceiling: Vec<NetworkPattern>,
164 pub approval_ceiling: ApprovalPolicy,
165 pub authority_epoch: u64,
166 pub persistent: bool,
167 pub integrity_binding: String,
170}
171
172fn intersect_scope(
177 requested: Vec<String>,
178 ceiling: &[String],
179 deny_when_empty_ceiling: bool,
180) -> Vec<String> {
181 if ceiling.is_empty() {
182 return if deny_when_empty_ceiling {
183 Vec::new()
184 } else {
185 requested
186 };
187 }
188 if ceiling.iter().any(|pattern| pattern == "*") {
189 return requested;
190 }
191 if requested.is_empty() || requested.iter().any(|pattern| pattern == "*") {
192 return ceiling.to_vec();
193 }
194 requested
195 .into_iter()
196 .filter(|pattern| ceiling.iter().any(|allowed| allowed == pattern))
197 .collect()
198}
199
200impl GrantPolicy {
201 pub fn mint(&self, mut requested: Capability) -> crate::error::Result<Capability> {
211 requested
212 .effects
213 .retain(|effect| self.effect_ceiling.contains(effect));
214 if requested.effects.is_empty() {
215 return Err(SdkError::Domain(
216 "grant intersection authorizes no effects".into(),
217 ));
218 }
219 let unwrap_paths = |patterns: Vec<PathPattern>| -> Vec<String> {
220 patterns.into_iter().map(|p| p.0).collect()
221 };
222 requested.path_scope = intersect_scope(
223 unwrap_paths(requested.path_scope),
224 &self
225 .path_ceiling
226 .iter()
227 .map(|p| p.0.clone())
228 .collect::<Vec<_>>(),
229 false,
230 )
231 .into_iter()
232 .map(PathPattern)
233 .collect();
234 requested.command_scope = intersect_scope(
235 requested.command_scope.into_iter().map(|p| p.0).collect(),
236 &self
237 .command_ceiling
238 .iter()
239 .map(|p| p.0.clone())
240 .collect::<Vec<_>>(),
241 false,
242 )
243 .into_iter()
244 .map(CommandPattern)
245 .collect();
246 requested.network_scope = intersect_scope(
247 requested.network_scope.into_iter().map(|p| p.0).collect(),
248 &self
249 .network_ceiling
250 .iter()
251 .map(|p| p.0.clone())
252 .collect::<Vec<_>>(),
253 true,
254 )
255 .into_iter()
256 .map(NetworkPattern)
257 .collect();
258 if requested.role != CapabilityRole::Worker
259 && requested.approval_policy.strictness() < self.approval_ceiling.strictness()
260 {
261 requested.approval_policy = self.approval_ceiling;
262 }
263 requested.authority_epoch = self.authority_epoch;
264 Ok(requested)
265 }
266
267 pub fn canonical_bytes(&self) -> Vec<u8> {
274 fn field(out: &mut Vec<u8>, bytes: &[u8]) {
275 out.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
276 out.extend_from_slice(bytes);
277 }
278 fn list(out: &mut Vec<u8>, entries: impl ExactSizeIterator<Item = String>) {
279 out.extend_from_slice(&(entries.len() as u64).to_be_bytes());
280 for entry in entries {
281 field(out, entry.as_bytes());
282 }
283 }
284 let mut out = Vec::new();
285 field(&mut out, b"perspt-grant-policy-v1");
286 field(&mut out, self.policy_id.as_bytes());
287 field(&mut out, self.workspace_root.as_bytes());
288 list(
289 &mut out,
290 self.effect_ceiling.iter().map(|e| format!("{e:?}")),
291 );
292 list(&mut out, self.path_ceiling.iter().map(|p| p.0.clone()));
293 list(&mut out, self.command_ceiling.iter().map(|p| p.0.clone()));
294 list(&mut out, self.network_ceiling.iter().map(|p| p.0.clone()));
295 field(&mut out, format!("{:?}", self.approval_ceiling).as_bytes());
296 out.extend_from_slice(&self.authority_epoch.to_be_bytes());
297 out.push(u8::from(self.persistent));
298 field(&mut out, self.integrity_binding.as_bytes());
299 out
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311pub struct SignedGrantPolicy {
312 pub policy: GrantPolicy,
313 pub public_key: String,
314 pub signature: String,
315}
316
317impl SignedGrantPolicy {
318 pub fn sign(policy: GrantPolicy, secret_key: &[u8; 32]) -> crate::error::Result<Self> {
319 let signing_key = SigningKey::from_bytes(secret_key);
320 let signature = signing_key.sign(&policy.canonical_bytes());
321 Ok(Self {
322 policy,
323 public_key: hex_encode(signing_key.verifying_key().as_bytes()),
324 signature: hex_encode(&signature.to_bytes()),
325 })
326 }
327
328 pub fn verify_against(&self, trusted_public_key: &[u8; 32]) -> crate::error::Result<()> {
333 let embedded: [u8; 32] = hex_decode(&self.public_key)?
334 .try_into()
335 .map_err(|_| SdkError::Signature("grant public key must be 32 bytes".into()))?;
336 if &embedded != trusted_public_key {
337 return Err(SdkError::Signature(
338 "grant signed by an untrusted key".into(),
339 ));
340 }
341 let signature: [u8; 64] = hex_decode(&self.signature)?
342 .try_into()
343 .map_err(|_| SdkError::Signature("grant signature must be 64 bytes".into()))?;
344 let verifying_key = VerifyingKey::from_bytes(&embedded)
345 .map_err(|error| SdkError::Signature(error.to_string()))?;
346 verifying_key
347 .verify(
348 &self.policy.canonical_bytes(),
349 &Signature::from_bytes(&signature),
350 )
351 .map_err(|error| SdkError::Signature(error.to_string()))
352 }
353}
354
355pub fn grant_public_key(secret_key: &[u8; 32]) -> [u8; 32] {
359 SigningKey::from_bytes(secret_key)
360 .verifying_key()
361 .to_bytes()
362}
363
364pub fn hex_encode(bytes: &[u8]) -> String {
366 use std::fmt::Write;
367 let mut output = String::with_capacity(bytes.len() * 2);
368 for byte in bytes {
369 let _ = write!(&mut output, "{byte:02x}");
370 }
371 output
372}
373
374pub fn hex_decode(value: &str) -> crate::error::Result<Vec<u8>> {
376 if value.len() & 1 != 0 {
377 return Err(SdkError::Signature("hex value has odd length".into()));
378 }
379 (0..value.len())
380 .step_by(2)
381 .map(|index| {
382 u8::from_str_radix(&value[index..index + 2], 16)
383 .map_err(|error| SdkError::Signature(format!("invalid hex value: {error}")))
384 })
385 .collect()
386}
387
388impl ApprovalPolicy {
389 pub fn strictness(self) -> u8 {
392 match self {
393 ApprovalPolicy::Auto | ApprovalPolicy::SessionApproved => 0,
394 ApprovalPolicy::Ask => 1,
395 ApprovalPolicy::Deny => 2,
396 }
397 }
398}
399
400#[derive(Debug, Clone, PartialEq)]
402pub struct Capability {
403 pub capability_id: String,
404 pub holder: ActorId,
405 pub effects: Vec<EffectKind>,
406 pub path_scope: Vec<PathPattern>,
407 pub write_scope: Vec<PathPattern>,
411 pub command_scope: Vec<CommandPattern>,
412 pub network_scope: Vec<NetworkPattern>,
413 pub max_calls: Option<u32>,
415 pub expires_at: Option<i64>,
417 pub may_delegate: bool,
419 pub risk_budget: Option<RiskBudget>,
420 pub approval_policy: ApprovalPolicy,
421 pub session_id: String,
422 pub authority_epoch: u64,
423 pub graph_revision: String,
424 pub node_generation: u32,
425 pub role: CapabilityRole,
426 pub parent_capability_id: Option<String>,
427}
428
429impl Capability {
430 pub fn new(holder: ActorId, effects: Vec<EffectKind>) -> Self {
431 Self {
432 capability_id: uuid::Uuid::new_v4().to_string(),
433 holder,
434 effects,
435 path_scope: Vec::new(),
436 write_scope: Vec::new(),
437 command_scope: Vec::new(),
438 network_scope: Vec::new(),
439 max_calls: None,
440 expires_at: None,
441 may_delegate: false,
442 risk_budget: None,
443 approval_policy: ApprovalPolicy::Auto,
444 session_id: String::new(),
445 authority_epoch: 0,
446 graph_revision: String::new(),
447 node_generation: 0,
448 role: CapabilityRole::Session,
449 parent_capability_id: None,
450 }
451 }
452
453 pub fn with_paths(mut self, patterns: Vec<&str>) -> Self {
454 self.path_scope = patterns
455 .into_iter()
456 .map(|p| PathPattern(p.to_string()))
457 .collect();
458 self
459 }
460
461 pub fn delegable(mut self) -> Self {
462 self.may_delegate = true;
463 self
464 }
465
466 pub fn grants(&self, effect: EffectKind) -> bool {
467 self.effects.contains(&effect)
468 }
469
470 pub fn attenuates(&self, source: &Capability) -> bool {
473 if !self.effects.iter().all(|e| source.effects.contains(e)) {
475 return false;
476 }
477 fn scope_attenuates<T: PartialEq>(
483 child: &[T],
484 parent: &[T],
485 parent_has_wildcard: bool,
486 ) -> bool {
487 if parent.is_empty() {
488 return true; }
490 if child.is_empty() {
491 return false; }
493 child
494 .iter()
495 .all(|c| parent_has_wildcard || parent.contains(c))
496 }
497 let path_wild = source.path_scope.iter().any(|sp| sp.0 == "*");
498 if !scope_attenuates(&self.path_scope, &source.path_scope, path_wild) {
499 return false;
500 }
501 let write_wild = source.write_scope.iter().any(|sp| sp.0 == "*");
502 if !scope_attenuates(&self.write_scope, &source.write_scope, write_wild) {
503 return false;
504 }
505 let cmd_wild = source.command_scope.iter().any(|sp| sp.0 == "*");
506 if !scope_attenuates(&self.command_scope, &source.command_scope, cmd_wild) {
507 return false;
508 }
509 if source.network_scope.is_empty() {
515 if !self.network_scope.is_empty() {
516 return false;
517 }
518 } else {
519 let net_wild = source.network_scope.iter().any(|sp| sp.0 == "*");
520 if !self
521 .network_scope
522 .iter()
523 .all(|c| net_wild || source.network_scope.contains(c))
524 {
525 return false;
526 }
527 }
528 if let (Some(child), Some(parent)) = (self.max_calls, source.max_calls) {
530 if child > parent {
531 return false;
532 }
533 }
534 if self.max_calls.is_none() && source.max_calls.is_some() {
535 return false; }
537 if let (Some(child), Some(parent)) = (self.expires_at, source.expires_at) {
539 if child > parent {
540 return false;
541 }
542 }
543 if self.expires_at.is_none() && source.expires_at.is_some() {
544 return false;
545 }
546 if self.may_delegate && !source.may_delegate {
548 return false;
549 }
550 match (&self.risk_budget, &source.risk_budget) {
552 (Some(child), Some(parent))
553 if child.limit - child.spent > parent.limit - parent.spent =>
554 {
555 return false;
556 }
557 (None, Some(_)) => return false, _ => {}
559 }
560 if self.approval_policy.strictness() < source.approval_policy.strictness() {
562 return false;
563 }
564 if self.session_id != source.session_id
565 || self.authority_epoch != source.authority_epoch
566 || self.graph_revision != source.graph_revision
567 || self.node_generation != source.node_generation
568 {
569 return false;
570 }
571 if self.role == CapabilityRole::Session && source.role != CapabilityRole::Session {
572 return false;
573 }
574 if self.parent_capability_id.as_deref() != Some(source.capability_id.as_str()) {
575 return false;
576 }
577 true
578 }
579
580 pub fn delegate(&self, child: Capability) -> Option<Capability> {
583 if !self.may_delegate {
584 return None;
585 }
586 if child.attenuates(self) {
588 Some(child)
589 } else {
590 None
591 }
592 }
593}
594
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
598pub struct StateWitness {
599 pub resource: String,
600 pub content_hash: String,
601}
602
603#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
605pub struct EffectProposal {
606 pub proposal_id: String,
607 pub actor: ActorId,
608 pub node_id: String,
609 pub generation: u32,
610 pub effect: EffectKind,
611 pub path: Option<String>,
613 #[serde(default)]
615 pub additional_paths: Vec<String>,
616 pub command: Option<CommandInvocation>,
618 pub network_target: Option<String>,
620 pub risk: RiskClass,
621 pub risk_cost: f64,
623 pub idempotency_key: String,
624 pub preconditions: Vec<StateWitness>,
625}
626
627impl EffectProposal {
628 pub fn new(actor: ActorId, node_id: impl Into<String>, effect: EffectKind) -> Self {
629 Self {
630 proposal_id: uuid::Uuid::new_v4().to_string(),
631 actor,
632 node_id: node_id.into(),
633 generation: 0,
634 effect,
635 path: None,
636 additional_paths: Vec::new(),
637 command: None,
638 network_target: None,
639 risk: RiskClass::Low,
640 risk_cost: 0.0,
641 idempotency_key: uuid::Uuid::new_v4().to_string(),
642 preconditions: Vec::new(),
643 }
644 }
645
646 pub fn with_path(mut self, path: impl Into<String>) -> Self {
647 self.path = Some(path.into());
648 self
649 }
650
651 pub fn with_additional_paths(mut self, paths: Vec<String>) -> Self {
652 self.additional_paths = paths;
653 self
654 }
655
656 pub fn with_command(mut self, command: CommandInvocation) -> Self {
657 self.command = Some(command);
658 self
659 }
660
661 pub fn with_generation(mut self, generation: u32) -> Self {
667 self.generation = generation;
668 self
669 }
670
671 pub fn with_risk_class(mut self, risk: RiskClass) -> Self {
672 self.risk = risk;
673 self
674 }
675
676 pub fn with_network_target(mut self, target: impl Into<String>) -> Self {
677 self.network_target = Some(target.into());
678 self
679 }
680
681 pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
682 self.idempotency_key = key.into();
683 self
684 }
685
686 pub fn with_preconditions(mut self, preconditions: Vec<StateWitness>) -> Self {
687 self.preconditions = preconditions;
688 self
689 }
690}
691
692#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694#[serde(tag = "decision", rename_all = "snake_case")]
695pub enum AdmissibilityDecision {
696 Allow,
697 Deny { reason: DenyReason },
698 NeedsApproval,
699}
700
701#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(rename_all = "snake_case")]
704pub enum DenyReason {
705 NoCapability,
706 EffectOutOfScope,
707 PathOutOfScope,
708 CommandOutOfScope,
709 NetworkOutOfScope,
710 CallBudgetExhausted,
711 Expired,
712 RiskBudgetExhausted,
713 StateWitnessMismatch,
714 ShellNotPermitted,
715 MutationNotPermitted,
716 PolicyDenied,
717 PrivilegeEscalation,
718}
719
720#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
722#[serde(rename_all = "snake_case")]
723pub enum RecoveryClass {
724 Retryable,
725 NeedsApproval,
726 NeedsCapability,
727 Fatal,
728}
729
730#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
732pub struct AdmissibilityWitness {
733 pub proposal_id: String,
734 pub actor: ActorId,
735 pub capability_id: Option<String>,
736 pub authority_ok: bool,
737 pub contract_ok: bool,
738 pub effect_ok: bool,
739 pub barrier_increment_ok: bool,
740 pub risk_budget_ok: bool,
741 pub decision: AdmissibilityDecision,
742 pub recovery_class: Option<RecoveryClass>,
743}
744
745#[derive(Debug, Clone, Default)]
747pub struct KernelState {
748 pub witnesses: std::collections::HashMap<String, String>,
750}
751
752impl KernelState {
753 pub fn new() -> Self {
754 Self::default()
755 }
756
757 pub fn set_witness(&mut self, resource: impl Into<String>, hash: impl Into<String>) {
758 self.witnesses.insert(resource.into(), hash.into());
759 }
760}
761
762fn deny(
763 proposal: &EffectProposal,
764 cap: Option<&Capability>,
765 reason: DenyReason,
766 recovery: RecoveryClass,
767) -> AdmissibilityWitness {
768 AdmissibilityWitness {
769 proposal_id: proposal.proposal_id.clone(),
770 actor: proposal.actor.clone(),
771 capability_id: cap.map(|c| c.capability_id.clone()),
772 authority_ok: cap.is_some(),
773 contract_ok: false,
774 effect_ok: false,
775 barrier_increment_ok: false,
776 risk_budget_ok: false,
777 decision: AdmissibilityDecision::Deny { reason },
778 recovery_class: Some(recovery),
779 }
780}
781
782pub fn check_admissibility(
789 proposal: &EffectProposal,
790 capabilities: &[Capability],
791 state: &KernelState,
792) -> AdmissibilityWitness {
793 let cap = match find_authorized_capability(proposal, capabilities) {
794 Ok(cap) => cap,
795 Err(witness) => return *witness,
796 };
797 if let Some(denied) = check_binding(proposal, cap, state) {
798 return denied;
799 }
800 if let Some(denied) = check_lifetime(proposal, cap, state) {
801 return denied;
802 }
803 if let Some(denied) = check_scope(proposal, cap) {
804 return denied;
805 }
806 if let Some(denied) = check_state_witnesses(proposal, cap, state) {
807 return denied;
808 }
809 finalize_decision(proposal, cap)
810}
811
812fn find_authorized_capability<'a>(
817 proposal: &EffectProposal,
818 capabilities: &'a [Capability],
819) -> Result<&'a Capability, Box<AdmissibilityWitness>> {
820 let cap = capabilities
821 .iter()
822 .find(|c| c.holder == proposal.actor && c.grants(proposal.effect))
823 .ok_or_else(|| {
824 Box::new(deny(
825 proposal,
826 None,
827 DenyReason::NoCapability,
828 RecoveryClass::NeedsCapability,
829 ))
830 })?;
831 if proposal.effect.is_privileged()
832 && (cap.role != CapabilityRole::Session || cap.parent_capability_id.is_some())
833 {
834 return Err(Box::new(deny(
835 proposal,
836 Some(cap),
837 DenyReason::PrivilegeEscalation,
838 RecoveryClass::Fatal,
839 )));
840 }
841 Ok(cap)
842}
843
844fn check_binding(
847 proposal: &EffectProposal,
848 cap: &Capability,
849 state: &KernelState,
850) -> Option<AdmissibilityWitness> {
851 if !cap.graph_revision.is_empty()
852 && state.witnesses.get("__graph_revision") != Some(&cap.graph_revision)
853 {
854 return Some(deny(
855 proposal,
856 Some(cap),
857 DenyReason::StateWitnessMismatch,
858 RecoveryClass::NeedsCapability,
859 ));
860 }
861 if !cap.session_id.is_empty()
862 && state
863 .witnesses
864 .get("__authority_epoch")
865 .and_then(|value| value.parse::<u64>().ok())
866 != Some(cap.authority_epoch)
867 {
868 return Some(deny(
869 proposal,
870 Some(cap),
871 DenyReason::StateWitnessMismatch,
872 RecoveryClass::NeedsCapability,
873 ));
874 }
875 if proposal.generation != cap.node_generation {
876 return Some(deny(
877 proposal,
878 Some(cap),
879 DenyReason::StateWitnessMismatch,
880 RecoveryClass::Retryable,
881 ));
882 }
883 None
884}
885
886fn check_lifetime(
891 proposal: &EffectProposal,
892 cap: &Capability,
893 state: &KernelState,
894) -> Option<AdmissibilityWitness> {
895 if let Some(expiry) = cap.expires_at {
896 match state
897 .witnesses
898 .get("__now")
899 .and_then(|s| s.parse::<i64>().ok())
900 {
901 Some(now) if now <= expiry => {}
902 _ => {
903 return Some(deny(
904 proposal,
905 Some(cap),
906 DenyReason::Expired,
907 RecoveryClass::NeedsCapability,
908 ));
909 }
910 }
911 }
912 if cap.max_calls == Some(0) {
913 return Some(deny(
914 proposal,
915 Some(cap),
916 DenyReason::CallBudgetExhausted,
917 RecoveryClass::NeedsCapability,
918 ));
919 }
920 None
921}
922
923fn check_scope(proposal: &EffectProposal, cap: &Capability) -> Option<AdmissibilityWitness> {
926 let mutating_proposal = matches!(
927 proposal.effect,
928 EffectKind::WriteArtifact
929 | EffectKind::ApplyPatch
930 | EffectKind::MoveFile
931 | EffectKind::DeleteFile
932 | EffectKind::MutateDependencies
933 );
934 for path in proposal.path.iter().chain(proposal.additional_paths.iter()) {
935 let out_of_path =
936 !cap.path_scope.is_empty() && !cap.path_scope.iter().any(|p| p.matches(path));
937 let out_of_write = mutating_proposal
938 && !cap.write_scope.is_empty()
939 && !cap.write_scope.iter().any(|p| p.matches(path));
940 if out_of_path || out_of_write {
941 return Some(deny(
942 proposal,
943 Some(cap),
944 DenyReason::PathOutOfScope,
945 RecoveryClass::NeedsApproval,
946 ));
947 }
948 }
949 if let Some(command) = &proposal.command {
950 if command.requires_shell() && !cap.grants(EffectKind::RunShell) {
951 return Some(deny(
952 proposal,
953 Some(cap),
954 DenyReason::ShellNotPermitted,
955 RecoveryClass::NeedsApproval,
956 ));
957 }
958 if classify_tier(command) == CommandTier::Mutation
959 && !mutating_proposal
960 && proposal.effect.is_read_only()
961 {
962 return Some(deny(
963 proposal,
964 Some(cap),
965 DenyReason::MutationNotPermitted,
966 RecoveryClass::NeedsApproval,
967 ));
968 }
969 if !cap.command_scope.is_empty()
970 && !cap
971 .command_scope
972 .iter()
973 .any(|p| p.matches(command.program_name()))
974 {
975 return Some(deny(
976 proposal,
977 Some(cap),
978 DenyReason::CommandOutOfScope,
979 RecoveryClass::NeedsApproval,
980 ));
981 }
982 }
983 if let Some(target) = &proposal.network_target {
984 if !cap.network_scope.iter().any(|p| p.matches(target)) {
985 return Some(deny(
986 proposal,
987 Some(cap),
988 DenyReason::NetworkOutOfScope,
989 RecoveryClass::NeedsApproval,
990 ));
991 }
992 }
993 None
994}
995
996fn check_state_witnesses(
998 proposal: &EffectProposal,
999 cap: &Capability,
1000 state: &KernelState,
1001) -> Option<AdmissibilityWitness> {
1002 for w in &proposal.preconditions {
1003 match state.witnesses.get(&w.resource) {
1004 Some(current) if current == &w.content_hash => {}
1005 _ => {
1006 return Some(deny(
1007 proposal,
1008 Some(cap),
1009 DenyReason::StateWitnessMismatch,
1010 RecoveryClass::Retryable,
1011 ));
1012 }
1013 }
1014 }
1015 None
1016}
1017
1018fn finalize_decision(proposal: &EffectProposal, cap: &Capability) -> AdmissibilityWitness {
1020 let risk_ok = cap
1021 .risk_budget
1022 .as_ref()
1023 .map(|b| b.admits(proposal.risk_cost))
1024 .unwrap_or(true);
1025 if !risk_ok {
1026 return deny(
1027 proposal,
1028 Some(cap),
1029 DenyReason::RiskBudgetExhausted,
1030 RecoveryClass::NeedsApproval,
1031 );
1032 }
1033 let decision = match cap.approval_policy {
1034 ApprovalPolicy::Deny => {
1035 return deny(
1036 proposal,
1037 Some(cap),
1038 DenyReason::PolicyDenied,
1039 RecoveryClass::Fatal,
1040 )
1041 }
1042 ApprovalPolicy::Ask => AdmissibilityDecision::NeedsApproval,
1043 ApprovalPolicy::Auto | ApprovalPolicy::SessionApproved => AdmissibilityDecision::Allow,
1044 };
1045 let recovery = match decision {
1046 AdmissibilityDecision::NeedsApproval => Some(RecoveryClass::NeedsApproval),
1047 _ => None,
1048 };
1049 AdmissibilityWitness {
1050 proposal_id: proposal.proposal_id.clone(),
1051 actor: proposal.actor.clone(),
1052 capability_id: Some(cap.capability_id.clone()),
1053 authority_ok: true,
1054 contract_ok: true,
1055 effect_ok: true,
1056 barrier_increment_ok: true,
1057 risk_budget_ok: risk_ok,
1058 decision,
1059 recovery_class: recovery,
1060 }
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066 use crate::command::canonicalize;
1067
1068 fn actor() -> ActorId {
1069 ActorId::new("implementer")
1070 }
1071
1072 #[test]
1073 fn read_only_actor_cannot_write() {
1074 let caps = vec![Capability::new(
1075 actor(),
1076 vec![EffectKind::ReadFile, EffectKind::Search],
1077 )];
1078 let proposal =
1079 EffectProposal::new(actor(), "n1", EffectKind::WriteArtifact).with_path("src/x.rs");
1080 let w = check_admissibility(&proposal, &caps, &KernelState::new());
1081 assert!(matches!(
1082 w.decision,
1083 AdmissibilityDecision::Deny {
1084 reason: DenyReason::NoCapability
1085 }
1086 ));
1087 }
1088
1089 #[test]
1090 fn write_in_scope_is_allowed() {
1091 let caps = vec![
1092 Capability::new(actor(), vec![EffectKind::WriteArtifact]).with_paths(vec!["src/*"])
1093 ];
1094 let proposal =
1095 EffectProposal::new(actor(), "n1", EffectKind::WriteArtifact).with_path("src/x.rs");
1096 let w = check_admissibility(&proposal, &caps, &KernelState::new());
1097 assert_eq!(w.decision, AdmissibilityDecision::Allow);
1098 }
1099
1100 #[test]
1101 fn write_out_of_path_scope_is_denied() {
1102 let caps = vec![
1103 Capability::new(actor(), vec![EffectKind::WriteArtifact]).with_paths(vec!["src/*"])
1104 ];
1105 let proposal =
1106 EffectProposal::new(actor(), "n1", EffectKind::WriteArtifact).with_path("/etc/passwd");
1107 let w = check_admissibility(&proposal, &caps, &KernelState::new());
1108 assert!(matches!(
1109 w.decision,
1110 AdmissibilityDecision::Deny {
1111 reason: DenyReason::PathOutOfScope
1112 }
1113 ));
1114 }
1115
1116 #[test]
1117 fn shell_command_denied_without_shell_capability() {
1118 let mut cap = Capability::new(actor(), vec![EffectKind::RunVerifier]);
1119 cap.command_scope = vec![CommandPattern("*".into())];
1120 let proposal = EffectProposal::new(actor(), "n1", EffectKind::RunVerifier)
1121 .with_command(canonicalize("cat x | grep y", "/r"));
1122 let w = check_admissibility(&proposal, &[cap], &KernelState::new());
1123 assert!(matches!(
1124 w.decision,
1125 AdmissibilityDecision::Deny {
1126 reason: DenyReason::ShellNotPermitted
1127 }
1128 ));
1129 }
1130
1131 #[test]
1132 fn sed_in_place_denied_under_read_only_effect() {
1133 let mut cap = Capability::new(actor(), vec![EffectKind::ReadFile]);
1134 cap.command_scope = vec![CommandPattern("*".into())];
1135 let proposal = EffectProposal::new(actor(), "n1", EffectKind::ReadFile)
1136 .with_command(canonicalize("sed -i s/a/b/ f", "/r"));
1137 let w = check_admissibility(&proposal, &[cap], &KernelState::new());
1138 assert!(matches!(
1139 w.decision,
1140 AdmissibilityDecision::Deny {
1141 reason: DenyReason::MutationNotPermitted
1142 }
1143 ));
1144 }
1145
1146 #[test]
1147 fn stale_state_witness_is_denied() {
1148 let caps =
1149 vec![Capability::new(actor(), vec![EffectKind::ApplyPatch]).with_paths(vec!["*"])];
1150 let mut proposal =
1151 EffectProposal::new(actor(), "n1", EffectKind::ApplyPatch).with_path("src/x.rs");
1152 proposal.preconditions = vec![StateWitness {
1153 resource: "src/x.rs".into(),
1154 content_hash: "old".into(),
1155 }];
1156 let mut state = KernelState::new();
1157 state.set_witness("src/x.rs", "new"); let w = check_admissibility(&proposal, &caps, &state);
1159 assert!(matches!(
1160 w.decision,
1161 AdmissibilityDecision::Deny {
1162 reason: DenyReason::StateWitnessMismatch
1163 }
1164 ));
1165 }
1166
1167 #[test]
1168 fn risk_budget_exhaustion_is_denied() {
1169 let mut cap = Capability::new(actor(), vec![EffectKind::ApplyPatch]).with_paths(vec!["*"]);
1170 cap.risk_budget = Some(RiskBudget {
1171 name: "session".into(),
1172 limit: 1.0,
1173 spent: 0.8,
1174 });
1175 let mut proposal =
1176 EffectProposal::new(actor(), "n1", EffectKind::ApplyPatch).with_path("x");
1177 proposal.risk_cost = 0.5;
1178 let w = check_admissibility(&proposal, &[cap], &KernelState::new());
1179 assert!(matches!(
1180 w.decision,
1181 AdmissibilityDecision::Deny {
1182 reason: DenyReason::RiskBudgetExhausted
1183 }
1184 ));
1185 }
1186
1187 #[test]
1188 fn ask_policy_needs_approval() {
1189 let mut cap = Capability::new(actor(), vec![EffectKind::RunShell]).with_paths(vec!["*"]);
1190 cap.approval_policy = ApprovalPolicy::Ask;
1191 cap.command_scope = vec![CommandPattern("*".into())];
1192 let proposal = EffectProposal::new(actor(), "n1", EffectKind::RunShell)
1193 .with_command(canonicalize("echo hi | tee x", "/r"));
1194 let w = check_admissibility(&proposal, &[cap], &KernelState::new());
1195 assert_eq!(w.decision, AdmissibilityDecision::NeedsApproval);
1196 }
1197
1198 #[test]
1199 fn attenuation_only_shrinks_authority() {
1200 let parent = Capability::new(
1201 actor(),
1202 vec![EffectKind::ReadFile, EffectKind::WriteArtifact],
1203 )
1204 .with_paths(vec!["*"])
1205 .delegable();
1206 let mut child = Capability::new(ActorId::new("sub"), vec![EffectKind::ReadFile])
1208 .with_paths(vec!["src/*"]);
1209 child.max_calls = Some(3);
1210 child.parent_capability_id = Some(parent.capability_id.clone());
1211 assert!(child.attenuates(&parent));
1212 assert!(parent.delegate(child).is_some());
1213
1214 let mut bad = Capability::new(ActorId::new("sub"), vec![EffectKind::UpdatePolicy]);
1216 bad.parent_capability_id = Some(parent.capability_id.clone());
1217 assert!(!bad.attenuates(&parent));
1218 assert!(parent.delegate(bad).is_none());
1219 }
1220
1221 #[test]
1222 fn non_delegable_capability_cannot_delegate() {
1223 let parent = Capability::new(actor(), vec![EffectKind::ReadFile]); let child = Capability::new(ActorId::new("sub"), vec![EffectKind::ReadFile]);
1225 assert!(parent.delegate(child).is_none());
1226 }
1227
1228 #[test]
1229 fn payload_cannot_mint_authority() {
1230 let proposal = EffectProposal::new(ActorId::new("ghost"), "n1", EffectKind::UpdatePolicy);
1233 let w = check_admissibility(&proposal, &[], &KernelState::new());
1234 assert!(matches!(
1235 w.decision,
1236 AdmissibilityDecision::Deny {
1237 reason: DenyReason::NoCapability
1238 }
1239 ));
1240 }
1241
1242 #[test]
1243 fn persistent_grant_signature_detects_policy_tampering() {
1244 let policy = GrantPolicy {
1245 policy_id: "p1".into(),
1246 workspace_root: "/workspace".into(),
1247 effect_ceiling: vec![EffectKind::ReadFile],
1248 path_ceiling: vec![PathPattern("src/*".into())],
1249 command_ceiling: vec![],
1250 network_ceiling: vec![],
1251 approval_ceiling: ApprovalPolicy::Ask,
1252 authority_epoch: 3,
1253 persistent: true,
1254 integrity_binding: "ledger:abc".into(),
1255 };
1256 let mut signed = SignedGrantPolicy::sign(policy, &[7u8; 32]).unwrap();
1257 let trusted: [u8; 32] = hex_decode(&signed.public_key).unwrap().try_into().unwrap();
1258 signed.verify_against(&trusted).unwrap();
1259 signed.policy.authority_epoch += 1;
1260 assert!(signed.verify_against(&trusted).is_err());
1261 }
1262
1263 #[test]
1264 fn grant_signature_rejects_an_untrusted_signer() {
1265 let policy = GrantPolicy {
1268 policy_id: "p1".into(),
1269 workspace_root: "/workspace".into(),
1270 effect_ceiling: vec![EffectKind::ReadFile],
1271 path_ceiling: vec![],
1272 command_ceiling: vec![],
1273 network_ceiling: vec![],
1274 approval_ceiling: ApprovalPolicy::Ask,
1275 authority_epoch: 0,
1276 persistent: true,
1277 integrity_binding: "ledger:abc".into(),
1278 };
1279 let trusted = SignedGrantPolicy::sign(policy.clone(), &[7u8; 32]).unwrap();
1280 let trusted_key: [u8; 32] = hex_decode(&trusted.public_key).unwrap().try_into().unwrap();
1281 let mut widened = policy;
1282 widened.effect_ceiling.push(EffectKind::RunShell);
1283 let attacker = SignedGrantPolicy::sign(widened, &[9u8; 32]).unwrap();
1284 assert!(attacker.verify_against(&trusted_key).is_err());
1285 }
1286
1287 #[test]
1288 fn no_network_parent_cannot_delegate_network_authority() {
1289 let parent = Capability::new(actor(), vec![EffectKind::NetworkFetch]).delegable();
1290 let mut child = Capability::new(ActorId::new("child"), vec![EffectKind::NetworkFetch]);
1291 child.parent_capability_id = Some(parent.capability_id.clone());
1292 child.role = CapabilityRole::Worker;
1293 child.network_scope = vec![NetworkPattern("*".into())];
1294 assert!(parent.delegate(child).is_none());
1295 }
1296
1297 #[test]
1298 fn privileged_effect_requires_root_session_authority() {
1299 let mut cap = Capability::new(actor(), vec![EffectKind::UpdatePolicy]);
1302 cap.role = CapabilityRole::Worker;
1303 cap.parent_capability_id = Some("parent".into());
1304 let proposal = EffectProposal::new(actor(), "n1", EffectKind::UpdatePolicy);
1305 let w = check_admissibility(&proposal, &[cap], &KernelState::new());
1306 assert!(matches!(
1307 w.decision,
1308 AdmissibilityDecision::Deny {
1309 reason: DenyReason::PrivilegeEscalation
1310 }
1311 ));
1312 }
1313
1314 #[test]
1315 fn mint_intersects_the_grant_ceiling_and_fails_closed() {
1316 let policy = GrantPolicy {
1317 policy_id: "p1".into(),
1318 workspace_root: "/workspace".into(),
1319 effect_ceiling: vec![EffectKind::ReadFile, EffectKind::WriteArtifact],
1320 path_ceiling: vec![PathPattern("src/*".into())],
1321 command_ceiling: vec![CommandPattern("rg".into())],
1322 network_ceiling: vec![],
1323 approval_ceiling: ApprovalPolicy::Ask,
1324 authority_epoch: 3,
1325 persistent: false,
1326 integrity_binding: "ledger:abc".into(),
1327 };
1328 let mut requested = Capability::new(
1330 ActorId::new("worker"),
1331 vec![EffectKind::ReadFile, EffectKind::RunShell],
1332 );
1333 requested.role = CapabilityRole::Worker;
1334 requested.path_scope = vec![PathPattern("*".into())];
1335 requested.network_scope = vec![NetworkPattern("*".into())];
1336 let minted = policy.mint(requested).unwrap();
1337 assert_eq!(minted.effects, vec![EffectKind::ReadFile]);
1338 assert_eq!(minted.path_scope, vec![PathPattern("src/*".into())]);
1339 assert!(minted.network_scope.is_empty(), "no-network ceiling holds");
1340 assert_eq!(minted.authority_epoch, 3);
1341 assert_eq!(minted.approval_policy, ApprovalPolicy::Auto);
1344 let mut session = Capability::new(ActorId::new("session"), vec![EffectKind::ReadFile]);
1345 session.role = CapabilityRole::Session;
1346 assert_eq!(
1347 policy.mint(session).unwrap().approval_policy,
1348 ApprovalPolicy::Ask
1349 );
1350 let ghost = Capability::new(ActorId::new("worker"), vec![EffectKind::RunShell]);
1352 assert!(policy.mint(ghost).is_err());
1353 }
1354
1355 #[test]
1356 fn expiry_without_a_now_witness_fails_closed() {
1357 let mut cap = Capability::new(actor(), vec![EffectKind::ReadFile]);
1358 cap.expires_at = Some(1_700_000_000);
1359 let proposal = EffectProposal::new(actor(), "n1", EffectKind::ReadFile);
1360 let w = check_admissibility(&proposal, std::slice::from_ref(&cap), &KernelState::new());
1361 assert!(matches!(
1362 w.decision,
1363 AdmissibilityDecision::Deny {
1364 reason: DenyReason::Expired
1365 }
1366 ));
1367 let mut state = KernelState::new();
1368 state.set_witness("__now", "1699999999");
1369 let w = check_admissibility(&proposal, &[cap], &state);
1370 assert_eq!(w.decision, AdmissibilityDecision::Allow);
1371 }
1372}