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}