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