Skip to main content

uni_plugin/
capability.rs

1//! Plugin capabilities — declared in manifest, granted at load time.
2//!
3//! A `Capability` is the unit of permission in the plugin framework. Every
4//! extension surface (`Capability::ScalarFn`, `Capability::Storage`, …) is
5//! gated by a capability; every host import that exposes powerful primitives
6//! (network, filesystem, secrets, host-side query) is gated by an attenuated
7//! capability (`Capability::Network { allow }`).
8//!
9//! Enforcement happens in three layers:
10//!
11//! 1. **Registrar gate** — `PluginRegistrar::scalar_fn` etc. check the
12//!    effective capability set before accepting a registration.
13//! 2. **WIT linker** — for WASM plugins, host imports for capability-gated
14//!    functions are linked into the wasmtime `Linker` only when the
15//!    corresponding capability is granted. Ungranted host functions are
16//!    not present in the plugin's imports table.
17//! 3. **Runtime pattern checks** — capability grants with patterns
18//!    (`Filesystem { read: vec!["/data/**"] }`) validate the actual call
19//!    arguments against the pattern before dispatching.
20
21use std::collections::BTreeSet;
22
23use serde::{Deserialize, Serialize};
24use smol_str::SmolStr;
25
26/// A single permission grant.
27///
28/// `Capability` is the leaf node of the permission model. A
29/// [`CapabilitySet`] is a collection of capabilities.
30#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31#[serde(tag = "kind", rename_all = "kebab-case")]
32#[non_exhaustive]
33pub enum Capability {
34    // ---- Host import surfaces (capability-gated host functions) ----
35    /// HTTP / TCP egress; allow-list of URI patterns.
36    Network {
37        /// Glob patterns of permitted URIs (`https://api.example/**`). Defaults
38        /// to empty (deny-all) so a bare `"network"` declaration grants no
39        /// egress until patterns are specified.
40        #[serde(default)]
41        allow: Vec<SmolStr>,
42    },
43    /// Filesystem read / write access with per-direction path patterns.
44    Filesystem {
45        /// Glob patterns of readable paths (empty = deny-all).
46        #[serde(default)]
47        read: Vec<SmolStr>,
48        /// Glob patterns of writable paths (empty = deny-all).
49        #[serde(default)]
50        write: Vec<SmolStr>,
51    },
52    /// Invoking Cypher / Locy queries back into the host session.
53    HostQuery {
54        /// If `true`, only read queries are permitted.
55        #[serde(default)]
56        read_only: bool,
57        /// Optional scope-restriction (label / edge-type prefixes).
58        #[serde(default)]
59        scopes: Vec<SmolStr>,
60    },
61    /// KMS access for sign / verify operations.
62    Kms {
63        /// Permitted key identifiers (empty = deny-all).
64        #[serde(default)]
65        key_ids: Vec<SmolStr>,
66    },
67    /// Acquiring named secret handles (opaque to the plugin).
68    Secret {
69        /// Permitted secret identifiers (empty = deny-all).
70        #[serde(default)]
71        ids: Vec<SmolStr>,
72    },
73    /// Explicit lock primitives (`host.lock_nodes`, `host.lock_edges`).
74    Lock {
75        /// Granularity of locks permitted.
76        granularity: LockGranularity,
77    },
78    /// Scoped configuration K/V access (`host.config_get`).
79    Config {
80        /// Patterns of permitted config keys (empty = deny-all).
81        #[serde(default)]
82        keys: Vec<SmolStr>,
83    },
84    /// Per-plugin K/V store (scoped namespace).
85    PluginStorage,
86
87    // ---- Extension surfaces (gate Registrar methods) ----
88    /// Register Cypher scalar functions.
89    ScalarFn,
90    /// Register Cypher aggregate functions.
91    AggregateFn,
92    /// Register Cypher window functions.
93    WindowFn,
94    /// Register Cypher procedures (read-only mode).
95    Procedure,
96    /// Register procedures that may mutate the graph.
97    ProcedureWrites,
98    /// Register procedures that may issue DDL.
99    ProcedureSchema,
100    /// Register administrative procedures.
101    ProcedureDbms,
102    /// Register Locy aggregate functions.
103    LocyAggregate,
104    /// Register Locy predicates (including neural).
105    LocyPredicate,
106    /// Register Locy generator predicates (table-valued, 1:N).
107    LocyGenerator,
108    /// Register physical operators / optimizer rules.
109    Operator,
110    /// Register index kinds.
111    Index,
112    /// Register storage backends by URI scheme.
113    Storage,
114    /// Register graph algorithms.
115    Algorithm,
116    /// Drive the GraphCompute coarse-kernel catalog from a guest algorithm.
117    ///
118    /// Gates the kernel surface (`graph-compute@1`). Orthogonal to
119    /// [`Capability::HostQuery`], which additionally gates the data-read
120    /// `project` kernel: a guest algorithm needs both to project a graph, but
121    /// only `GraphCompute` to run kernels over an already-projected handle
122    /// (GraphCompute proposal §4.6).
123    GraphCompute,
124    /// Register CRDT kinds.
125    Crdt,
126    /// Register session / query lifecycle hooks.
127    Hook,
128    /// Register fine-grained mutation triggers.
129    Trigger,
130    /// Register background / scheduled jobs.
131    BackgroundJob {
132        /// Maximum concurrent invocations of this plugin's jobs.
133        max_concurrent: u32,
134    },
135    /// Register logical (Arrow extension) types.
136    Type,
137    /// Register authentication providers.
138    Auth,
139    /// Register authorization policies.
140    Authz,
141    /// Register collations (sort orders).
142    Collation,
143    /// Register CDC output sinks.
144    Cdc,
145    /// Register catalogs / virtual schemas.
146    Catalog,
147    /// Authority to call meta-procedures (`uni.plugin.declare*`).
148    PluginDeclare,
149
150    // ---- Resource quotas ----
151    /// Maximum wasm linear memory per instance.
152    MemoryBytes(u64),
153    /// Maximum wasmtime fuel per call.
154    FuelPerCall(u64),
155    /// Maximum wall-clock milliseconds per call.
156    WallClockMillisPerCall(u64),
157    /// Maximum concurrent instances in the wasm pool.
158    ConcurrentInstances(u32),
159    /// Maximum total memory across all instances.
160    TotalMemoryBytes(u64),
161    /// Cap on rows yielded by a procedure.
162    MaxResultRows(u64),
163    /// Cap on GraphCompute native-work units per invocation (proposal §12).
164    GraphComputeWork(u64),
165    /// Cap on GraphCompute handle-arena bytes per invocation (proposal §12).
166    GraphComputeArenaBytes(u64),
167}
168
169/// Granularity of lock-capability grants.
170#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
171#[serde(rename_all = "kebab-case")]
172#[non_exhaustive]
173pub enum LockGranularity {
174    /// Per-node locks only.
175    Nodes,
176    /// Per-edge locks only.
177    Edges,
178    /// Both nodes and edges.
179    Both,
180    /// Global (graph-wide) locks.
181    Global,
182}
183
184/// A set of capabilities — declared by manifest, granted by loader.
185///
186/// The *effective* capability set is the intersection of declared and
187/// granted. Registrations attempted without the corresponding capability in
188/// the effective set fail with [`crate::PluginError::CapabilityRequired`].
189#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(transparent)]
191pub struct CapabilitySet {
192    set: BTreeSet<Capability>,
193}
194
195impl CapabilitySet {
196    /// Construct an empty capability set.
197    #[must_use]
198    pub fn new() -> Self {
199        Self::default()
200    }
201
202    /// Construct a capability set from an iterable.
203    #[must_use]
204    pub fn from_iter_of(caps: impl IntoIterator<Item = Capability>) -> Self {
205        Self {
206            set: caps.into_iter().collect(),
207        }
208    }
209
210    /// Construct a capability set from guest-manifest declarations, each of
211    /// which may be a bare name or a structured [`ManifestCapability`].
212    #[must_use]
213    pub fn from_manifest(caps: impl IntoIterator<Item = ManifestCapability>) -> Self {
214        Self::from_iter_of(caps.into_iter().map(|m| m.0))
215    }
216
217    /// Insert a capability; returns `true` if the capability was not already present.
218    pub fn insert(&mut self, cap: Capability) -> bool {
219        self.set.insert(cap)
220    }
221
222    /// Check whether the set contains the given capability (exact equality).
223    #[must_use]
224    pub fn contains(&self, cap: &Capability) -> bool {
225        self.set.contains(cap)
226    }
227
228    /// Check whether the set contains a registration-gating capability.
229    ///
230    /// Match is on the *variant* — `contains_variant(Capability::ScalarFn)`
231    /// returns `true` regardless of any associated data on other variants.
232    /// Useful for registrar gates like "any `BackgroundJob { max_concurrent }`
233    /// is sufficient regardless of the cap."
234    #[must_use]
235    pub fn contains_variant(&self, target: &Capability) -> bool {
236        self.set.iter().any(|c| variant_matches(c, target))
237    }
238
239    /// Intersect this (guest-declared) set with the host-granted `other`,
240    /// returning the effective capability set.
241    ///
242    /// Loaders call `declared.intersect(grants)`, so `self` is the guest
243    /// manifest and `other` is the host ceiling. A guest capability survives
244    /// only if the host grants the same variant, and its **payload is attenuated
245    /// against the host**: for the allow-list variants (`Network`,
246    /// `Filesystem`, `Kms`, `Secret`, `Config`) and `HostQuery`, the effective
247    /// grant permits a resource only if *both* the guest and the host permit it
248    /// — the host is a true ceiling a guest cannot widen. Non-payload variants
249    /// (registration gates, resource quotas) retain the guest value as before.
250    #[must_use]
251    pub fn intersect(&self, other: &Self) -> Self {
252        let mut out = Self::new();
253        for c in &self.set {
254            if other.contains_variant(c) {
255                out.insert(attenuate_to_host(c, other));
256            }
257        }
258        out
259    }
260
261    /// Returns an iterator over the contained capabilities.
262    pub fn iter(&self) -> impl Iterator<Item = &Capability> {
263        self.set.iter()
264    }
265
266    /// Returns the number of distinct capabilities in the set.
267    #[must_use]
268    pub fn len(&self) -> usize {
269        self.set.len()
270    }
271
272    /// Returns `true` if the set is empty.
273    #[must_use]
274    pub fn is_empty(&self) -> bool {
275        self.set.is_empty()
276    }
277}
278
279fn variant_matches(a: &Capability, b: &Capability) -> bool {
280    std::mem::discriminant(a) == std::mem::discriminant(b)
281}
282
283/// Attenuate a guest capability against the host grant (the ceiling).
284///
285/// For the allow-list payload variants and `HostQuery`, returns a capability
286/// whose effective grant is the conjunction of guest and host; for every other
287/// variant, returns the guest capability unchanged (registration gates and
288/// quotas have no allow-list to narrow). See [`CapabilitySet::intersect`].
289fn attenuate_to_host(guest: &Capability, host: &CapabilitySet) -> Capability {
290    match guest {
291        Capability::Network { allow } => Capability::Network {
292            allow: intersect_globs(allow, &host_lists(host, network_allow)),
293        },
294        Capability::Filesystem { read, write } => Capability::Filesystem {
295            read: intersect_globs(read, &host_lists(host, fs_read)),
296            write: intersect_globs(write, &host_lists(host, fs_write)),
297        },
298        Capability::Kms { key_ids } => Capability::Kms {
299            key_ids: intersect_globs(key_ids, &host_lists(host, kms_ids)),
300        },
301        Capability::Secret { ids } => Capability::Secret {
302            ids: intersect_globs(ids, &host_lists(host, secret_ids)),
303        },
304        Capability::Config { keys } => Capability::Config {
305            keys: intersect_globs(keys, &host_lists(host, config_keys)),
306        },
307        Capability::HostQuery { read_only, scopes } => {
308            // `read_only` is restrictive-true: either side may force read-only.
309            // `scopes` empty means "unrestricted", so an empty list on a side
310            // imposes no narrowing (unlike the deny-on-empty allow-lists above).
311            let host_read_only = host.set.iter().any(|c| {
312                matches!(
313                    c,
314                    Capability::HostQuery {
315                        read_only: true,
316                        ..
317                    }
318                )
319            });
320            let host_scopes = host_lists(host, host_query_scopes);
321            let scopes = if scopes.is_empty() {
322                host_scopes
323            } else if host_scopes.is_empty() {
324                scopes.clone()
325            } else {
326                intersect_globs(scopes, &host_scopes)
327            };
328            Capability::HostQuery {
329                read_only: *read_only || host_read_only,
330                scopes,
331            }
332        }
333        // Registration gates and resource quotas carry no allow-list to narrow.
334        other => other.clone(),
335    }
336}
337
338// Per-variant payload extractors used to gather the host ceiling. Each returns
339// the allow-list for capabilities of its variant, `None` otherwise.
340fn network_allow(c: &Capability) -> Option<&[SmolStr]> {
341    match c {
342        Capability::Network { allow } => Some(allow),
343        _ => None,
344    }
345}
346fn fs_read(c: &Capability) -> Option<&[SmolStr]> {
347    match c {
348        Capability::Filesystem { read, .. } => Some(read),
349        _ => None,
350    }
351}
352fn fs_write(c: &Capability) -> Option<&[SmolStr]> {
353    match c {
354        Capability::Filesystem { write, .. } => Some(write),
355        _ => None,
356    }
357}
358fn kms_ids(c: &Capability) -> Option<&[SmolStr]> {
359    match c {
360        Capability::Kms { key_ids } => Some(key_ids),
361        _ => None,
362    }
363}
364fn secret_ids(c: &Capability) -> Option<&[SmolStr]> {
365    match c {
366        Capability::Secret { ids } => Some(ids),
367        _ => None,
368    }
369}
370fn config_keys(c: &Capability) -> Option<&[SmolStr]> {
371    match c {
372        Capability::Config { keys } => Some(keys),
373        _ => None,
374    }
375}
376fn host_query_scopes(c: &Capability) -> Option<&[SmolStr]> {
377    match c {
378        Capability::HostQuery { scopes, .. } => Some(scopes),
379        _ => None,
380    }
381}
382
383/// Union the allow-lists of every host capability matching `extract`'s variant.
384fn host_lists<'a>(
385    host: &'a CapabilitySet,
386    extract: impl Fn(&'a Capability) -> Option<&'a [SmolStr]>,
387) -> Vec<SmolStr> {
388    host.set
389        .iter()
390        .filter_map(extract)
391        .flatten()
392        .cloned()
393        .collect()
394}
395
396/// Intersect two glob allow-lists with each side acting as a ceiling on the
397/// other.
398///
399/// A pattern is kept only when some pattern in the opposite list *subsumes* it
400/// (`wildcard_match(other_pattern, pattern)`), so the result permits a resource
401/// only if both inputs would. Incomparable patterns are dropped (deny — the
402/// safe direction). This is sound for the prefix-glob patterns capability
403/// allow-lists use; it can under-grant only for exotic overlapping-but-
404/// incomparable globs, never over-grant. An empty input yields an empty result
405/// (deny-all), matching the allow-list "empty = deny" convention.
406fn intersect_globs(a: &[SmolStr], b: &[SmolStr]) -> Vec<SmolStr> {
407    let mut out: Vec<SmolStr> = Vec::new();
408    let mut keep = |pat: &SmolStr, ceiling: &[SmolStr]| {
409        if ceiling.iter().any(|q| wildcard_match(q, pat)) && !out.contains(pat) {
410            out.push(pat.clone());
411        }
412    };
413    for pat in a {
414        keep(pat, b);
415    }
416    for pat in b {
417        keep(pat, a);
418    }
419    out
420}
421
422impl Capability {
423    /// True if this is a [`Capability::Network`] grant whose allow-list
424    /// matches `url`.
425    ///
426    /// Used for layer-3 (call-time) attenuation of `uni.http.*` host fns: a
427    /// granted `Network { allow }` only permits URLs matching one of its
428    /// patterns. Non-`Network` capabilities never match.
429    #[must_use]
430    pub fn network_allows(&self, url: &str) -> bool {
431        matches!(self, Capability::Network { allow } if allow.iter().any(|p| wildcard_match(p, url)))
432    }
433
434    /// True if this is a [`Capability::Kms`] grant permitting `key_id`.
435    #[must_use]
436    pub fn kms_allows(&self, key_id: &str) -> bool {
437        matches!(self, Capability::Kms { key_ids } if key_ids.iter().any(|p| wildcard_match(p, key_id)))
438    }
439
440    /// True if this is a [`Capability::Secret`] grant permitting `id`.
441    #[must_use]
442    pub fn secret_allows(&self, id: &str) -> bool {
443        matches!(self, Capability::Secret { ids } if ids.iter().any(|p| wildcard_match(p, id)))
444    }
445
446    /// True if this is a [`Capability::Filesystem`] grant whose `read`
447    /// allow-list matches `path`.
448    ///
449    /// Patterns are matched with `wildcard_match` (path-opaque — `*` and `**`
450    /// both span `/`), which suits the `/data/**`-style grants in use.
451    #[must_use]
452    pub fn filesystem_read_allows(&self, path: &str) -> bool {
453        matches!(self, Capability::Filesystem { read, .. } if read.iter().any(|p| wildcard_match(p, path)))
454    }
455
456    /// True if this is a [`Capability::Filesystem`] grant whose `write`
457    /// allow-list matches `path`.
458    #[must_use]
459    pub fn filesystem_write_allows(&self, path: &str) -> bool {
460        matches!(self, Capability::Filesystem { write, .. } if write.iter().any(|p| wildcard_match(p, path)))
461    }
462}
463
464/// A capability as it appears in a **guest plugin manifest** (WASM / Extism) —
465/// either a bare capability name (`"network"`, `"scalar-fn"`) or a structured
466/// object carrying attenuation patterns
467/// (`{"kind":"network","allow":["https://api.example/**"]}`).
468///
469/// Bare names normalize to their **zero-attenuation** variant — e.g.
470/// `"network"` → `Network { allow: [] }` (deny-all egress) — so a guest must
471/// spell out patterns to gain real host-surface access. This lets guest
472/// manifests opt into the same rich [`Capability`] model the in-process Rhai /
473/// Rust paths use, while staying backward-compatible with manifests that listed
474/// bare capability names.
475#[derive(Clone, Debug)]
476pub struct ManifestCapability(pub Capability);
477
478impl<'de> Deserialize<'de> for ManifestCapability {
479    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
480    where
481        D: serde::Deserializer<'de>,
482    {
483        /// String-or-object shim. A JSON string is a bare name; a map is the
484        /// structured `Capability` form (internally tagged on `kind`).
485        #[derive(Deserialize)]
486        #[serde(untagged)]
487        enum Repr {
488            Bare(String),
489            Full(Capability),
490        }
491
492        let cap = match Repr::deserialize(deserializer)? {
493            Repr::Full(c) => c,
494            Repr::Bare(name) => {
495                // Reconstruct the internally-tagged object `{ "kind": <name> }`
496                // so unit variants and (defaulted-field) structured variants
497                // both round-trip through the canonical `Capability` serde.
498                let tagged = serde_json::json!({ "kind": name });
499                Capability::deserialize(tagged).map_err(serde::de::Error::custom)?
500            }
501        };
502        Ok(ManifestCapability(cap))
503    }
504}
505
506/// Anchored wildcard match where `*` (and `**`) match any run of characters.
507///
508/// Capability attenuation patterns (network URL allow-lists, KMS key ids,
509/// secret ids) are globs over opaque strings, not paths, so `**` is treated
510/// identically to `*` — both match any sequence including `/`. Uses the
511/// standard greedy two-pointer algorithm with backtracking; matching is
512/// anchored at both ends.
513fn wildcard_match(pattern: &str, text: &str) -> bool {
514    let p = pattern.as_bytes();
515    let t = text.as_bytes();
516    let (mut pi, mut ti) = (0usize, 0usize);
517    let mut star: Option<usize> = None;
518    let mut mark = 0usize;
519    while ti < t.len() {
520        if pi < p.len() && p[pi] == b'*' {
521            // Collapse consecutive `*` so `**` behaves like `*`.
522            while pi < p.len() && p[pi] == b'*' {
523                pi += 1;
524            }
525            if pi == p.len() {
526                return true;
527            }
528            star = Some(pi);
529            mark = ti;
530        } else if pi < p.len() && p[pi] == t[ti] {
531            pi += 1;
532            ti += 1;
533        } else if let Some(s) = star {
534            pi = s;
535            mark += 1;
536            ti = mark;
537        } else {
538            return false;
539        }
540    }
541    while pi < p.len() && p[pi] == b'*' {
542        pi += 1;
543    }
544    pi == p.len()
545}
546
547/// Determinism characterization — drives planner caching and hoisting.
548#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(rename_all = "kebab-case")]
550pub enum Determinism {
551    /// Same inputs always produce identical output. Cacheable; hoistable
552    /// from loops. Maps to DataFusion `Volatility::Immutable`.
553    Pure,
554    /// Stable within one session (e.g. `current_user()`). Maps to
555    /// DataFusion `Volatility::Stable`.
556    SessionScoped,
557    /// Non-deterministic (`rand()`, `now()`). Maps to DataFusion
558    /// `Volatility::Volatile`.
559    #[default]
560    Nondeterministic,
561}
562
563/// Declared side-effects of a plugin.
564#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
565#[serde(rename_all = "kebab-case")]
566pub enum SideEffects {
567    /// Reads only. Pure or session-scoped data access.
568    #[default]
569    ReadOnly,
570    /// May write to the graph.
571    Writes,
572    /// May perform external I/O (network, filesystem).
573    ExternalIo,
574}
575
576/// Lifetime scope of a plugin's registrations.
577#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
578#[serde(rename_all = "kebab-case")]
579pub enum Scope {
580    /// Lives until `Uni::remove_plugin` or instance drop. Visible to every
581    /// session. The default for compile-time and WASM plugins.
582    #[default]
583    Instance,
584    /// Lives until the registering `Session` is dropped. Not visible to
585    /// other sessions on the same instance. The default for PyO3 and Lua
586    /// REPL-style plugins.
587    Session,
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn capability_set_default_empty() {
596        let s = CapabilitySet::new();
597        assert!(s.is_empty());
598        assert_eq!(s.len(), 0);
599    }
600
601    #[test]
602    fn capability_set_insert_dedup() {
603        let mut s = CapabilitySet::new();
604        assert!(s.insert(Capability::ScalarFn));
605        assert!(!s.insert(Capability::ScalarFn));
606        assert_eq!(s.len(), 1);
607    }
608
609    #[test]
610    fn intersect_keeps_matching_variants() {
611        let a = CapabilitySet::from_iter_of([
612            Capability::ScalarFn,
613            Capability::Storage,
614            Capability::Network {
615                allow: vec![SmolStr::new("https://api.example/**")],
616            },
617        ]);
618        let b = CapabilitySet::from_iter_of([
619            Capability::ScalarFn,
620            Capability::Network {
621                allow: vec![SmolStr::new("https://api.example/**")],
622            },
623        ]);
624        let inter = a.intersect(&b);
625        assert!(inter.contains(&Capability::ScalarFn));
626        assert!(!inter.contains_variant(&Capability::Storage));
627        assert!(inter.contains_variant(&Capability::Network { allow: vec![] }));
628    }
629
630    /// G-3 (proposal §9): a `GraphComputeWork` grant is a resource quota with no
631    /// allow-list to narrow, so its declared value survives capability
632    /// attenuation verbatim — the host cannot silently shrink it. This is the
633    /// property `WorkBudget::resolve` relies on to treat the grant as
634    /// authoritative and *raise* the ceiling (the §9 revision would be defeated
635    /// if attenuation clamped the grant down).
636    #[test]
637    fn graph_compute_work_grant_survives_attenuation_verbatim() {
638        let big = 5_000_000_000u64; // deliberately above the 1e9 size ceiling
639        let guest = CapabilitySet::from_iter_of([
640            Capability::GraphCompute,
641            Capability::GraphComputeWork(big),
642        ]);
643        let host = CapabilitySet::from_iter_of([
644            Capability::GraphCompute,
645            Capability::GraphComputeWork(big),
646        ]);
647        let inter = guest.intersect(&host);
648        let work = inter.iter().find_map(|c| match c {
649            Capability::GraphComputeWork(w) => Some(*w),
650            _ => None,
651        });
652        assert_eq!(
653            work,
654            Some(big),
655            "the work grant must survive attenuation unchanged"
656        );
657    }
658
659    /// G-6 (proposal §9): the work grant, arena-bytes cap, and wall-clock
660    /// deadline are independent dimensions — attenuating a set carrying all three
661    /// preserves each verbatim and does not let one move another.
662    #[test]
663    fn work_grant_is_independent_of_arena_and_wallclock() {
664        let caps = CapabilitySet::from_iter_of([
665            Capability::GraphComputeWork(1_234),
666            Capability::GraphComputeArenaBytes(9_999),
667            Capability::WallClockMillisPerCall(42),
668        ]);
669        let inter = caps.intersect(&caps);
670        let mut work = None;
671        let mut arena = None;
672        let mut wall = None;
673        for c in inter.iter() {
674            match c {
675                Capability::GraphComputeWork(w) => work = Some(*w),
676                Capability::GraphComputeArenaBytes(b) => arena = Some(*b),
677                Capability::WallClockMillisPerCall(ms) => wall = Some(*ms),
678                _ => {}
679            }
680        }
681        assert_eq!(work, Some(1_234));
682        assert_eq!(
683            arena,
684            Some(9_999),
685            "arena cap must be untouched by the work grant"
686        );
687        assert_eq!(
688            wall,
689            Some(42),
690            "wall-clock must be untouched by the work grant"
691        );
692    }
693
694    /// Regression for the 2026-06-10 review #6: `intersect` must bound the
695    /// guest's allow-list by the host grant (the host is the ceiling), not clone
696    /// the guest's broader list. A guest that declares `**` must not reach hosts
697    /// the grant excludes.
698    #[test]
699    fn intersect_attenuates_network_to_host_ceiling() {
700        let guest = CapabilitySet::from_iter_of([Capability::Network {
701            allow: vec![SmolStr::new("**")],
702        }]);
703        let host = CapabilitySet::from_iter_of([Capability::Network {
704            allow: vec![SmolStr::new("https://api.example/**")],
705        }]);
706
707        // Loaders call declared.intersect(grants) — guest is `self`.
708        let effective = guest.intersect(&host);
709
710        assert!(
711            effective
712                .iter()
713                .any(|c| c.network_allows("https://api.example/v1/x")),
714            "host-permitted URL must remain allowed"
715        );
716        assert!(
717            !effective
718                .iter()
719                .any(|c| c.network_allows("https://evil.example/x")),
720            "guest's `**` must not survive the host ceiling — sandbox escape"
721        );
722    }
723
724    /// A guest narrower than the host keeps its own (narrower) list.
725    #[test]
726    fn intersect_keeps_guest_when_narrower_than_host() {
727        let guest = CapabilitySet::from_iter_of([Capability::Network {
728            allow: vec![SmolStr::new("https://api.example/v1/**")],
729        }]);
730        let host = CapabilitySet::from_iter_of([Capability::Network {
731            allow: vec![SmolStr::new("https://api.example/**")],
732        }]);
733        let effective = guest.intersect(&host);
734        assert!(
735            effective
736                .iter()
737                .any(|c| c.network_allows("https://api.example/v1/x"))
738        );
739        assert!(
740            !effective
741                .iter()
742                .any(|c| c.network_allows("https://api.example/v2/x")),
743            "guest's own restriction must still bind"
744        );
745    }
746
747    /// KMS / Secret / Filesystem payloads attenuate the same way.
748    #[test]
749    fn intersect_attenuates_kms_secret_fs() {
750        let guest = CapabilitySet::from_iter_of([
751            Capability::Kms {
752                key_ids: vec![SmolStr::new("**")],
753            },
754            Capability::Secret {
755                ids: vec![SmolStr::new("**")],
756            },
757            Capability::Filesystem {
758                read: vec![SmolStr::new("**")],
759                write: vec![SmolStr::new("**")],
760            },
761        ]);
762        let host = CapabilitySet::from_iter_of([
763            Capability::Kms {
764                key_ids: vec![SmolStr::new("prod/signing/**")],
765            },
766            Capability::Secret {
767                ids: vec![SmolStr::new("db/**")],
768            },
769            Capability::Filesystem {
770                read: vec![SmolStr::new("/data/**")],
771                write: vec![], // host grants no write
772            },
773        ]);
774        let effective = guest.intersect(&host);
775
776        assert!(effective.iter().any(|c| c.kms_allows("prod/signing/key1")));
777        assert!(!effective.iter().any(|c| c.kms_allows("dev/key")));
778        assert!(effective.iter().any(|c| c.secret_allows("db/password")));
779        assert!(!effective.iter().any(|c| c.secret_allows("kms/root")));
780        // Host grants no write path → no writable path survives.
781        assert!(
782            !effective.iter().any(|c| matches!(
783                c,
784                Capability::Filesystem { write, .. } if !write.is_empty()
785            )),
786            "guest write `**` must not survive an empty host write grant"
787        );
788    }
789
790    #[test]
791    fn contains_variant_ignores_attenuation() {
792        let s = CapabilitySet::from_iter_of([Capability::Network {
793            allow: vec![SmolStr::new("https://x.example/*")],
794        }]);
795        assert!(s.contains_variant(&Capability::Network { allow: vec![] }));
796        // Exact equality requires identical attenuation.
797        assert!(!s.contains(&Capability::Network { allow: vec![] }));
798    }
799
800    #[test]
801    fn determinism_default_is_nondeterministic() {
802        assert_eq!(Determinism::default(), Determinism::Nondeterministic);
803    }
804
805    #[test]
806    fn wildcard_match_basics() {
807        assert!(wildcard_match("*", "anything"));
808        assert!(wildcard_match("**", "any/thing"));
809        assert!(wildcard_match(
810            "https://api.example/**",
811            "https://api.example/v1/x"
812        ));
813        assert!(wildcard_match("exact", "exact"));
814        assert!(!wildcard_match("exact", "other"));
815        assert!(!wildcard_match(
816            "https://api.example/**",
817            "https://evil.example/x"
818        ));
819        assert!(wildcard_match("a*c", "abbbc"));
820        assert!(!wildcard_match("a*c", "abbb"));
821    }
822
823    #[test]
824    fn network_allows_matches_only_network_variant() {
825        let net = Capability::Network {
826            allow: vec![SmolStr::new("https://api.example/**")],
827        };
828        assert!(net.network_allows("https://api.example/v1/data"));
829        assert!(!net.network_allows("https://evil.example/x"));
830        // A non-network capability never grants network access.
831        assert!(!Capability::ScalarFn.network_allows("https://api.example/x"));
832    }
833
834    #[test]
835    fn kms_and_secret_allow_wildcard_and_exact() {
836        let kms = Capability::Kms {
837            key_ids: vec![SmolStr::new("*")],
838        };
839        assert!(kms.kms_allows("signing-key-1"));
840        let secret = Capability::Secret {
841            ids: vec![SmolStr::new("db-password")],
842        };
843        assert!(secret.secret_allows("db-password"));
844        assert!(!secret.secret_allows("other"));
845    }
846
847    #[test]
848    fn manifest_capability_parses_bare_and_structured() {
849        // Bare name → zero-attenuation variant (deny-all egress).
850        let bare: ManifestCapability = serde_json::from_str("\"network\"").unwrap();
851        assert!(matches!(&bare.0, Capability::Network { allow } if allow.is_empty()));
852        assert!(!bare.0.network_allows("https://api.example/x"));
853        // Bare unit variant.
854        let scalar: ManifestCapability = serde_json::from_str("\"scalar-fn\"").unwrap();
855        assert_eq!(scalar.0, Capability::ScalarFn);
856        // Structured object → carries the allow-list.
857        let structured: ManifestCapability =
858            serde_json::from_str(r#"{"kind":"network","allow":["https://api.example/**"]}"#)
859                .unwrap();
860        assert!(structured.0.network_allows("https://api.example/v1/x"));
861        assert!(!structured.0.network_allows("https://evil.example/x"));
862        // A whole manifest list folds into a CapabilitySet.
863        let set = CapabilitySet::from_manifest([bare, scalar, structured]);
864        assert!(set.contains_variant(&Capability::Network { allow: vec![] }));
865        assert!(set.contains(&Capability::ScalarFn));
866    }
867
868    #[test]
869    fn filesystem_allows_read_and_write_separately() {
870        let fs = Capability::Filesystem {
871            read: vec![SmolStr::new("/data/**")],
872            write: vec![SmolStr::new("/tmp/out/**")],
873        };
874        assert!(fs.filesystem_read_allows("/data/x/y.txt"));
875        assert!(!fs.filesystem_read_allows("/etc/passwd"));
876        assert!(fs.filesystem_write_allows("/tmp/out/log"));
877        // read grant does not imply write grant for the same path
878        assert!(!fs.filesystem_write_allows("/data/x/y.txt"));
879        // a non-filesystem capability never matches
880        assert!(!Capability::ScalarFn.filesystem_read_allows("/data/x"));
881    }
882}