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