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 /// Regression for the 2026-06-10 review #6: `intersect` must bound the
631 /// guest's allow-list by the host grant (the host is the ceiling), not clone
632 /// the guest's broader list. A guest that declares `**` must not reach hosts
633 /// the grant excludes.
634 #[test]
635 fn intersect_attenuates_network_to_host_ceiling() {
636 let guest = CapabilitySet::from_iter_of([Capability::Network {
637 allow: vec![SmolStr::new("**")],
638 }]);
639 let host = CapabilitySet::from_iter_of([Capability::Network {
640 allow: vec![SmolStr::new("https://api.example/**")],
641 }]);
642
643 // Loaders call declared.intersect(grants) — guest is `self`.
644 let effective = guest.intersect(&host);
645
646 assert!(
647 effective
648 .iter()
649 .any(|c| c.network_allows("https://api.example/v1/x")),
650 "host-permitted URL must remain allowed"
651 );
652 assert!(
653 !effective
654 .iter()
655 .any(|c| c.network_allows("https://evil.example/x")),
656 "guest's `**` must not survive the host ceiling — sandbox escape"
657 );
658 }
659
660 /// A guest narrower than the host keeps its own (narrower) list.
661 #[test]
662 fn intersect_keeps_guest_when_narrower_than_host() {
663 let guest = CapabilitySet::from_iter_of([Capability::Network {
664 allow: vec![SmolStr::new("https://api.example/v1/**")],
665 }]);
666 let host = CapabilitySet::from_iter_of([Capability::Network {
667 allow: vec![SmolStr::new("https://api.example/**")],
668 }]);
669 let effective = guest.intersect(&host);
670 assert!(
671 effective
672 .iter()
673 .any(|c| c.network_allows("https://api.example/v1/x"))
674 );
675 assert!(
676 !effective
677 .iter()
678 .any(|c| c.network_allows("https://api.example/v2/x")),
679 "guest's own restriction must still bind"
680 );
681 }
682
683 /// KMS / Secret / Filesystem payloads attenuate the same way.
684 #[test]
685 fn intersect_attenuates_kms_secret_fs() {
686 let guest = CapabilitySet::from_iter_of([
687 Capability::Kms {
688 key_ids: vec![SmolStr::new("**")],
689 },
690 Capability::Secret {
691 ids: vec![SmolStr::new("**")],
692 },
693 Capability::Filesystem {
694 read: vec![SmolStr::new("**")],
695 write: vec![SmolStr::new("**")],
696 },
697 ]);
698 let host = CapabilitySet::from_iter_of([
699 Capability::Kms {
700 key_ids: vec![SmolStr::new("prod/signing/**")],
701 },
702 Capability::Secret {
703 ids: vec![SmolStr::new("db/**")],
704 },
705 Capability::Filesystem {
706 read: vec![SmolStr::new("/data/**")],
707 write: vec![], // host grants no write
708 },
709 ]);
710 let effective = guest.intersect(&host);
711
712 assert!(effective.iter().any(|c| c.kms_allows("prod/signing/key1")));
713 assert!(!effective.iter().any(|c| c.kms_allows("dev/key")));
714 assert!(effective.iter().any(|c| c.secret_allows("db/password")));
715 assert!(!effective.iter().any(|c| c.secret_allows("kms/root")));
716 // Host grants no write path → no writable path survives.
717 assert!(
718 !effective.iter().any(|c| matches!(
719 c,
720 Capability::Filesystem { write, .. } if !write.is_empty()
721 )),
722 "guest write `**` must not survive an empty host write grant"
723 );
724 }
725
726 #[test]
727 fn contains_variant_ignores_attenuation() {
728 let s = CapabilitySet::from_iter_of([Capability::Network {
729 allow: vec![SmolStr::new("https://x.example/*")],
730 }]);
731 assert!(s.contains_variant(&Capability::Network { allow: vec![] }));
732 // Exact equality requires identical attenuation.
733 assert!(!s.contains(&Capability::Network { allow: vec![] }));
734 }
735
736 #[test]
737 fn determinism_default_is_nondeterministic() {
738 assert_eq!(Determinism::default(), Determinism::Nondeterministic);
739 }
740
741 #[test]
742 fn wildcard_match_basics() {
743 assert!(wildcard_match("*", "anything"));
744 assert!(wildcard_match("**", "any/thing"));
745 assert!(wildcard_match(
746 "https://api.example/**",
747 "https://api.example/v1/x"
748 ));
749 assert!(wildcard_match("exact", "exact"));
750 assert!(!wildcard_match("exact", "other"));
751 assert!(!wildcard_match(
752 "https://api.example/**",
753 "https://evil.example/x"
754 ));
755 assert!(wildcard_match("a*c", "abbbc"));
756 assert!(!wildcard_match("a*c", "abbb"));
757 }
758
759 #[test]
760 fn network_allows_matches_only_network_variant() {
761 let net = Capability::Network {
762 allow: vec![SmolStr::new("https://api.example/**")],
763 };
764 assert!(net.network_allows("https://api.example/v1/data"));
765 assert!(!net.network_allows("https://evil.example/x"));
766 // A non-network capability never grants network access.
767 assert!(!Capability::ScalarFn.network_allows("https://api.example/x"));
768 }
769
770 #[test]
771 fn kms_and_secret_allow_wildcard_and_exact() {
772 let kms = Capability::Kms {
773 key_ids: vec![SmolStr::new("*")],
774 };
775 assert!(kms.kms_allows("signing-key-1"));
776 let secret = Capability::Secret {
777 ids: vec![SmolStr::new("db-password")],
778 };
779 assert!(secret.secret_allows("db-password"));
780 assert!(!secret.secret_allows("other"));
781 }
782
783 #[test]
784 fn manifest_capability_parses_bare_and_structured() {
785 // Bare name → zero-attenuation variant (deny-all egress).
786 let bare: ManifestCapability = serde_json::from_str("\"network\"").unwrap();
787 assert!(matches!(&bare.0, Capability::Network { allow } if allow.is_empty()));
788 assert!(!bare.0.network_allows("https://api.example/x"));
789 // Bare unit variant.
790 let scalar: ManifestCapability = serde_json::from_str("\"scalar-fn\"").unwrap();
791 assert_eq!(scalar.0, Capability::ScalarFn);
792 // Structured object → carries the allow-list.
793 let structured: ManifestCapability =
794 serde_json::from_str(r#"{"kind":"network","allow":["https://api.example/**"]}"#)
795 .unwrap();
796 assert!(structured.0.network_allows("https://api.example/v1/x"));
797 assert!(!structured.0.network_allows("https://evil.example/x"));
798 // A whole manifest list folds into a CapabilitySet.
799 let set = CapabilitySet::from_manifest([bare, scalar, structured]);
800 assert!(set.contains_variant(&Capability::Network { allow: vec![] }));
801 assert!(set.contains(&Capability::ScalarFn));
802 }
803
804 #[test]
805 fn filesystem_allows_read_and_write_separately() {
806 let fs = Capability::Filesystem {
807 read: vec![SmolStr::new("/data/**")],
808 write: vec![SmolStr::new("/tmp/out/**")],
809 };
810 assert!(fs.filesystem_read_allows("/data/x/y.txt"));
811 assert!(!fs.filesystem_read_allows("/etc/passwd"));
812 assert!(fs.filesystem_write_allows("/tmp/out/log"));
813 // read grant does not imply write grant for the same path
814 assert!(!fs.filesystem_write_allows("/data/x/y.txt"));
815 // a non-filesystem capability never matches
816 assert!(!Capability::ScalarFn.filesystem_read_allows("/data/x"));
817 }
818}