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 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/// An actor that can hold capabilities and emit proposals.
21#[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/// Effect classes (PSP-8 System 7).
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum EffectKind {
34    ReadFile,
35    /// Read-only operating-system and toolchain facts.
36    SystemProbe,
37    /// Read-only, bounded queries over explicitly selected data sources.
38    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    /// Read-only effects allowed in workspace scope by default.
66    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    /// Privileged effects that self-modifying agents must never grant themselves.
82    pub fn is_privileged(self) -> bool {
83        matches!(self, EffectKind::UpdateGraph | EffectKind::UpdatePolicy)
84    }
85}
86
87/// Risk classification for a proposed effect.
88#[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/// A recorded risk budget (PSP-8 System 7).
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct RiskBudget {
103    pub name: String,
104    /// Total budget `ρ_c`.
105    pub limit: f64,
106    /// Amount already spent `spent(x)`.
107    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    /// Whether `spent + cost <= limit`.
120    pub fn admits(&self, cost: f64) -> bool {
121        self.spent + cost <= self.limit
122    }
123}
124
125/// Approval policy for an effect.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum ApprovalPolicy {
129    /// Allowed without explicit approval (within scope).
130    Auto,
131    /// Requires user approval.
132    Ask,
133    /// Allowed because an approved session policy covers it.
134    SessionApproved,
135    /// Never allowed.
136    Deny,
137}
138
139/// Role-scoped authority template. Role is part of attenuation and cannot be
140/// changed by proposal payload data.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum CapabilityRole {
144    Session,
145    /// Interactive chat external tools. Kept distinct from coding workers so
146    /// chat eligibility cannot widen agent authority.
147    ChatTool,
148    Explorer,
149    Worker,
150    Reviewer,
151}
152
153/// Durable authorization intent. A resume re-mints a fresh live capability
154/// from this policy and current workspace facts; the capability itself is
155/// deliberately not serializable.
156#[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    /// Content binding recorded in the session ledger. Persistent policies
168    /// require a separate cryptographic signature before cross-session use.
169    pub integrity_binding: String,
170}
171
172/// Scope intersection for `GrantPolicy::mint`. An empty ceiling means
173/// unbounded for allow-when-empty scopes (paths, commands) and *no authority*
174/// for deny-when-empty scopes (network). A `*` on either side defers to the
175/// other side's bound.
176fn 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    /// Mint a live capability as the intersection of a requested template
202    /// with this policy's ceilings (PSP-9 resolved decision 6). Fails closed:
203    /// an empty effect intersection is an error, never an unlimited grant.
204    ///
205    /// The approval ceiling governs durable delivery. A `Worker`-role request
206    /// confined to the reversible candidate overlay may keep `Auto` inside an
207    /// `Ask` ceiling, because nothing it does becomes durable without the
208    /// separate promotion approval; every other role is clamped to the
209    /// stricter policy.
210    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    /// The deterministic byte encoding the grant signature commits to.
268    ///
269    /// serde output is not canonical (field order, float and escape formatting
270    /// may change across versions), so the signature covers this fixed,
271    /// length-prefixed encoding instead. Every field participates; list fields
272    /// are count-prefixed so no concatenation can alias two policies.
273    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/// Signed durable grant intent. Verification authenticates policy bytes only;
304/// resume must still intersect the policy with the current authority epoch,
305/// workspace facts, and configured ceilings before minting a new capability.
306///
307/// The embedded public key is *not* a trust anchor — anyone can re-sign a
308/// rewritten policy with their own key. Callers MUST verify against the key
309/// they trust via [`SignedGrantPolicy::verify_against`].
310#[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    /// Verify the signature against a caller-supplied trusted public key.
329    ///
330    /// This is the only verification that authenticates the policy: it fails
331    /// both on a bad signature and on a signer other than `trusted_public_key`.
332    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
355/// Derive the Ed25519 public key for a grant signing key, so callers can
356/// anchor [`SignedGrantPolicy::verify_against`] to the key they resolved
357/// without taking their own dependency on the signature crate.
358pub fn grant_public_key(secret_key: &[u8; 32]) -> [u8; 32] {
359    SigningKey::from_bytes(secret_key)
360        .verifying_key()
361        .to_bytes()
362}
363
364/// Lowercase hex encoding, shared by grant signing and key persistence.
365pub 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
374/// Inverse of [`hex_encode`].
375pub 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    /// Ordering for the attenuation preorder: a child's policy must be at
390    /// least as strict as its parent's (PSP-9 system 12).
391    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/// A capability: an explicit, attenuable grant of authority (PSP-8 System 7).
401#[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    /// Mutation-only path ceiling (PSP-10 system 22): the node's declared
408    /// `output_targets`. Reads stay governed by `path_scope` alone; empty
409    /// means unrestricted (undeclared targets).
410    pub write_scope: Vec<PathPattern>,
411    pub command_scope: Vec<CommandPattern>,
412    pub network_scope: Vec<NetworkPattern>,
413    /// Remaining call budget `q_c`. `None` means unbounded.
414    pub max_calls: Option<u32>,
415    /// Expiry `τ_c` as a unix timestamp. `None` means no expiry.
416    pub expires_at: Option<i64>,
417    /// Delegability `d_c`.
418    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    /// The attenuation preorder `c' ⪯ c`: a delegated capability may only shrink
471    /// effect scope, call budget, expiry, and delegability (PSP-8 System 7).
472    pub fn attenuates(&self, source: &Capability) -> bool {
473        // Effects subset.
474        if !self.effects.iter().all(|e| source.effects.contains(e)) {
475            return false;
476        }
477        // Path/command/network scope subset (each pattern must be covered).
478        // All three scopes are part of Perspt's effective `E_c` and MUST
479        // participate in the Def. 4.1 preorder (PSP-9 system 12, Gate L).
480        // Empty child scope means unbounded only when the parent is also
481        // unbounded; it must never widen a bounded parent.
482        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; // unbounded parent admits any child scope
489            }
490            if child.is_empty() {
491                return false; // bounded parent, unbounded child: widening
492            }
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        // Network scope is deny-by-default at admissibility time (an empty
510        // scope grants no network authority), so its preorder must invert:
511        // an empty parent scope bounds the child to empty, and a non-empty
512        // child must be covered by the parent. Reusing the allow-by-default
513        // rule here would let a no-network parent delegate `*` (widening).
514        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        // Call budget no greater.
529        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; // child unbounded but parent bounded
536        }
537        // Expiry no later.
538        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        // Delegability no greater.
547        if self.may_delegate && !source.may_delegate {
548            return false;
549        }
550        // Risk budget no larger (PSP-9): a bounded parent bounds the child.
551        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, // child unbounded, parent bounded
558            _ => {}
559        }
560        // Approval policy at least as strict.
561        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    /// Attempt to delegate an attenuated child capability. Returns `None` if the
581    /// source is not delegable or the child does not satisfy the preorder.
582    pub fn delegate(&self, child: Capability) -> Option<Capability> {
583        if !self.may_delegate {
584            return None;
585        }
586        // The child holder may differ (the delegatee); attenuation governs scope.
587        if child.attenuates(self) {
588            Some(child)
589        } else {
590            None
591        }
592    }
593}
594
595/// A state witness: a content hash of a precondition that must still hold at
596/// execution time (PSP-8 System 7).
597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
598pub struct StateWitness {
599    pub resource: String,
600    pub content_hash: String,
601}
602
603/// An effect proposal (PSP-8 System 7).
604#[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    /// The path the effect touches, if any.
612    pub path: Option<String>,
613    /// Additional paths touched by a multi-path effect such as a move.
614    #[serde(default)]
615    pub additional_paths: Vec<String>,
616    /// The command, if this is an execution effect.
617    pub command: Option<CommandInvocation>,
618    /// The network target, if any.
619    pub network_target: Option<String>,
620    pub risk: RiskClass,
621    /// Cost charged against the capability risk budget `c_c`.
622    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    // --- PSP-9 system 12 builders. The certified increment `c_c` and the
662    // budget debit come from the registered capability contract and
663    // `BarrierWitness`, never from model arguments, so there is no
664    // `with_risk_cost` builder. ---
665
666    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/// The admissibility decision.
693#[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/// Why an effect was denied.
702#[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/// Recovery classification for a denied or failed effect.
721#[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/// The witness produced by checking a proposal (PSP-8 `AdmissibilityWitness`).
731#[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/// The current durable state the kernel reads when checking a proposal.
746#[derive(Debug, Clone, Default)]
747pub struct KernelState {
748    /// Live content hashes of resources, for state-witness validation.
749    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
782/// Evaluate the admissibility predicate `Adm(x, p, x')` for a proposal against
783/// the actor's capabilities and current kernel state.
784///
785/// Returns an [`AdmissibilityWitness`] recording each clause and the decision.
786/// Every effect SHALL be mediated by such a witness before any durable effect
787/// occurs (PSP-8 Gate E).
788pub 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
812/// Authority clause: a capability held by the proposing actor that grants the
813/// effect. Privileged effects additionally require root, user-minted authority:
814/// a delegated or non-session capability cannot carry them, so a self-modifying
815/// agent can never launder `UpdateGraph`/`UpdatePolicy` through a child grant.
816fn 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
844/// Binding clause: the capability is bound to the live graph revision,
845/// authority epoch, and node generation it was minted for.
846fn 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
886/// Lifetime clause: expiry `τ_c` and call budget `q_c`. An expiry with no
887/// `__now` witness fails closed — the kernel cannot certify "not expired"
888/// without a clock it trusts, and defaulting to open would make `expires_at`
889/// decorative on any path that forgets to supply time.
890fn 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
923/// Scope clause: path, write (mutating proposals only), command, and
924/// network scope of the effective `E_c`.
925fn 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
996/// State-witness clause: every recorded precondition hash still holds.
997fn 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
1018/// Risk-budget and approval clauses, then the final witness.
1019fn 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"); // changed since proposal
1158        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        // Valid child: fewer effects, bounded calls.
1207        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        // Invalid child: tries to add an effect the parent lacks.
1215        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]); // may_delegate = false
1224        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        // An actor with no capability at all cannot perform any effect, no matter
1231        // what the proposal claims.
1232        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        // Re-signing a rewritten policy with a different key must not verify:
1266        // the embedded public key is not a trust anchor.
1267        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        // A delegated (child) capability carrying a privileged effect is
1300        // rejected even though it nominally grants it.
1301        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        // A widening request is clamped in every dimension.
1329        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        // Worker role keeps Auto inside an Ask ceiling (candidate-only
1342        // authority); a session role is clamped to the stricter policy.
1343        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        // An empty effect intersection is an error, not an unlimited grant.
1351        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}