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                containment: None,
467            };
468            let narrowed = ToolCapabilityGrant::derive(Some(&capabilities), &ceiling_rules, "cap")
469                .expect("candidate fixture must parse");
470            let narrowing_verdict = narrowed.dropped_network_entries.is_empty();
471
472            let spawn_verdict = network_envelope(&[ceiling])
473                .contains(&network_envelope(&[candidate]))
474                .is_ok();
475
476            assert_eq!(
477                narrowing_verdict, spawn_verdict,
478                "'{ceiling}' vs '{candidate}': the narrowing path and the spawn path disagree",
479            );
480            assert_eq!(
481                spawn_verdict, *expected,
482                "'{ceiling}' vs '{candidate}': expected covered={expected}",
483            );
484        }
485    }
486
487    #[test]
488    fn a_network_host_the_parent_does_not_hold_is_refused_by_name() {
489        let parent = envelope("capabilities:\n  network:\n    allow: [registry.internal]\n");
490        let child = envelope("capabilities:\n  network:\n    allow: [api.example.com]\n");
491
492        let violation = parent.contains(&child).unwrap_err();
493
494        assert_eq!(violation.axis, EnvelopeAxis::NetworkAllow);
495        assert_eq!(violation.entry, "api.example.com");
496        let message = violation.to_string();
497        assert!(message.contains("capabilities.network.allow"), "{message}");
498        assert!(message.contains("api.example.com"), "{message}");
499        assert!(message.contains("its parent does not hold"), "{message}");
500    }
501
502    /// One case per axis, each child exceeding on exactly one of them: the axis a refusal names is
503    /// the axis that was exceeded, never a neighbour.
504    #[test]
505    fn every_axis_refuses_naming_its_own_manifest_key_and_entry() {
506        let parent_yaml = "capabilities:\n  \
507             network:\n    allow: [registry.internal]\n    unix_sockets: false\n  \
508             peer_fetch:\n    allow: [peer.internal]\n  \
509             shell:\n    allow: [git]\n  \
510             spawn:\n    allow: [local/worker-a]\n  \
511             env:\n    allow: [HOME]\n  \
512             filesystem:\n    scope: data\n    workdir_exec: false\n  \
513             install:\n    skill: [\"local/style-*\"]\n    tool: [local/jq]\n";
514        let parent = envelope(parent_yaml);
515
516        let cases: &[(&str, EnvelopeAxis, &str)] = &[
517            (
518                "capabilities:\n  network:\n    allow: [api.example.com]\n",
519                EnvelopeAxis::NetworkAllow,
520                "api.example.com",
521            ),
522            (
523                "capabilities:\n  network:\n    unix_sockets: true\n",
524                EnvelopeAxis::UnixSockets,
525                "true",
526            ),
527            (
528                "capabilities:\n  peer_fetch:\n    allow: [other.internal]\n",
529                EnvelopeAxis::PeerFetchAllow,
530                "other.internal",
531            ),
532            (
533                "capabilities:\n  shell:\n    allow: [curl]\n",
534                EnvelopeAxis::ShellAllow,
535                "curl",
536            ),
537            (
538                "capabilities:\n  spawn:\n    allow: [local/worker-b]\n",
539                EnvelopeAxis::SpawnAllow,
540                "local/worker-b",
541            ),
542            (
543                "capabilities:\n  env:\n    allow: [GITHUB_TOKEN]\n",
544                EnvelopeAxis::EnvAllow,
545                "GITHUB_TOKEN",
546            ),
547            (
548                "capabilities:\n  filesystem:\n    scope: other\n",
549                EnvelopeAxis::FilesystemScope,
550                "other",
551            ),
552            (
553                "capabilities:\n  filesystem:\n    scope: data/../other\n",
554                EnvelopeAxis::FilesystemScope,
555                "data/../other",
556            ),
557            (
558                "capabilities:\n  filesystem:\n    scope: data\n    workdir_exec: true\n",
559                EnvelopeAxis::WorkdirExec,
560                "true",
561            ),
562            (
563                "capabilities:\n  filesystem:\n    scope: data\n  install:\n    skill: [\"*\"]\n",
564                EnvelopeAxis::InstallSkill,
565                "*",
566            ),
567            (
568                "capabilities:\n  filesystem:\n    scope: data\n  install:\n    tool: [local/yq]\n",
569                EnvelopeAxis::InstallTool,
570                "local/yq",
571            ),
572        ];
573
574        for (child_yaml, axis, entry) in cases {
575            let violation = parent.contains(&envelope(child_yaml)).unwrap_err();
576            assert_eq!(violation.axis, *axis, "child: {child_yaml}");
577            assert_eq!(violation.entry, *entry, "child: {child_yaml}");
578            let message = violation.to_string();
579            assert!(message.contains(axis.manifest_key()), "{message}");
580            assert!(message.contains(entry), "{message}");
581        }
582    }
583
584    /// A child install entry fits when some parent entry covers it: a name or a narrower prefix
585    /// under a parent prefix, never a wider prefix or `*`.
586    #[test]
587    fn a_child_install_entry_fits_only_under_a_covering_parent_entry() {
588        let parent = envelope("capabilities:\n  install:\n    skill: [\"local/style-*\"]\n");
589
590        for (skill, covered) in [
591            ("local/style-rust", true),
592            ("\"local/style-rust-*\"", true),
593            ("\"local/style-*\"", true),
594            ("\"local/style*\"", false),
595            ("\"ana/style-rust\"", false),
596            ("\"*\"", false),
597            ("local/stylist", false),
598        ] {
599            let child = envelope(&format!(
600                "capabilities:\n  install:\n    skill: [{skill}]\n"
601            ));
602            let verdict = parent.contains(&child);
603            assert_eq!(verdict.is_ok(), covered, "child skill {skill}: {verdict:?}");
604            if let Err(violation) = verdict {
605                assert_eq!(violation.axis, EnvelopeAxis::InstallSkill);
606                assert_eq!(violation.entry, skill.trim_matches('"'));
607            }
608        }
609
610        let child_tool = envelope("capabilities:\n  install:\n    tool: [local/style-rust]\n");
611        let violation = parent.contains(&child_tool).unwrap_err();
612        assert_eq!(violation.axis, EnvelopeAxis::InstallTool);
613        assert_eq!(violation.entry, "local/style-rust");
614    }
615
616    /// `capabilities.install.from: [stores, sources]` reaches a scope's GitHub source; a child
617    /// declaring it under a parent that pulls from the stores alone is refused naming the key.
618    #[test]
619    fn a_child_fetching_from_sources_is_refused_under_a_parent_that_does_not() {
620        let stores = envelope("capabilities:\n  install:\n    tool: [\"*\"]\n");
621        let sources =
622            envelope("capabilities:\n  install:\n    tool: [\"*\"]\n    from: [stores, sources]\n");
623        assert!(!stores.install_from_sources && sources.install_from_sources);
624
625        let violation = stores.contains(&sources).unwrap_err();
626        assert_eq!(violation.axis, EnvelopeAxis::InstallFrom);
627        assert_eq!(violation.entry, "sources");
628        assert!(
629            violation.to_string().starts_with(
630                "capabilities.install.from: the child declares 'sources', which its parent does \
631                 not hold"
632            ),
633            "{violation}"
634        );
635        assert_eq!(sources.contains(&stores), Ok(()));
636        assert_eq!(sources.contains(&sources), Ok(()));
637    }
638
639    /// A child that declares no install block holds none, whatever its parent holds.
640    #[test]
641    fn a_child_declaring_no_install_is_within_any_parent() {
642        let parent = envelope("capabilities:\n  install:\n    skill: [\"*\"]\n    tool: [\"*\"]\n");
643        let child = envelope("capabilities:\n  env:\n    allow: []\n");
644        assert!(child.install_skill.is_empty() && child.install_tool.is_empty());
645        assert_eq!(parent.contains(&child), Ok(()));
646    }
647
648    /// A parent that scoped itself to a subtree is not widened by a child that declares nothing:
649    /// an undeclared scope preopens the whole workdir.
650    #[test]
651    fn a_child_declaring_no_scope_is_refused_under_a_scoped_parent() {
652        let parent = envelope("capabilities:\n  filesystem:\n    scope: data\n");
653        let child = envelope("capabilities:\n  env:\n    allow: []\n");
654
655        let violation = parent.contains(&child).unwrap_err();
656
657        assert_eq!(violation.axis, EnvelopeAxis::FilesystemScope);
658        assert!(violation.entry.is_empty());
659        let message = violation.to_string();
660        assert!(
661            message.contains("capabilities.filesystem.scope"),
662            "{message}"
663        );
664        assert!(message.contains("declares no scope"), "{message}");
665        assert!(message.contains("'data'"), "{message}");
666    }
667
668    #[test]
669    fn a_scope_at_or_beneath_the_parents_is_within_the_envelope() {
670        let parent = envelope("capabilities:\n  filesystem:\n    scope: data\n");
671
672        for scope in ["data", "data/in", "data/in/../in"] {
673            let child = envelope(&format!(
674                "capabilities:\n  filesystem:\n    scope: {scope}\n"
675            ));
676            assert!(parent.contains(&child).is_ok(), "scope '{scope}'");
677        }
678    }
679
680    /// A parent that declares no scope holds the whole workdir, so every child scope is beneath it.
681    #[test]
682    fn an_unscoped_parent_covers_any_child_scope() {
683        let parent = envelope("capabilities:\n  env:\n    allow: []\n");
684        let child = envelope("capabilities:\n  filesystem:\n    scope: data\n");
685
686        assert!(parent.contains(&child).is_ok());
687    }
688
689    /// A store is granted per artifact and defaults to the declaring capsule's own name, so two
690    /// capsules that both declare a bare `capabilities.state` open two different directories.
691    #[test]
692    fn a_state_store_the_parents_artifacts_do_not_open_is_refused() {
693        let with_state = |name: &str, store: &str| {
694            manifest_envelope(
695                name,
696                &format!(
697                    "artifacts:\n  - name: local/writer\n    version: 0.1.0\n    runtime: tool\n    \
698                     capabilities:\n      state:\n        store: {store}\n"
699                ),
700            )
701        };
702        let parent = with_state("parent", "parent-notes");
703        let child = with_state("child", "child-notes");
704
705        let violation = parent.contains(&child).unwrap_err();
706
707        assert_eq!(violation.axis, EnvelopeAxis::StateStore);
708        assert_eq!(violation.entry, "child-notes");
709        assert!(violation.to_string().contains("capabilities.state.store"));
710
711        let shared = with_state("child", "parent-notes");
712        assert!(parent.contains(&shared).is_ok());
713    }
714
715    /// An undeclared `store:` resolves to the declaring capsule's name, which is what makes two
716    /// bare declarations compare as the different directories they open.
717    #[test]
718    fn an_undeclared_store_resolves_to_the_capsule_name() {
719        let envelope = manifest_envelope(
720            "notes-capsule",
721            "artifacts:\n  - name: local/writer\n    version: 0.1.0\n    runtime: tool\n    \
722             capabilities:\n      state: {}\n",
723        );
724
725        assert_eq!(
726            envelope.state_stores.iter().collect::<Vec<_>>(),
727            vec!["notes-capsule"]
728        );
729    }
730
731    /// The one axis where more is safer. A floor may rise, and only a floor that *falls* is an
732    /// escalation.
733    #[test]
734    fn a_containment_floor_may_rise_but_never_fall() {
735        let parent = envelope("capabilities:\n  containment: scoped\n");
736
737        for raised in ["scoped", "sealed"] {
738            let child = envelope(&format!("capabilities:\n  containment: {raised}\n"));
739            assert!(parent.contains(&child).is_ok(), "floor '{raised}'");
740        }
741
742        let child = envelope("capabilities:\n  containment: advisory\n");
743        let violation = parent.contains(&child).unwrap_err();
744
745        assert_eq!(violation.axis, EnvelopeAxis::Containment);
746        assert_eq!(violation.entry, "advisory");
747        let message = violation.to_string();
748        assert!(message.contains("capabilities.containment"), "{message}");
749        assert!(message.contains("advisory"), "{message}");
750        assert!(message.contains("may only rise"), "{message}");
751    }
752
753    /// The operator-facing text, pinned in full. A refusal is read by someone who has to find one
754    /// line in one manifest, so the key, the offending declaration and the rule are all in it.
755    #[test]
756    fn a_refusal_names_the_key_the_declaration_and_the_rule() {
757        let list_axis = envelope("capabilities:\n  network:\n    allow: [registry.internal]\n")
758            .contains(&envelope(
759                "capabilities:\n  network:\n    allow: [api.example.com]\n",
760            ))
761            .unwrap_err();
762        assert_eq!(
763            list_axis.to_string(),
764            "capabilities.network.allow: the child declares 'api.example.com', which its parent \
765             does not hold — a spawned capsule can never hold more capability than the capsule \
766             that spawned it",
767        );
768
769        let absent_scope = envelope("capabilities:\n  filesystem:\n    scope: data\n")
770            .contains(&envelope("artifacts: []\n"))
771            .unwrap_err();
772        assert_eq!(
773            absent_scope.to_string(),
774            "capabilities.filesystem.scope: the child declares no scope and would reach the whole \
775             workdir, which its parent's scope 'data' does not cover — a spawned capsule can never \
776             hold more capability than the capsule that spawned it",
777        );
778
779        let floor = envelope("capabilities:\n  containment: scoped\n")
780            .contains(&envelope("capabilities:\n  containment: advisory\n"))
781            .unwrap_err();
782        assert_eq!(
783            floor.to_string(),
784            "capabilities.containment: the child declares containment 'advisory', below its \
785             parent's floor 'scoped' — a spawned capsule's containment floor may only rise",
786        );
787    }
788
789    /// A capsule that declares nothing holds nothing, so it is inside any parent — including one
790    /// that also declares nothing.
791    #[test]
792    fn a_silent_child_is_within_any_parent() {
793        let parent = envelope(
794            "capabilities:\n  network:\n    allow: [registry.internal]\n  \
795             shell:\n    allow: [git]\n",
796        );
797
798        assert!(parent.contains(&envelope("artifacts: []\n")).is_ok());
799        assert!(envelope("artifacts: []\n")
800            .contains(&envelope("artifacts: []\n"))
801            .is_ok());
802    }
803}