Skip to main content

capsule_runtime/
spawn_envelope.rs

1//! The capability ceiling a spawned capsule must fit inside, and the comparison that refuses one
2//! that does not.
3//!
4//! A delegating capsule hands its child a manifest of the child's own, and the runtime lowers that
5//! manifest independently of the parent's. Without a comparison between the two, delegation is a
6//! privilege-escalation ladder: a capsule granted one network host, no shell and a narrow
7//! filesystem scope can spawn a child declaring unrestricted network, a full shell allow-list and
8//! the whole workdir. [`SpawnEnvelope::contains`] is the comparison, and it names the axis and the
9//! exact entry that exceeded.
10//!
11//! **Refusing, not narrowing.** This is the opposite of
12//! [`crate::network_policy::ToolCapabilityGrant::derive`], which clamps a per-artifact allow-list
13//! to the capsule ceiling and reports the dropped entries as `W-SEC-007`. An artifact's declared
14//! capabilities are its *author's* wish list, so keeping the covered subset is right — the
15//! operator did not write them. A child capsule's manifest is the operator's own declaration, so a
16//! mismatch is a mistake in something they control. A refusal is auditable and fixable; a silent
17//! narrowing produces a child that runs and mysteriously cannot do its job.
18//!
19//! **One subset predicate.** Both paths decide "is this network entry covered?" with
20//! [`crate::network_policy::NetworkAllowRule::covers`], which errs toward deny on ambiguity — a
21//! bare `example.com` is not covered by a ceiling of `https://example.com`, because the bare form
22//! spans both schemes and every port. There is one subset rule in the workspace, not two, and the
23//! shared case table in this module's tests drives both call sites and fails if their verdicts
24//! ever diverge.
25
26use std::collections::BTreeSet;
27use std::fmt;
28use std::path::{Component, Path};
29
30use murmur_artifact::{ContainmentClass, RuntimeManifest};
31
32use crate::network_policy::{validate_filesystem_scope, NetworkAllowRule};
33
34/// Every axis two capsules are compared on, and the manifest key each one renders as in a refusal.
35///
36/// One variant per *declaration site*, not per lowered field: `unix_sockets` and `workdir_exec`
37/// are separate axes from the allow-list and the scope they are declared beside, because each is a
38/// real widening on its own. `unix_sockets: true` reaches a local daemon socket the parent cannot,
39/// and `workdir_exec: true` makes `capabilities.shell.allow` unenforceable for anything inside the
40/// workdir. Comparing the list while ignoring the boolean beside it would make the axis hollow.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum EnvelopeAxis {
43    NetworkAllow,
44    UnixSockets,
45    PeerFetchAllow,
46    ShellAllow,
47    SpawnAllow,
48    EnvAllow,
49    FilesystemScope,
50    WorkdirExec,
51    StateStore,
52    Containment,
53}
54
55impl EnvelopeAxis {
56    /// The manifest key an operator edits to fix a refusal on this axis, spelled exactly as it
57    /// appears in `murmur.yaml`.
58    pub fn manifest_key(self) -> &'static str {
59        match self {
60            Self::NetworkAllow => "capabilities.network.allow",
61            Self::UnixSockets => "capabilities.network.unix_sockets",
62            Self::PeerFetchAllow => "capabilities.peer_fetch.allow",
63            Self::ShellAllow => "capabilities.shell.allow",
64            Self::SpawnAllow => "capabilities.spawn.allow",
65            Self::EnvAllow => "capabilities.env.allow",
66            Self::FilesystemScope => "capabilities.filesystem.scope",
67            Self::WorkdirExec => "capabilities.filesystem.workdir_exec",
68            Self::StateStore => "capabilities.state.store",
69            Self::Containment => "capabilities.containment",
70        }
71    }
72}
73
74/// The first axis a child exceeded its parent on, with the exact declaration that did it.
75///
76/// Carries one offending entry rather than every difference: the operator's next action is to edit
77/// one line, and a comparison that stops at the first failure cannot be read as an exhaustive
78/// audit of what else would have been refused.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct EnvelopeViolation {
81    pub axis: EnvelopeAxis,
82    /// The child declaration that exceeded, rendered as the operator wrote it: one allow-list
83    /// entry, one filesystem scope, one store name, one containment class, or the literal `true`
84    /// for a boolean widening.
85    ///
86    /// Empty on the one axis where the offending declaration is an *absence*: a child that
87    /// declares no `capabilities.filesystem.scope` under a parent that declares one reaches the
88    /// whole workdir, which is wider than the parent's scope.
89    pub entry: String,
90    /// The parent's own value, on the two axes that hold a single value rather than a list: the
91    /// parent's `capabilities.filesystem.scope` and its containment floor. `None` on every list
92    /// axis and on the two boolean ones, where the parent's value is implied by the refusal itself
93    /// — it does not hold the entry, and the boolean is `false`.
94    pub parent_entry: Option<String>,
95}
96
97impl fmt::Display for EnvelopeViolation {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        let key = self.axis.manifest_key();
100        let parent = self.parent_entry.as_deref().unwrap_or_default();
101        match self.axis {
102            EnvelopeAxis::Containment => write!(
103                f,
104                "{key}: the child declares containment '{}', below its parent's floor '{parent}' \
105                 — a spawned capsule's containment floor may only rise",
106                self.entry,
107            ),
108            EnvelopeAxis::FilesystemScope if self.entry.is_empty() => write!(
109                f,
110                "{key}: the child declares no scope and would reach the whole workdir, which its \
111                 parent's scope '{parent}' does not cover — a spawned capsule can never hold more \
112                 capability than the capsule that spawned it",
113            ),
114            EnvelopeAxis::FilesystemScope => write!(
115                f,
116                "{key}: the child declares '{}', which its parent's scope '{parent}' does not \
117                 cover — a spawned capsule can never hold more capability than the capsule that \
118                 spawned it",
119                self.entry,
120            ),
121            _ => write!(
122                f,
123                "{key}: the child declares '{}', which its parent does not hold — a spawned \
124                 capsule can never hold more capability than the capsule that spawned it",
125                self.entry,
126            ),
127        }
128    }
129}
130
131/// One capsule's declared capability surface, in the shape the spawn comparison needs.
132///
133/// Narrower than [`crate::types::CapabilityPolicy`] on most axes, and wider on one. Narrower:
134/// limits, resources and the `shell_strip_env`/`baseline_env`/`interpreter_runtime` grants are
135/// bounds and plumbing rather than reach, so a child that sets them differently is not
136/// escalating. Wider: [`Self::state_stores`] is per *artifact*, so it is not on the capsule-wide
137/// policy at all, and a child whose artifacts open a store the parent's do not is reaching durable
138/// state outside every workdir that its parent cannot see.
139#[derive(Debug, Clone, PartialEq, Eq, Default)]
140pub struct SpawnEnvelope {
141    /// `capabilities.network.allow`, unparsed. Held as declared so a refusal quotes the operator's
142    /// own line; parsing happens inside [`Self::contains`], where an unparseable entry is
143    /// uncovered rather than an error (`validate_capability_policy` refuses it again, with the
144    /// parse error, if a spawn ever gets as far as staging).
145    pub network_allow: Vec<String>,
146    pub unix_sockets_allowed: bool,
147    pub peer_fetch_allow: Vec<String>,
148    pub shell_allow: Vec<String>,
149    /// `capabilities.spawn.allow`. Compared as an axis like any other — a child that can spawn a
150    /// name its parent cannot has widened the delegation graph, not just its own reach. This is a
151    /// different question from the name check `mur-roost` runs first, which asks whether *this*
152    /// child is a name the parent may spawn at all.
153    pub spawn_allow: Vec<String>,
154    pub env_allow: Vec<String>,
155    pub filesystem_scope: Option<String>,
156    pub workdir_exec_allowed: bool,
157    /// Every store name this capsule's artifacts would open, with the capsule-name default already
158    /// applied by [`crate::state_store::resolve_store_name`] — so two capsules that both declare a
159    /// bare `capabilities.state` compare as the different directories they actually open.
160    ///
161    /// A set rather than a list: the axis is "which stores are opened", and an artifact list that
162    /// names one store twice is not a wider grant.
163    pub state_stores: BTreeSet<String>,
164    /// `capabilities.containment`. The one axis where more is *safer*, so it is the one axis
165    /// compared with `>=` rather than by containment of a set — see [`Self::contains`].
166    pub containment_floor: ContainmentClass,
167}
168
169impl SpawnEnvelope {
170    /// Lower a capsule's manifest into the envelope it both holds and imposes.
171    ///
172    /// Every capsule-wide axis is read off [`crate::types::capability_policy_from_runtime_manifest`]
173    /// rather than off the manifest again, so the envelope compared here and the
174    /// [`crate::types::CapabilityPolicy`] the session is actually staged with cannot read the same
175    /// manifest two different ways.
176    pub fn from_runtime_manifest(manifest: &RuntimeManifest) -> Self {
177        let policy = crate::types::capability_policy_from_runtime_manifest(manifest);
178        Self {
179            network_allow: policy.network_allow,
180            unix_sockets_allowed: policy.unix_sockets_allowed,
181            peer_fetch_allow: policy.peer_fetch_allow,
182            shell_allow: policy.shell_allow,
183            spawn_allow: policy.spawn_allow,
184            env_allow: policy.env_allow,
185            filesystem_scope: policy.filesystem_scope,
186            workdir_exec_allowed: policy.workdir_exec_allowed,
187            state_stores: declared_state_stores(manifest),
188            containment_floor: policy.containment_floor,
189        }
190    }
191
192    /// `Ok(())` when `child` holds no more capability than `self` on any axis, otherwise the first
193    /// axis it exceeded.
194    ///
195    /// Containment is the exception to "the child must be a subset": a floor is a requirement
196    /// rather than a grant, so a child may only ever raise it. `ContainmentClass` lists its
197    /// variants weakest-first and derives `Ord` for exactly this comparison.
198    ///
199    /// Axis order is the order a refusal reports in, and matters only when a child exceeds on more
200    /// than one: it is the table order in the reference docs, so the page and the daemon agree
201    /// about which refusal an operator sees first.
202    pub fn contains(&self, child: &Self) -> Result<(), EnvelopeViolation> {
203        if let Some(entry) = first_uncovered_host(&self.network_allow, &child.network_allow) {
204            return Err(violation(EnvelopeAxis::NetworkAllow, entry));
205        }
206        if child.unix_sockets_allowed && !self.unix_sockets_allowed {
207            return Err(violation(EnvelopeAxis::UnixSockets, "true".to_string()));
208        }
209        if let Some(entry) = first_uncovered_host(&self.peer_fetch_allow, &child.peer_fetch_allow) {
210            return Err(violation(EnvelopeAxis::PeerFetchAllow, entry));
211        }
212        if let Some(entry) = first_absent(&self.shell_allow, &child.shell_allow) {
213            return Err(violation(EnvelopeAxis::ShellAllow, entry));
214        }
215        if let Some(entry) = first_absent(&self.spawn_allow, &child.spawn_allow) {
216            return Err(violation(EnvelopeAxis::SpawnAllow, entry));
217        }
218        if let Some(entry) = first_absent(&self.env_allow, &child.env_allow) {
219            return Err(violation(EnvelopeAxis::EnvAllow, entry));
220        }
221        // An unset parent scope is the whole workdir and covers anything. A set one covers only a
222        // child scope at or beneath it — and never a child that declares none, because that child
223        // reaches the whole workdir too.
224        if let Some(parent_scope) = self.filesystem_scope.as_deref() {
225            let child_scope = child.filesystem_scope.as_deref().unwrap_or_default();
226            if !scope_covers(parent_scope, child_scope) {
227                return Err(EnvelopeViolation {
228                    axis: EnvelopeAxis::FilesystemScope,
229                    entry: child_scope.to_string(),
230                    parent_entry: Some(parent_scope.to_string()),
231                });
232            }
233        }
234        if child.workdir_exec_allowed && !self.workdir_exec_allowed {
235            return Err(violation(EnvelopeAxis::WorkdirExec, "true".to_string()));
236        }
237        if let Some(store) = child
238            .state_stores
239            .difference(&self.state_stores)
240            .next()
241            .cloned()
242        {
243            return Err(violation(EnvelopeAxis::StateStore, store));
244        }
245        if child.containment_floor < self.containment_floor {
246            return Err(EnvelopeViolation {
247                axis: EnvelopeAxis::Containment,
248                entry: child.containment_floor.to_string(),
249                parent_entry: Some(self.containment_floor.to_string()),
250            });
251        }
252        Ok(())
253    }
254}
255
256fn violation(axis: EnvelopeAxis, entry: String) -> EnvelopeViolation {
257    EnvelopeViolation {
258        axis,
259        entry,
260        parent_entry: None,
261    }
262}
263
264/// Every store name a capsule's artifacts would open, resolved through the same helper the grant
265/// lowering and `mur run --explain-scope` resolve through, so no two of them can name different
266/// directories for one declaration.
267///
268/// The capsule's *own* `capabilities.state` is not read: a capsule-wide declaration grants no store
269/// to anything (see [`crate::types::CapabilityPolicy::state_declared`]), so treating it as reach
270/// would refuse spawns over a key that opens nothing.
271fn declared_state_stores(manifest: &RuntimeManifest) -> BTreeSet<String> {
272    manifest
273        .artifacts
274        .iter()
275        .filter_map(|artifact| artifact.capabilities.as_ref())
276        .filter_map(|capabilities| capabilities.state.as_ref())
277        .map(|state| crate::state_store::resolve_store_name(state, &manifest.name))
278        .collect()
279}
280
281/// The first `candidate` entry no `ceiling` entry covers, judged by
282/// [`NetworkAllowRule::covers`] — the same predicate that clamps a per-artifact allow-list.
283///
284/// An entry neither side can parse counts as uncovered, in both directions: a malformed child entry
285/// is the one named in the refusal, and a malformed parent entry grants nothing. Both fail closed,
286/// and `validate_capability_policy` refuses the entry again with its parse error if a spawn reaches
287/// staging.
288fn first_uncovered_host(ceiling: &[String], candidate: &[String]) -> Option<String> {
289    let ceiling_rules: Vec<NetworkAllowRule> = ceiling
290        .iter()
291        .filter_map(|entry| NetworkAllowRule::parse(entry).ok())
292        .collect();
293    candidate
294        .iter()
295        .find(|entry| match NetworkAllowRule::parse(entry) {
296            Ok(rule) => !ceiling_rules
297                .iter()
298                .any(|ceiling_rule| ceiling_rule.covers(&rule)),
299            Err(_) => true,
300        })
301        .cloned()
302}
303
304/// The first `candidate` name absent from `ceiling`. Plain string membership: a shell binary name,
305/// a capsule name and an environment variable name are identifiers, with no covering relation
306/// between two different ones.
307fn first_absent(ceiling: &[String], candidate: &[String]) -> Option<String> {
308    candidate
309        .iter()
310        .find(|entry| !ceiling.contains(entry))
311        .cloned()
312}
313
314/// Whether a directory tree rooted at `parent` contains the one rooted at `child`.
315///
316/// Compared as resolved path components rather than as strings, so `data/../other` is judged as
317/// `other` — outside a parent scoped to `data` — rather than as a string that happens to start with
318/// `data`. An empty component list is the workdir root, which contains everything, and is what both
319/// `.` and an undeclared scope resolve to.
320///
321/// A scope [`validate_filesystem_scope`] refuses is contained by nothing, including by itself: the
322/// launch would refuse it anyway, and a comparison that admitted it would be deciding reach from a
323/// path it cannot resolve.
324fn scope_covers(parent: &str, child: &str) -> bool {
325    match (scope_components(parent), scope_components(child)) {
326        (Some(parent), Some(child)) => child.starts_with(&parent),
327        _ => false,
328    }
329}
330
331fn scope_components(scope: &str) -> Option<Vec<String>> {
332    if scope.is_empty() {
333        return Some(Vec::new());
334    }
335    validate_filesystem_scope(scope).ok()?;
336    let mut components = Vec::new();
337    for component in Path::new(scope).components() {
338        match component {
339            Component::CurDir => {}
340            Component::Normal(part) => components.push(part.to_string_lossy().into_owned()),
341            // `validate_filesystem_scope` has already established that no `..` escapes the
342            // workdir, so every one of them has a component to remove.
343            Component::ParentDir => {
344                components.pop();
345            }
346            Component::Prefix(_) | Component::RootDir => return None,
347        }
348    }
349    Some(components)
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::network_policy::{parse_network_allow_rules, ToolCapabilityGrant};
356
357    /// `(ceiling entry, candidate entry, the ceiling covers the candidate)`.
358    ///
359    /// One table, two consumers: the artifact-narrowing path and the spawn-envelope path. The
360    /// asymmetric pair in the middle is the whole reason the table exists — a bare host spans both
361    /// schemes and every port, so it is broader than a scheme-bound rule and is *not* covered by
362    /// one, while the reverse is.
363    const COVERAGE_CASES: &[(&str, &str, bool)] = &[
364        ("example.com", "https://example.com", true),
365        ("https://example.com", "example.com", false),
366        ("example.com", "example.com", true),
367        ("https://example.com", "https://example.com", true),
368        ("https://example.com", "http://example.com", false),
369        ("example.com:8443", "example.com:8443", true),
370        ("example.com:8443", "example.com:9000", false),
371        ("example.com", "other.example.com", false),
372    ];
373
374    fn envelope(capabilities_yaml: &str) -> SpawnEnvelope {
375        manifest_envelope("cap", capabilities_yaml)
376    }
377
378    fn manifest_envelope(name: &str, capabilities_yaml: &str) -> SpawnEnvelope {
379        let manifest = RuntimeManifest::from_yaml_str(&format!(
380            "name: {name}\nversion: 0.1.0\n{capabilities_yaml}"
381        ))
382        .expect("manifest fixture must parse");
383        SpawnEnvelope::from_runtime_manifest(&manifest)
384    }
385
386    fn network_envelope(entries: &[&str]) -> SpawnEnvelope {
387        SpawnEnvelope {
388            network_allow: entries.iter().map(|entry| entry.to_string()).collect(),
389            ..SpawnEnvelope::default()
390        }
391    }
392
393    /// The subset decision cannot drift. Replacing either call site with a second, independently
394    /// written host/scheme/port comparison makes this fail — the two verdicts are asserted equal to
395    /// each other as well as to the expected value, so a divergence is a failure even if the new
396    /// implementation is the "right" one.
397    #[test]
398    fn the_narrowing_path_and_the_spawn_path_share_one_subset_rule() {
399        for (ceiling, candidate, expected) in COVERAGE_CASES {
400            let ceiling_rules = parse_network_allow_rules(&[ceiling.to_string()])
401                .expect("ceiling fixture must parse");
402            let capabilities = murmur_artifact::Capabilities {
403                network: Some(murmur_artifact::NetworkCapabilities {
404                    allow: vec![candidate.to_string()],
405                    unix_sockets: false,
406                }),
407                peer_fetch: None,
408                filesystem: None,
409                shell: None,
410                spawn: None,
411                env: None,
412                limits: None,
413                resources: None,
414                state: None,
415                task_io: None,
416                conversation: None,
417                plan: None,
418                containment: None,
419            };
420            let narrowed = ToolCapabilityGrant::derive(Some(&capabilities), &ceiling_rules, "cap")
421                .expect("candidate fixture must parse");
422            let narrowing_verdict = narrowed.dropped_network_entries.is_empty();
423
424            let spawn_verdict = network_envelope(&[ceiling])
425                .contains(&network_envelope(&[candidate]))
426                .is_ok();
427
428            assert_eq!(
429                narrowing_verdict, spawn_verdict,
430                "'{ceiling}' vs '{candidate}': the narrowing path and the spawn path disagree",
431            );
432            assert_eq!(
433                spawn_verdict, *expected,
434                "'{ceiling}' vs '{candidate}': expected covered={expected}",
435            );
436        }
437    }
438
439    #[test]
440    fn a_network_host_the_parent_does_not_hold_is_refused_by_name() {
441        let parent = envelope("capabilities:\n  network:\n    allow: [registry.internal]\n");
442        let child = envelope("capabilities:\n  network:\n    allow: [api.example.com]\n");
443
444        let violation = parent.contains(&child).unwrap_err();
445
446        assert_eq!(violation.axis, EnvelopeAxis::NetworkAllow);
447        assert_eq!(violation.entry, "api.example.com");
448        let message = violation.to_string();
449        assert!(message.contains("capabilities.network.allow"), "{message}");
450        assert!(message.contains("api.example.com"), "{message}");
451        assert!(message.contains("its parent does not hold"), "{message}");
452    }
453
454    /// One case per axis, each child exceeding on exactly one of them: the axis a refusal names is
455    /// the axis that was exceeded, never a neighbour.
456    #[test]
457    fn every_axis_refuses_naming_its_own_manifest_key_and_entry() {
458        let parent_yaml = "capabilities:\n  \
459             network:\n    allow: [registry.internal]\n    unix_sockets: false\n  \
460             peer_fetch:\n    allow: [peer.internal]\n  \
461             shell:\n    allow: [git]\n  \
462             spawn:\n    allow: [worker-a]\n  \
463             env:\n    allow: [HOME]\n  \
464             filesystem:\n    scope: data\n    workdir_exec: false\n";
465        let parent = envelope(parent_yaml);
466
467        let cases: &[(&str, EnvelopeAxis, &str)] = &[
468            (
469                "capabilities:\n  network:\n    allow: [api.example.com]\n",
470                EnvelopeAxis::NetworkAllow,
471                "api.example.com",
472            ),
473            (
474                "capabilities:\n  network:\n    unix_sockets: true\n",
475                EnvelopeAxis::UnixSockets,
476                "true",
477            ),
478            (
479                "capabilities:\n  peer_fetch:\n    allow: [other.internal]\n",
480                EnvelopeAxis::PeerFetchAllow,
481                "other.internal",
482            ),
483            (
484                "capabilities:\n  shell:\n    allow: [curl]\n",
485                EnvelopeAxis::ShellAllow,
486                "curl",
487            ),
488            (
489                "capabilities:\n  spawn:\n    allow: [worker-b]\n",
490                EnvelopeAxis::SpawnAllow,
491                "worker-b",
492            ),
493            (
494                "capabilities:\n  env:\n    allow: [GITHUB_TOKEN]\n",
495                EnvelopeAxis::EnvAllow,
496                "GITHUB_TOKEN",
497            ),
498            (
499                "capabilities:\n  filesystem:\n    scope: other\n",
500                EnvelopeAxis::FilesystemScope,
501                "other",
502            ),
503            (
504                "capabilities:\n  filesystem:\n    scope: data/../other\n",
505                EnvelopeAxis::FilesystemScope,
506                "data/../other",
507            ),
508            (
509                "capabilities:\n  filesystem:\n    scope: data\n    workdir_exec: true\n",
510                EnvelopeAxis::WorkdirExec,
511                "true",
512            ),
513        ];
514
515        for (child_yaml, axis, entry) in cases {
516            let violation = parent.contains(&envelope(child_yaml)).unwrap_err();
517            assert_eq!(violation.axis, *axis, "child: {child_yaml}");
518            assert_eq!(violation.entry, *entry, "child: {child_yaml}");
519            let message = violation.to_string();
520            assert!(message.contains(axis.manifest_key()), "{message}");
521            assert!(message.contains(entry), "{message}");
522        }
523    }
524
525    /// A parent that scoped itself to a subtree is not widened by a child that declares nothing:
526    /// an undeclared scope preopens the whole workdir.
527    #[test]
528    fn a_child_declaring_no_scope_is_refused_under_a_scoped_parent() {
529        let parent = envelope("capabilities:\n  filesystem:\n    scope: data\n");
530        let child = envelope("capabilities:\n  env:\n    allow: []\n");
531
532        let violation = parent.contains(&child).unwrap_err();
533
534        assert_eq!(violation.axis, EnvelopeAxis::FilesystemScope);
535        assert!(violation.entry.is_empty());
536        let message = violation.to_string();
537        assert!(
538            message.contains("capabilities.filesystem.scope"),
539            "{message}"
540        );
541        assert!(message.contains("declares no scope"), "{message}");
542        assert!(message.contains("'data'"), "{message}");
543    }
544
545    #[test]
546    fn a_scope_at_or_beneath_the_parents_is_within_the_envelope() {
547        let parent = envelope("capabilities:\n  filesystem:\n    scope: data\n");
548
549        for scope in ["data", "data/in", "data/in/../in"] {
550            let child = envelope(&format!(
551                "capabilities:\n  filesystem:\n    scope: {scope}\n"
552            ));
553            assert!(parent.contains(&child).is_ok(), "scope '{scope}'");
554        }
555    }
556
557    /// A parent that declares no scope holds the whole workdir, so every child scope is beneath it.
558    #[test]
559    fn an_unscoped_parent_covers_any_child_scope() {
560        let parent = envelope("capabilities:\n  env:\n    allow: []\n");
561        let child = envelope("capabilities:\n  filesystem:\n    scope: data\n");
562
563        assert!(parent.contains(&child).is_ok());
564    }
565
566    /// A store is granted per artifact and defaults to the declaring capsule's own name, so two
567    /// capsules that both declare a bare `capabilities.state` open two different directories.
568    #[test]
569    fn a_state_store_the_parents_artifacts_do_not_open_is_refused() {
570        let with_state = |name: &str, store: &str| {
571            manifest_envelope(
572                name,
573                &format!(
574                    "artifacts:\n  - name: writer\n    version: 0.1.0\n    runtime: tool\n    \
575                     capabilities:\n      state:\n        store: {store}\n"
576                ),
577            )
578        };
579        let parent = with_state("parent", "parent-notes");
580        let child = with_state("child", "child-notes");
581
582        let violation = parent.contains(&child).unwrap_err();
583
584        assert_eq!(violation.axis, EnvelopeAxis::StateStore);
585        assert_eq!(violation.entry, "child-notes");
586        assert!(violation.to_string().contains("capabilities.state.store"));
587
588        let shared = with_state("child", "parent-notes");
589        assert!(parent.contains(&shared).is_ok());
590    }
591
592    /// An undeclared `store:` resolves to the declaring capsule's name, which is what makes two
593    /// bare declarations compare as the different directories they open.
594    #[test]
595    fn an_undeclared_store_resolves_to_the_capsule_name() {
596        let envelope = manifest_envelope(
597            "notes-capsule",
598            "artifacts:\n  - name: writer\n    version: 0.1.0\n    runtime: tool\n    \
599             capabilities:\n      state: {}\n",
600        );
601
602        assert_eq!(
603            envelope.state_stores.iter().collect::<Vec<_>>(),
604            vec!["notes-capsule"]
605        );
606    }
607
608    /// The one axis where more is safer. A floor may rise, and only a floor that *falls* is an
609    /// escalation.
610    #[test]
611    fn a_containment_floor_may_rise_but_never_fall() {
612        let parent = envelope("capabilities:\n  containment: scoped\n");
613
614        for raised in ["scoped", "sealed"] {
615            let child = envelope(&format!("capabilities:\n  containment: {raised}\n"));
616            assert!(parent.contains(&child).is_ok(), "floor '{raised}'");
617        }
618
619        let child = envelope("capabilities:\n  containment: advisory\n");
620        let violation = parent.contains(&child).unwrap_err();
621
622        assert_eq!(violation.axis, EnvelopeAxis::Containment);
623        assert_eq!(violation.entry, "advisory");
624        let message = violation.to_string();
625        assert!(message.contains("capabilities.containment"), "{message}");
626        assert!(message.contains("advisory"), "{message}");
627        assert!(message.contains("may only rise"), "{message}");
628    }
629
630    /// The operator-facing text, pinned in full. A refusal is read by someone who has to find one
631    /// line in one manifest, so the key, the offending declaration and the rule are all in it.
632    #[test]
633    fn a_refusal_names_the_key_the_declaration_and_the_rule() {
634        let list_axis = envelope("capabilities:\n  network:\n    allow: [registry.internal]\n")
635            .contains(&envelope(
636                "capabilities:\n  network:\n    allow: [api.example.com]\n",
637            ))
638            .unwrap_err();
639        assert_eq!(
640            list_axis.to_string(),
641            "capabilities.network.allow: the child declares 'api.example.com', which its parent \
642             does not hold — a spawned capsule can never hold more capability than the capsule \
643             that spawned it",
644        );
645
646        let absent_scope = envelope("capabilities:\n  filesystem:\n    scope: data\n")
647            .contains(&envelope("artifacts: []\n"))
648            .unwrap_err();
649        assert_eq!(
650            absent_scope.to_string(),
651            "capabilities.filesystem.scope: the child declares no scope and would reach the whole \
652             workdir, which its parent's scope 'data' does not cover — a spawned capsule can never \
653             hold more capability than the capsule that spawned it",
654        );
655
656        let floor = envelope("capabilities:\n  containment: scoped\n")
657            .contains(&envelope("capabilities:\n  containment: advisory\n"))
658            .unwrap_err();
659        assert_eq!(
660            floor.to_string(),
661            "capabilities.containment: the child declares containment 'advisory', below its \
662             parent's floor 'scoped' — a spawned capsule's containment floor may only rise",
663        );
664    }
665
666    /// A capsule that declares nothing holds nothing, so it is inside any parent — including one
667    /// that also declares nothing.
668    #[test]
669    fn a_silent_child_is_within_any_parent() {
670        let parent = envelope(
671            "capabilities:\n  network:\n    allow: [registry.internal]\n  \
672             shell:\n    allow: [git]\n",
673        );
674
675        assert!(parent.contains(&envelope("artifacts: []\n")).is_ok());
676        assert!(envelope("artifacts: []\n")
677            .contains(&envelope("artifacts: []\n"))
678            .is_ok());
679    }
680}