Skip to main content

perspt_sdk/
capability.rs

1//! Capability-constrained admissibility kernel (PSP-8 System 7).
2//!
3//! Stochastic components emit proposals, never unmediated effects. Every effect
4//! passes through an admissibility kernel before execution. This module is the
5//! domain-neutral reference kernel and contract; `perspt-policy` is the
6//! deterministic trusted base that adopts it. Generated code, prompts, domain
7//! packages, and subagents are outside that trusted base.
8//!
9//! Authority is an explicit, attenuable value: delegation may only *shrink*
10//! effect scope, call budget, expiry, and delegability (the attenuation
11//! preorder `c' ⪯ c`). Payload data, model text, or generated code cannot mint
12//! authority (PSP-8 R4).
13
14use serde::{Deserialize, Serialize};
15
16use crate::command::{classify_tier, CommandInvocation, CommandTier};
17
18/// An actor that can hold capabilities and emit proposals.
19#[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/// Effect classes (PSP-8 System 7).
29#[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    /// Read-only effects allowed in workspace scope by default.
58    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    /// Privileged effects that self-modifying agents must never grant themselves.
70    pub fn is_privileged(self) -> bool {
71        matches!(self, EffectKind::UpdateGraph | EffectKind::UpdatePolicy)
72    }
73}
74
75/// Risk classification for a proposed effect.
76#[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/// A glob-like path pattern. `matches` uses a simple prefix/suffix/`*` rule.
87#[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/// A command pattern matched against the canonical program name.
97#[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/// A network host/URL pattern.
107#[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
116/// Minimal glob: supports a single trailing `*`, leading `*`, or exact match.
117fn 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/// A recorded risk budget (PSP-8 System 7).
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct RiskBudget {
133    pub name: String,
134    /// Total budget `ρ_c`.
135    pub limit: f64,
136    /// Amount already spent `spent(x)`.
137    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    /// Whether `spent + cost <= limit`.
150    pub fn admits(&self, cost: f64) -> bool {
151        self.spent + cost <= self.limit
152    }
153}
154
155/// Approval policy for an effect.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum ApprovalPolicy {
159    /// Allowed without explicit approval (within scope).
160    Auto,
161    /// Requires user approval.
162    Ask,
163    /// Allowed because an approved session policy covers it.
164    SessionApproved,
165    /// Never allowed.
166    Deny,
167}
168
169/// A capability: an explicit, attenuable grant of authority (PSP-8 System 7).
170#[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    /// Remaining call budget `q_c`. `None` means unbounded.
179    pub max_calls: Option<u32>,
180    /// Expiry `τ_c` as a unix timestamp. `None` means no expiry.
181    pub expires_at: Option<i64>,
182    /// Delegability `d_c`.
183    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    /// The attenuation preorder `c' ⪯ c`: a delegated capability may only shrink
223    /// effect scope, call budget, expiry, and delegability (PSP-8 System 7).
224    pub fn attenuates(&self, source: &Capability) -> bool {
225        // Effects subset.
226        if !self.effects.iter().all(|e| source.effects.contains(e)) {
227            return false;
228        }
229        // Path/command/network scope subset (each pattern must be covered).
230        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        // Call budget no greater.
238        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; // child unbounded but parent bounded
245        }
246        // Expiry no later.
247        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        // Delegability no greater.
256        if self.may_delegate && !source.may_delegate {
257            return false;
258        }
259        true
260    }
261
262    /// Attempt to delegate an attenuated child capability. Returns `None` if the
263    /// source is not delegable or the child does not satisfy the preorder.
264    pub fn delegate(&self, child: Capability) -> Option<Capability> {
265        if !self.may_delegate {
266            return None;
267        }
268        // The child holder may differ (the delegatee); attenuation governs scope.
269        if child.attenuates(self) {
270            Some(child)
271        } else {
272            None
273        }
274    }
275}
276
277/// A state witness: a content hash of a precondition that must still hold at
278/// execution time (PSP-8 System 7).
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct StateWitness {
281    pub resource: String,
282    pub content_hash: String,
283}
284
285/// An effect proposal (PSP-8 System 7).
286#[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    /// The path the effect touches, if any.
294    pub path: Option<String>,
295    /// The command, if this is an execution effect.
296    pub command: Option<CommandInvocation>,
297    /// The network target, if any.
298    pub network_target: Option<String>,
299    pub risk: RiskClass,
300    /// Cost charged against the capability risk budget `c_c`.
301    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/// The admissibility decision.
336#[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/// Why an effect was denied.
345#[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/// Recovery classification for a denied or failed effect.
364#[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/// The witness produced by checking a proposal (PSP-8 `AdmissibilityWitness`).
374#[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/// The current durable state the kernel reads when checking a proposal.
389#[derive(Debug, Clone, Default)]
390pub struct KernelState {
391    /// Live content hashes of resources, for state-witness validation.
392    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
425/// Evaluate the admissibility predicate `Adm(x, p, x')` for a proposal against
426/// the actor's capabilities and current kernel state.
427///
428/// Returns an [`AdmissibilityWitness`] recording each clause and the decision.
429/// Every effect SHALL be mediated by such a witness before any durable effect
430/// occurs (PSP-8 Gate E).
431pub fn check_admissibility(
432    proposal: &EffectProposal,
433    capabilities: &[Capability],
434    state: &KernelState,
435) -> AdmissibilityWitness {
436    // Self-modifying agents cannot grant themselves privileged effects: a
437    // privileged effect requires a capability whose holder is *not* the
438    // proposing actor, or an explicitly user-granted privileged capability.
439    // Find a capability held by the actor that grants the effect.
440    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    // Expiry.
457    if let Some(expiry) = cap.expires_at {
458        // A timestamp of 0 in preconditions is treated as "now unknown"; callers
459        // pass the real clock through the witness resource. Here we only reject
460        // when an explicit `__now` witness exceeds expiry.
461        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    // Call budget.
478    if cap.max_calls == Some(0) {
479        return deny(
480            proposal,
481            Some(cap),
482            DenyReason::CallBudgetExhausted,
483            RecoveryClass::NeedsCapability,
484        );
485    }
486
487    // Effect scope: path.
488    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    // Command governance.
500    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    // Network scope.
542    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    // State witnesses still match.
554    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    // Risk budget: spent + cost <= limit.
569    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    // Approval policy.
584    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"); // changed since proposal
710        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        // Valid child: fewer effects, bounded calls.
759        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        // Invalid child: tries to add an effect the parent lacks.
766        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]); // may_delegate = false
774        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        // An actor with no capability at all cannot perform any effect, no matter
781        // what the proposal claims.
782        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}