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