Skip to main content

capsule_runtime/
network_policy.rs

1use std::path::{Component as PathComponent, Path, PathBuf};
2
3use crate::{
4    containment::{PreopenReport, PreopenSurface},
5    errors::RuntimeError,
6};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub(crate) struct NetworkAllowRule {
10    pub(crate) scheme: Option<String>,
11    pub(crate) host: String,
12    pub(crate) port: Option<u16>,
13}
14
15impl NetworkAllowRule {
16    pub(crate) fn parse(entry: &str) -> Result<Self, RuntimeError> {
17        let trimmed = entry.trim();
18        if trimmed.is_empty() {
19            return Err(RuntimeError::InvalidNetworkAllowEntry {
20                entry: entry.to_string(),
21                message: "entry must not be empty".to_string(),
22            });
23        }
24
25        if trimmed.contains("://") {
26            parse_url_allow_rule(trimmed)
27        } else {
28            parse_host_allow_rule(trimmed)
29        }
30    }
31
32    /// Whether `self` — a capsule-wide ceiling rule — already permits everything `narrower`
33    /// would permit. Used to clamp a per-artifact allow-list to the ceiling: a rule no
34    /// ceiling rule covers is dropped, never granted.
35    ///
36    /// An unset `scheme`/`port` on the ceiling side is a wildcard, so `example.com` covers
37    /// `https://example.com`. The reverse is not true: a bare `example.com` on the artifact
38    /// side spans both schemes and every port, so a ceiling of `https://example.com` does
39    /// not cover it and the entry is dropped. That asymmetry is deliberate — clamping errs
40    /// toward denial rather than silently keeping a broader rule than the ceiling states.
41    pub(crate) fn covers(&self, narrower: &NetworkAllowRule) -> bool {
42        if self.host != narrower.host {
43            return false;
44        }
45
46        if let Some(expected_scheme) = &self.scheme {
47            if narrower.scheme.as_ref() != Some(expected_scheme) {
48                return false;
49            }
50        }
51
52        match self.port {
53            Some(expected_port) => narrower.port == Some(expected_port),
54            None => true,
55        }
56    }
57
58    pub(crate) fn matches(&self, target: &RequestTarget) -> bool {
59        if self.host != target.host {
60            return false;
61        }
62
63        if let Some(expected_scheme) = &self.scheme {
64            if expected_scheme != &target.scheme {
65                return false;
66            }
67        }
68
69        match self.port {
70            Some(expected_port) => target.port == Some(expected_port),
71            None => true,
72        }
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub(crate) struct RequestTarget {
78    pub(crate) scheme: String,
79    pub(crate) host: String,
80    pub(crate) port: Option<u16>,
81}
82
83impl RequestTarget {
84    pub(crate) fn from_request(uri: &http::Uri, use_tls: bool) -> Option<Self> {
85        let authority = uri.authority()?;
86        let scheme = uri
87            .scheme_str()
88            .map(|s| s.to_ascii_lowercase())
89            .unwrap_or_else(|| if use_tls { "https" } else { "http" }.to_string());
90
91        let port = authority
92            .port_u16()
93            .or_else(|| default_port_for_scheme(&scheme));
94
95        Some(Self {
96            scheme,
97            host: authority.host().to_ascii_lowercase(),
98            port,
99        })
100    }
101}
102
103pub(crate) fn parse_network_allow_rules(
104    entries: &[String],
105) -> Result<Vec<NetworkAllowRule>, RuntimeError> {
106    entries
107        .iter()
108        .map(|entry| NetworkAllowRule::parse(entry))
109        .collect()
110}
111
112fn parse_url_allow_rule(entry: &str) -> Result<NetworkAllowRule, RuntimeError> {
113    let url = url::Url::parse(entry).map_err(|err| RuntimeError::InvalidNetworkAllowEntry {
114        entry: entry.to_string(),
115        message: format!("invalid URL: {err}"),
116    })?;
117
118    let scheme = url.scheme().to_ascii_lowercase();
119    if scheme != "http" && scheme != "https" {
120        return Err(RuntimeError::InvalidNetworkAllowEntry {
121            entry: entry.to_string(),
122            message: format!("unsupported URL scheme '{scheme}' (expected http or https)"),
123        });
124    }
125
126    if url.query().is_some() || url.fragment().is_some() || (url.path() != "" && url.path() != "/")
127    {
128        return Err(RuntimeError::InvalidNetworkAllowEntry {
129            entry: entry.to_string(),
130            message: "URL allow entries must not include path, query, or fragment".to_string(),
131        });
132    }
133
134    let host = url
135        .host_str()
136        .ok_or_else(|| RuntimeError::InvalidNetworkAllowEntry {
137            entry: entry.to_string(),
138            message: "URL allow entries must include a host".to_string(),
139        })?
140        .to_ascii_lowercase();
141
142    let port = url.port().or_else(|| default_port_for_scheme(&scheme));
143    Ok(NetworkAllowRule {
144        scheme: Some(scheme),
145        host,
146        port,
147    })
148}
149
150fn parse_host_allow_rule(entry: &str) -> Result<NetworkAllowRule, RuntimeError> {
151    let prefixed = format!("http://{entry}");
152    let parsed =
153        url::Url::parse(&prefixed).map_err(|err| RuntimeError::InvalidNetworkAllowEntry {
154            entry: entry.to_string(),
155            message: format!("invalid host allow entry: {err}"),
156        })?;
157
158    if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() {
159        return Err(RuntimeError::InvalidNetworkAllowEntry {
160            entry: entry.to_string(),
161            message: "host allow entries must be host or host:port only".to_string(),
162        });
163    }
164
165    let host = parsed
166        .host_str()
167        .ok_or_else(|| RuntimeError::InvalidNetworkAllowEntry {
168            entry: entry.to_string(),
169            message: "host allow entries must include a host".to_string(),
170        })?
171        .to_ascii_lowercase();
172
173    Ok(NetworkAllowRule {
174        scheme: None,
175        host,
176        port: parsed.port(),
177    })
178}
179
180fn default_port_for_scheme(scheme: &str) -> Option<u16> {
181    match scheme {
182        "http" => Some(80),
183        "https" => Some(443),
184        _ => None,
185    }
186}
187
188/// Which of the three shapes a workdir subtree cannot have a declared path broke.
189///
190/// Carried instead of a built error so one rule can serve declarations that report under
191/// different diagnostic codes; [`Self::message`] supplies the noun each of them names.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub(crate) enum WorkdirSubpathRejection {
194    Absolute,
195    Escapes,
196    Prefixed,
197}
198
199impl WorkdirSubpathRejection {
200    /// The operator-facing half of the message, with `noun` naming the declaration that was
201    /// rejected (`"scope"`, `"read-only path"`).
202    pub(crate) fn message(self, noun: &str) -> String {
203        match self {
204            Self::Absolute => format!("{noun} must be relative to the workdir"),
205            Self::Escapes => format!("{noun} cannot escape the workdir via '..'"),
206            Self::Prefixed => format!("{noun} must not contain absolute or prefixed components"),
207        }
208    }
209}
210
211/// Lower one manifest-declared workdir-relative path to its normalized form: `.` components
212/// dropped and `..` applied against what is already accumulated.
213///
214/// The single definition of what a workdir subtree may be, shared by
215/// `capabilities.filesystem.scope` and `capabilities.filesystem.read_only` so the two cannot drift
216/// into accepting different shapes. Purely lexical — no filesystem access — so it is safe on a
217/// path that does not exist yet.
218pub(crate) fn lower_workdir_subpath(raw: &str) -> Result<PathBuf, WorkdirSubpathRejection> {
219    let path = Path::new(raw);
220    if path.is_absolute() {
221        return Err(WorkdirSubpathRejection::Absolute);
222    }
223
224    let mut normalized = PathBuf::new();
225    for component in path.components() {
226        match component {
227            PathComponent::CurDir => {}
228            PathComponent::Normal(part) => normalized.push(part),
229            // A `..` that would pop past the workdir root escapes it, whatever follows.
230            PathComponent::ParentDir => {
231                if !normalized.pop() {
232                    return Err(WorkdirSubpathRejection::Escapes);
233                }
234            }
235            PathComponent::Prefix(_) | PathComponent::RootDir => {
236                return Err(WorkdirSubpathRejection::Prefixed);
237            }
238        }
239    }
240
241    Ok(normalized)
242}
243
244pub(crate) fn validate_filesystem_scope(scope: &str) -> Result<(), RuntimeError> {
245    lower_workdir_subpath(scope)
246        .map(|_| ())
247        .map_err(|rejection| RuntimeError::InvalidFilesystemScope {
248            scope: scope.to_string(),
249            message: rejection.message("scope"),
250        })
251}
252
253/// The entry's declared `capabilities.filesystem.scope`, validated as a relative, non-escaping
254/// path before it is returned.
255///
256/// The one place the declaration is read. Both grant derivations and [`preopen_reports`] go
257/// through it, so a scope that refuses a launch refuses the report that describes one.
258fn declared_filesystem_scope(
259    capabilities: &murmur_artifact::Capabilities,
260) -> Result<Option<String>, RuntimeError> {
261    let scope = capabilities
262        .filesystem
263        .as_ref()
264        .and_then(|filesystem| filesystem.scope.clone());
265    if let Some(scope) = scope.as_deref() {
266        validate_filesystem_scope(scope)?;
267    }
268    Ok(scope)
269}
270
271/// Whether the entry declares `capabilities.filesystem.records: true`.
272fn declares_records(capabilities: &murmur_artifact::Capabilities) -> bool {
273    capabilities
274        .filesystem
275        .as_ref()
276        .is_some_and(|filesystem| filesystem.records)
277}
278
279/// Resolve a validated filesystem `scope` to the directory a guest's preopen should target,
280/// creating it if it does not already exist.
281///
282/// Shared by `hooks.rs::build_wasi_ctx` and `runtime.rs::build_wasi_ctx` — both preopen
283/// `root.join(scope)` as `"."` once a grant declares a scope, and both must fail the same way
284/// (hard error naming the scope) rather than silently falling back to an unscoped preopen,
285/// which would widen the grant.
286pub(crate) fn resolve_scoped_dir(root: &Path, scope: &str) -> Result<PathBuf, RuntimeError> {
287    let scoped_dir = root.join(scope);
288    std::fs::create_dir_all(&scoped_dir).map_err(|err| {
289        RuntimeError::wasi(
290            scoped_dir.clone(),
291            format!("failed to create granted filesystem scope '{scope}': {err}"),
292        )
293    })?;
294    Ok(scoped_dir)
295}
296
297/// A single hook's capability grant, lowered from the **capsule operator's own** manifest
298/// entry for that hook (`murmur_artifact::RuntimeArtifact::capabilities`) at staging time.
299///
300/// [`Default`] is the default-deny state and is what a hook entry with no `capabilities:`
301/// block lowers to: an empty allow-rule list (every outbound request is denied by
302/// `NetworkPolicyHooks`, and no raw WASI socket capability is granted at all) and no
303/// filesystem scope (no preopened directory of any kind).
304///
305/// Deliberately narrower than [`crate::types::CapabilityPolicy`]: the capsule-wide policy
306/// carries shell/spawn/env/limits, none of which a per-hook grant governs.
307#[derive(Debug, Clone, Default, PartialEq, Eq)]
308pub(crate) struct HookCapabilityGrant {
309    /// Hosts this hook may reach through the gated wasi-http path. Empty = deny all.
310    pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
311    /// Relative path under the hook's working directory to preopen. `None` = preopen
312    /// nothing, so the hook has no `wasi:filesystem` access whatsoever.
313    pub(crate) filesystem_scope: Option<String>,
314    /// `capabilities.filesystem.records: true`: the hook's `"."` is the session's records
315    /// directory's `hooks/`, read-write, in place of a [`Self::filesystem_scope`] (the manifest
316    /// refuses both on one entry).
317    pub(crate) filesystem_records: bool,
318    /// Host path of the created `<records dir>/hooks/`, filled by the staging path once it has
319    /// made it through the records directory's root. Left `None` by [`Self::derive`], on
320    /// [`Self::state_dir`]'s terms.
321    pub(crate) records_hooks_dir: Option<PathBuf>,
322    /// Whether this hook may read the in-scope task's input and result text through the
323    /// `murmur:task-io/read` host import. `false` = the import still links and every one of
324    /// its functions returns `not-granted`.
325    pub(crate) task_io_read: bool,
326    /// Whether this hook may read the capsule's durable conversation record through the
327    /// `murmur:conversation/read` host import. `false` = the import still links and
328    /// `read-messages` returns `not-granted`.
329    pub(crate) conversation_read: bool,
330    /// Resolved, validated `capabilities.state.store` name, with the capsule-name default already
331    /// applied. `None` = no `capabilities.state` block, so no durable store of any kind.
332    pub(crate) state_store: Option<String>,
333    /// Host path of the created store directory, filled by the staging path once
334    /// [`crate::state_store::ensure_state_store`] has made it. Left `None` by [`Self::derive`],
335    /// which stays pure — the same division [`ToolCapabilityGrant::dropped_network_entries`]
336    /// already uses, where lowering is unit-testable and only staging touches the filesystem.
337    pub(crate) state_dir: Option<PathBuf>,
338    /// This hook's operator-declared `config:` block, already lowered to compact JSON by
339    /// [`crate::artifact_config::lower_artifact_config`]. `Some` adds exactly one variable,
340    /// [`crate::artifact_config::ARTIFACT_CONFIG_ENV`], to the hook's environment; `None` — the
341    /// hook entry declaring no `config:` — adds nothing at all.
342    ///
343    /// Filled by the staging path rather than by [`Self::derive`], because config is declared
344    /// beside `capabilities:` and not inside it: the two are independent, and a hook may carry
345    /// either without the other. Carried on the grant because dispatch already looks a grant up by
346    /// artifact name, which is what scopes the value to the declaring hook alone.
347    pub(crate) config_json: Option<String>,
348    /// `capabilities.limits.deadline_seconds` on this hook's entry: each of its lifecycle calls'
349    /// deadline, ahead of the capsule-wide value. `None` keeps the session's hook deadline. The
350    /// entry's other `limits` fields govern nothing.
351    pub(crate) deadline_seconds: Option<u64>,
352}
353
354impl HookCapabilityGrant {
355    /// Lower an operator-declared `capabilities:` block into an enforceable grant,
356    /// validating both halves up front so a malformed grant fails staging rather than
357    /// surfacing as a confusing denial once the hook is already running.
358    ///
359    /// `None` (no block declared) yields [`HookCapabilityGrant::default`] — full
360    /// default-deny. Only `network`, `filesystem`, `state`, `task_io`, `conversation` and
361    /// `limits.deadline_seconds` are read; the rest of what a [`murmur_artifact::Capabilities`]
362    /// can carry governs capsule-wide concerns that a per-hook grant does not reach.
363    ///
364    /// `capsule_name` is what an undeclared `capabilities.state.store` defaults to. It is the
365    /// operator's own capsule name, on the same sourcing rule as the block itself.
366    pub(crate) fn derive(
367        capabilities: Option<&murmur_artifact::Capabilities>,
368        capsule_name: &str,
369    ) -> Result<Self, RuntimeError> {
370        let Some(capabilities) = capabilities else {
371            return Ok(Self::default());
372        };
373
374        let network_allow_rules = match capabilities.network.as_ref() {
375            Some(network) => parse_network_allow_rules(&network.allow)?,
376            None => Vec::new(),
377        };
378
379        let filesystem_scope = declared_filesystem_scope(capabilities)?;
380        let filesystem_records = declares_records(capabilities);
381
382        Ok(Self {
383            network_allow_rules,
384            filesystem_scope,
385            filesystem_records,
386            records_hooks_dir: None,
387            task_io_read: capabilities
388                .task_io
389                .as_ref()
390                .is_some_and(|task_io| task_io.read),
391            conversation_read: capabilities
392                .conversation
393                .as_ref()
394                .is_some_and(|conversation| conversation.read),
395            state_store: derive_state_store(capabilities, capsule_name)?,
396            state_dir: None,
397            config_json: None,
398            deadline_seconds: capabilities
399                .limits
400                .as_ref()
401                .and_then(|limits| limits.deadline_seconds),
402        })
403    }
404}
405
406/// Resolve and validate the store name a `capabilities:` block asks for, applying the capsule-name
407/// default. `None` when the block declares no `state`, which is deny.
408///
409/// Shared by both grants and by a native tool's staging because the rule is one rule: the name
410/// comes from the capsule operator's own manifest entry, the default is the operator's capsule
411/// name, and a name that is not a single usable path segment fails staging rather than surfacing
412/// later as a confusing denial.
413pub(crate) fn derive_state_store(
414    capabilities: &murmur_artifact::Capabilities,
415    capsule_name: &str,
416) -> Result<Option<String>, RuntimeError> {
417    let Some(state) = capabilities.state.as_ref() else {
418        return Ok(None);
419    };
420    let store = crate::state_store::resolve_store_name(state, capsule_name);
421    crate::state_store::validate_store_name(&store)?;
422    Ok(Some(store))
423}
424
425/// One tool's or driver's capability grant, lowered from the **capsule operator's own**
426/// manifest entry for that artifact (`murmur_artifact::RuntimeArtifact::capabilities`) at
427/// staging time and applied on the shared `invoke_tool_component` dispatch path.
428///
429/// The baseline is the opposite of [`HookCapabilityGrant`]'s, which is why they are two types: a
430/// hook derives its grant *from nothing* (absent block = deny), whereas a tool or driver derives
431/// it *from the capsule ceiling* (absent block = the capsule-wide policy, unchanged). Both fields
432/// are therefore `Option`-of-narrowing rather than plain values, and [`Default`] — what an entry
433/// with no `capabilities:` block yields — means "inherit everything".
434///
435/// # The wide filesystem default
436///
437/// [`Default`]'s `filesystem_scope: None` preopens the entire accessible workdir, read-write, for
438/// every tool and driver whose entry declares no `capabilities.filesystem.scope`. That default is
439/// chosen against **prompt injection steering an honest artifact**: the artifact's code does what
440/// its publisher wrote, and the hazard is a model that has read attacker-controlled text calling
441/// it with attacker-chosen arguments. Against that, the containment class, the network allow-list,
442/// the untrusted fence and `exports` are the mechanisms, and a per-artifact `scope` is the
443/// operator's tool for the entries where a narrower working surface is known.
444///
445/// It is **not** chosen against a hostile artifact. Malicious artifact code is outside murmur's
446/// documented threat model: no artifact trust model and no signing exist, so nothing here
447/// establishes that an artifact's code is what its publisher intended. Hash-pinning through
448/// `murmur.lock` establishes that an artifact has not changed since it was locked, and never that
449/// it was safe when it was locked.
450///
451/// The alternative — deny by default, narrow by declaration — is rejected because nothing in the
452/// system tells an operator what scope to write. A grant is read only from the operator's own
453/// manifest and never from the artifact's bundled one (the anti-self-escalation property every
454/// `derive` here rests on), so there is no declaration of need to migrate from. Under a narrow
455/// default the discoverable path to a working capsule is trial and error, and its terminal state
456/// is `scope: .` on every entry: this default restated, plus a migration that broke every existing
457/// capsule.
458#[derive(Debug, Clone, Default, PartialEq, Eq)]
459pub(crate) struct ToolCapabilityGrant {
460    /// Effective allow-rules for this artifact, already clamped to the ceiling. `None` =
461    /// no `capabilities.network` block, so the capsule-wide rules apply untouched.
462    /// `Some(vec![])` is a real narrowing to zero network access, and is what both an
463    /// explicit `allow: []` and an allow-list wholly outside the ceiling lower to.
464    pub(crate) network_allow_rules: Option<Vec<NetworkAllowRule>>,
465    /// Relative path under `accessible_workdir` to preopen as `"."` instead of the whole
466    /// workdir. `None` = no `capabilities.filesystem.scope`, so the artifact sees the entire
467    /// `accessible_workdir` — the wide default this type documents above.
468    pub(crate) filesystem_scope: Option<String>,
469    /// Declared `network.allow` entries dropped because no ceiling rule covers them. Held
470    /// (rather than warned about in `derive`) so lowering stays pure and unit-testable; the
471    /// staging path turns a non-empty list into a `W-SEC-007` warning.
472    pub(crate) dropped_network_entries: Vec<String>,
473    /// Resolved, validated `capabilities.state.store` name, with the capsule-name default already
474    /// applied. `None` = no `capabilities.state` block, so no durable store of any kind.
475    ///
476    /// The one axis on which a per-artifact block *widens* rather than narrows, and it can only
477    /// ever open one directory the capsule ceiling does not otherwise reach — never a workdir
478    /// path, and never another capsule's store.
479    pub(crate) state_store: Option<String>,
480    /// Host path of the created store directory, filled by the staging path once
481    /// [`crate::state_store::ensure_state_store`] has made it. Left `None` by [`Self::derive`],
482    /// which stays pure, on the same terms as `dropped_network_entries` above.
483    pub(crate) state_dir: Option<PathBuf>,
484    /// This artifact's operator-declared `config:` block, already lowered to compact JSON by
485    /// [`crate::artifact_config::lower_artifact_config`]. `Some` adds exactly one variable,
486    /// [`crate::artifact_config::ARTIFACT_CONFIG_ENV`], to this artifact's guest environment;
487    /// `None` adds nothing.
488    ///
489    /// The one field here that neither narrows nor widens: it grants no reach the ceiling did not
490    /// already allow, which is why an entry declaring `config:` and nothing else stages a grant
491    /// equal to [`Default`] in every other field. Filled by the staging path rather than by
492    /// [`Self::derive`], because `config:` is declared beside `capabilities:` and not inside it.
493    pub(crate) config_json: Option<String>,
494}
495
496impl ToolCapabilityGrant {
497    /// Lower an operator-declared `capabilities:` block into a grant that can only ever be
498    /// narrower than `ceiling_network_allow_rules` (the capsule-wide allow-list, already
499    /// parsed). Validating here rather than at dispatch means a malformed entry fails
500    /// staging instead of surfacing as a confusing denial mid-run.
501    ///
502    /// `None` (no block declared) yields [`ToolCapabilityGrant::default`] — inherit the
503    /// ceiling wholesale. Only `network`, `filesystem` and `state` are read; the other sub-blocks
504    /// a [`murmur_artifact::Capabilities`] can carry govern capsule-wide concerns that a
505    /// per-artifact grant does not reach (the caller warns `W-SEC-008` for those).
506    ///
507    /// `state` is the one member of that set that widens rather than narrows: it opens one
508    /// directory outside every workdir and touches nothing the ceiling governs. `capsule_name` is
509    /// what an undeclared `capabilities.state.store` defaults to.
510    pub(crate) fn derive(
511        capabilities: Option<&murmur_artifact::Capabilities>,
512        ceiling_network_allow_rules: &[NetworkAllowRule],
513        capsule_name: &str,
514    ) -> Result<Self, RuntimeError> {
515        let Some(capabilities) = capabilities else {
516            return Ok(Self::default());
517        };
518
519        let mut dropped_network_entries = Vec::new();
520        let network_allow_rules = match capabilities.network.as_ref() {
521            None => None,
522            Some(network) => {
523                let mut kept = Vec::new();
524                for entry in &network.allow {
525                    let rule = NetworkAllowRule::parse(entry)?;
526                    if ceiling_network_allow_rules
527                        .iter()
528                        .any(|ceiling_rule| ceiling_rule.covers(&rule))
529                    {
530                        kept.push(rule);
531                    } else {
532                        dropped_network_entries.push(entry.clone());
533                    }
534                }
535                Some(kept)
536            }
537        };
538
539        let filesystem_scope = declared_filesystem_scope(capabilities)?;
540
541        Ok(Self {
542            network_allow_rules,
543            filesystem_scope,
544            dropped_network_entries,
545            state_store: derive_state_store(capabilities, capsule_name)?,
546            state_dir: None,
547            config_json: None,
548        })
549    }
550}
551
552/// The filesystem surface each of the given artifacts will be preopened into, resolved but not
553/// opened, one entry per artifact that has a guest.
554///
555/// Lives here, beside the two grant types whose [`Default`]s it reports: [`ToolCapabilityGrant`]
556/// supplies the whole-workdir baseline a tool or driver falls back to, and
557/// [`HookCapabilityGrant`] the deny baseline a hook falls back to. Resolving the same question in
558/// a second place is how a report starts disagreeing with a launch.
559///
560/// Takes `(artifact name, role, that artifact's operator-declared capabilities)` triples rather
561/// than a concrete artifact type so the two callers that must agree can both satisfy it:
562/// `stage_session` holds [`crate::types::ArtifactRequest`]s, and `mur run --explain-scope` and
563/// `mur doctor` hold [`murmur_artifact::RuntimeArtifact`]s and have staged nothing.
564///
565/// [`murmur_artifact::ArtifactRuntime::Skill`] produces no entry: a skill is markdown staged into
566/// the workdir, no component is instantiated for it, and it holds no descriptor to report.
567///
568/// Every declared scope is read through [`declared_filesystem_scope`], so a scope a launch would
569/// refuse is refused here too. Nothing is created — [`resolve_scoped_dir`] is the staging path's
570/// job — so this stays usable from a read-only diagnostic.
571pub fn preopen_reports<'a, I>(artifacts: I) -> Result<Vec<PreopenReport>, RuntimeError>
572where
573    I: IntoIterator<
574        Item = (
575            &'a str,
576            &'a murmur_artifact::ArtifactRuntime,
577            Option<&'a murmur_artifact::Capabilities>,
578        ),
579    >,
580{
581    let mut reports = Vec::new();
582
583    for (name, runtime, capabilities) in artifacts {
584        if matches!(runtime, murmur_artifact::ArtifactRuntime::Skill) {
585            continue;
586        }
587
588        let scope = capabilities
589            .map(declared_filesystem_scope)
590            .transpose()?
591            .flatten();
592
593        let records = matches!(runtime, murmur_artifact::ArtifactRuntime::Hook)
594            && capabilities.is_some_and(declares_records);
595        let surface = match (runtime, scope.is_some()) {
596            // A declared scope resolves the same way for every role: `build_wasi_ctx` in both
597            // `runtime.rs` and `hooks.rs` preopens `<workdir>/<scope>` as `"."`.
598            (_, true) => PreopenSurface::ScopedSubtree,
599            (murmur_artifact::ArtifactRuntime::Hook, false) if records => {
600                PreopenSurface::RecordsHooks
601            }
602            (murmur_artifact::ArtifactRuntime::Hook, false) => PreopenSurface::Nothing,
603            (_, false) => PreopenSurface::WholeWorkdir,
604        };
605
606        // Unresolved until the caller knows whether the installed artifact is WASM or native:
607        // see `containment::resolve_runtime_dir_reaches`.
608        let runtime_dir_reach =
609            crate::runtime_dir::gets_runtime_dir_preopen(runtime, scope.is_some())
610                .then_some(crate::containment::RuntimeDirReach::Unresolved);
611        reports.push(PreopenReport {
612            artifact: name.to_string(),
613            role: runtime.as_str().to_string(),
614            scope,
615            surface,
616            runtime_dir_reach,
617            extra_roots: Vec::new(),
618        });
619    }
620
621    Ok(reports)
622}
623
624/// The rules `NetworkPolicyHooks` enforces for one tool/driver dispatch: the artifact's own
625/// clamped set when it declared a `capabilities.network` block, otherwise the capsule ceiling
626/// borrowed unchanged.
627///
628/// Takes the grant as an `Option` because that is how the dispatch path holds it — a name
629/// missing from the session's grant map (no per-artifact block, or an artifact pulled in at
630/// runtime) must land on the ceiling, not on an empty allow-list.
631pub(crate) fn effective_tool_network_rules<'a>(
632    grant: Option<&'a ToolCapabilityGrant>,
633    ceiling_network_allow_rules: &'a [NetworkAllowRule],
634) -> &'a [NetworkAllowRule] {
635    grant
636        .and_then(|grant| grant.network_allow_rules.as_deref())
637        .unwrap_or(ceiling_network_allow_rules)
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use murmur_artifact::{Capabilities, FilesystemCapabilities, NetworkCapabilities};
644
645    /// A `Capabilities` block carrying only the two sub-blocks a per-artifact grant reads.
646    fn capabilities_block(
647        network: Option<Vec<&str>>,
648        filesystem_scope: Option<&str>,
649    ) -> Capabilities {
650        Capabilities {
651            peer_fetch: None,
652            network: network.map(|allow| NetworkCapabilities {
653                allow: allow.into_iter().map(str::to_string).collect(),
654                unix_sockets: false,
655            }),
656            filesystem: filesystem_scope.map(|scope| FilesystemCapabilities {
657                scope: Some(scope.to_string()),
658                workdir_exec: false,
659                read_only: Vec::new(),
660                records: false,
661            }),
662            shell: None,
663            spawn: None,
664            env: None,
665            limits: None,
666            resources: None,
667            state: None,
668            task_io: None,
669            conversation: None,
670            plan: None,
671            install: None,
672            containment: None,
673        }
674    }
675
676    /// A hook entry with no `capabilities:` block gets nothing: no allow rules (so
677    /// `NetworkPolicyHooks` denies every request) and no scope (so nothing is preopened).
678    #[test]
679    fn hook_grant_defaults_to_deny_network_and_filesystem() {
680        let grant = HookCapabilityGrant::derive(None, "test-capsule").unwrap();
681
682        assert_eq!(grant, HookCapabilityGrant::default());
683        assert!(grant.network_allow_rules.is_empty());
684        assert!(grant.filesystem_scope.is_none());
685
686        let target = RequestTarget {
687            scheme: "https".to_string(),
688            host: "telemetry.example.com".to_string(),
689            port: Some(443),
690        };
691        assert!(!grant
692            .network_allow_rules
693            .iter()
694            .any(|rule| rule.matches(&target)));
695    }
696
697    /// `capabilities.limits.deadline_seconds` on the hook's entry rides the grant; the entry's
698    /// other `limits` fields do not.
699    #[test]
700    fn hook_grant_carries_the_entrys_deadline() {
701        let mut caps = capabilities_block(None, None);
702        assert_eq!(
703            HookCapabilityGrant::derive(Some(&caps), "test-capsule")
704                .unwrap()
705                .deadline_seconds,
706            None
707        );
708        caps.limits = Some(murmur_artifact::ResourceLimits {
709            memory_bytes: Some(1 << 20),
710            table_elements: None,
711            instances: None,
712            deadline_seconds: Some(2),
713        });
714        let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
715        assert_eq!(grant.deadline_seconds, Some(2));
716    }
717
718    /// A declared-but-empty `capabilities:` block is still full default-deny — declaring
719    /// the key must not, by itself, widen anything.
720    #[test]
721    fn hook_grant_empty_capabilities_block_grants_nothing() {
722        let grant =
723            HookCapabilityGrant::derive(Some(&capabilities_block(None, None)), "test-capsule")
724                .unwrap();
725
726        assert_eq!(grant, HookCapabilityGrant::default());
727    }
728
729    #[test]
730    fn hook_grant_network_allows_exactly_the_declared_host() {
731        let caps = capabilities_block(Some(vec!["https://telemetry.example.com"]), None);
732        let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
733
734        let allowed = RequestTarget {
735            scheme: "https".to_string(),
736            host: "telemetry.example.com".to_string(),
737            port: Some(443),
738        };
739        let other = RequestTarget {
740            scheme: "https".to_string(),
741            host: "evil.example.com".to_string(),
742            port: Some(443),
743        };
744
745        assert!(grant
746            .network_allow_rules
747            .iter()
748            .any(|rule| rule.matches(&allowed)));
749        assert!(!grant
750            .network_allow_rules
751            .iter()
752            .any(|rule| rule.matches(&other)));
753        // A network grant alone never widens the filesystem.
754        assert!(grant.filesystem_scope.is_none());
755    }
756
757    #[test]
758    fn hook_grant_filesystem_scope_is_carried_through() {
759        let caps = capabilities_block(None, Some("hook-state"));
760        let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
761
762        assert_eq!(grant.filesystem_scope.as_deref(), Some("hook-state"));
763        // A filesystem grant alone never widens the network.
764        assert!(grant.network_allow_rules.is_empty());
765    }
766
767    /// `capabilities.task_io.read` lowers into the grant on its own axis: `true` grants it,
768    /// `false` and an absent block do not, and neither ever widens network or filesystem.
769    #[test]
770    fn hook_grant_task_io_read_is_carried_through_independently() {
771        for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
772            let caps = murmur_artifact::Capabilities {
773                task_io: declared.map(|read| murmur_artifact::TaskIoCapabilities { read }),
774                conversation: None,
775                plan: None,
776                ..capabilities_block(None, None)
777            };
778            let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
779            assert_eq!(grant.task_io_read, expected, "declared: {declared:?}");
780            assert!(grant.network_allow_rules.is_empty());
781            assert!(grant.filesystem_scope.is_none());
782        }
783    }
784
785    /// `capabilities.conversation.read` lowers on its own axis, exactly as `task_io.read` does,
786    /// and neither ever widens network or filesystem.
787    #[test]
788    fn hook_grant_conversation_read_is_carried_through_independently() {
789        for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
790            let caps = murmur_artifact::Capabilities {
791                conversation: declared
792                    .map(|read| murmur_artifact::ConversationCapabilities { read }),
793                ..capabilities_block(None, None)
794            };
795            let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
796            assert_eq!(grant.conversation_read, expected, "declared: {declared:?}");
797            assert!(!grant.task_io_read, "one grant never implies the other");
798            assert!(grant.network_allow_rules.is_empty());
799            assert!(grant.filesystem_scope.is_none());
800        }
801    }
802
803    /// The store name defaults to the capsule's, and an explicit `store:` wins. Applied where the
804    /// grant is lowered, so a report and a preopen can never name different directories.
805    #[test]
806    fn state_store_defaults_to_the_capsule_name_and_an_explicit_name_wins() {
807        for (declared, expected) in [(None, "state-capsule"), (Some("shey"), "shey")] {
808            let caps = Capabilities {
809                state: Some(murmur_artifact::StateCapabilities {
810                    store: declared.map(str::to_string),
811                }),
812                ..capabilities_block(None, None)
813            };
814
815            let hook = HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap();
816            let tool =
817                ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap();
818
819            assert_eq!(hook.state_store.as_deref(), Some(expected));
820            assert_eq!(tool.state_store.as_deref(), Some(expected));
821            // `derive` stays pure: the directory is made by the staging path, not here.
822            assert!(hook.state_dir.is_none());
823            assert!(tool.state_dir.is_none());
824            // A state grant alone widens nothing else.
825            assert!(hook.filesystem_scope.is_none());
826            assert!(hook.network_allow_rules.is_empty());
827            assert!(tool.filesystem_scope.is_none());
828            assert!(tool.network_allow_rules.is_none());
829        }
830    }
831
832    /// Absent `capabilities.state` is deny for both roles, including on a block that declares
833    /// other things — declaring the key is what grants a store, never declaring the block.
834    #[test]
835    fn an_absent_state_block_grants_no_store_to_either_role() {
836        let caps = capabilities_block(Some(vec!["https://api.example.com"]), Some("cache"));
837
838        assert!(HookCapabilityGrant::derive(Some(&caps), "state-capsule")
839            .unwrap()
840            .state_store
841            .is_none());
842        assert!(
843            ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule")
844                .unwrap()
845                .state_store
846                .is_none()
847        );
848    }
849
850    /// A malformed store name fails lowering, so it fails staging — never a confusing denial once
851    /// a guest is already running. Refused identically for a hook and for a tool.
852    #[test]
853    fn a_malformed_store_name_fails_lowering_for_both_roles() {
854        for store in ["../escape", "/abs/path", "a/b", "", ".hidden"] {
855            let caps = Capabilities {
856                state: Some(murmur_artifact::StateCapabilities {
857                    store: Some(store.to_string()),
858                }),
859                ..capabilities_block(None, None)
860            };
861
862            for err in [
863                HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap_err(),
864                ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap_err(),
865            ] {
866                assert!(
867                    matches!(err, RuntimeError::InvalidStateStore { .. }),
868                    "store '{store}' must refuse as InvalidStateStore, got: {err}"
869                );
870            }
871        }
872    }
873
874    /// An empty capsule name is not a usable store name either, so a capsule that somehow has one
875    /// is refused rather than silently given `~/.murmur/state/`.
876    #[test]
877    fn an_empty_capsule_name_cannot_become_a_store_name() {
878        let caps = Capabilities {
879            state: Some(murmur_artifact::StateCapabilities { store: None }),
880            ..capabilities_block(None, None)
881        };
882
883        assert!(matches!(
884            ToolCapabilityGrant::derive(Some(&caps), &ceiling(), ""),
885            Err(RuntimeError::InvalidStateStore { .. })
886        ));
887    }
888
889    #[test]
890    fn hook_grant_rejects_escaping_filesystem_scope() {
891        for scope in ["../escape", "/etc"] {
892            let caps = capabilities_block(None, Some(scope));
893            let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
894            assert!(
895                matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
896                "scope {scope} should fail staging, got: {err}"
897            );
898        }
899    }
900
901    #[test]
902    fn hook_grant_rejects_malformed_network_entry() {
903        let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
904        let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
905
906        assert!(
907            matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
908            "got: {err}"
909        );
910    }
911
912    /// The capsule ceiling used by the tool/driver narrowing tests: two hosts, one of which
913    /// a narrowed artifact is expected to lose access to.
914    fn ceiling() -> Vec<NetworkAllowRule> {
915        parse_network_allow_rules(&[
916            "https://api.example.com".to_string(),
917            "https://other.example.com".to_string(),
918        ])
919        .unwrap()
920    }
921
922    fn target(host: &str) -> RequestTarget {
923        RequestTarget {
924            scheme: "https".to_string(),
925            host: host.to_string(),
926            port: Some(443),
927        }
928    }
929
930    fn reaches(rules: &[NetworkAllowRule], host: &str) -> bool {
931        rules.iter().any(|rule| rule.matches(&target(host)))
932    }
933
934    /// The no-op invariant: a tool/driver entry with no `capabilities:` block keeps the whole
935    /// ceiling and the whole workdir, unchanged.
936    #[test]
937    fn tool_grant_without_entry_inherits_the_ceiling_unchanged() {
938        let ceiling = ceiling();
939        let grant = ToolCapabilityGrant::derive(None, &ceiling, "test-capsule").unwrap();
940
941        assert_eq!(grant, ToolCapabilityGrant::default());
942        assert!(grant.network_allow_rules.is_none());
943        assert!(grant.filesystem_scope.is_none());
944        assert!(grant.dropped_network_entries.is_empty());
945
946        let effective = effective_tool_network_rules(Some(&grant), &ceiling);
947        assert_eq!(effective, ceiling.as_slice());
948        assert!(reaches(effective, "api.example.com"));
949        assert!(reaches(effective, "other.example.com"));
950    }
951
952    /// A declared subset keeps exactly the declared host and loses the rest of the ceiling.
953    #[test]
954    fn tool_grant_subset_of_ceiling_is_kept_and_narrows() {
955        let ceiling = ceiling();
956        let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
957        let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
958
959        let effective = effective_tool_network_rules(Some(&grant), &ceiling);
960        assert!(reaches(effective, "api.example.com"));
961        assert!(!reaches(effective, "other.example.com"));
962        assert!(grant.dropped_network_entries.is_empty());
963    }
964
965    /// Narrowing can only subtract: a host the ceiling never allowed is dropped (and
966    /// reported for `W-SEC-007`), not granted.
967    #[test]
968    fn tool_grant_entry_outside_the_ceiling_is_dropped_not_granted() {
969        let ceiling = ceiling();
970        let caps = capabilities_block(
971            Some(vec!["https://api.example.com", "https://evil.example.com"]),
972            None,
973        );
974        let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
975
976        let effective = effective_tool_network_rules(Some(&grant), &ceiling);
977        assert!(reaches(effective, "api.example.com"));
978        assert!(!reaches(effective, "evil.example.com"));
979        assert_eq!(
980            grant.dropped_network_entries,
981            vec!["https://evil.example.com".to_string()]
982        );
983    }
984
985    /// A bare host is broader than a scheme-bound ceiling rule (it spans http too), so it is
986    /// dropped rather than silently widening the artifact past the ceiling.
987    #[test]
988    fn tool_grant_entry_broader_than_the_ceiling_rule_is_dropped() {
989        let ceiling = ceiling();
990        let caps = capabilities_block(Some(vec!["api.example.com"]), None);
991        let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
992
993        assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
994        assert_eq!(
995            grant.dropped_network_entries,
996            vec!["api.example.com".to_string()]
997        );
998    }
999
1000    /// An explicit empty allow-list is a real narrowing to zero network access — distinct
1001    /// from the key being absent, which inherits the ceiling.
1002    #[test]
1003    fn tool_grant_empty_network_allow_denies_all_for_that_artifact() {
1004        let ceiling = ceiling();
1005        let caps = capabilities_block(Some(vec![]), None);
1006        let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1007
1008        let effective = effective_tool_network_rules(Some(&grant), &ceiling);
1009        assert!(effective.is_empty());
1010        assert!(!reaches(effective, "api.example.com"));
1011        assert!(!reaches(effective, "other.example.com"));
1012    }
1013
1014    /// A filesystem-only block leaves the network on the ceiling: narrowing one axis must
1015    /// not implicitly clamp the other.
1016    #[test]
1017    fn tool_grant_filesystem_scope_is_carried_and_leaves_network_inherited() {
1018        let ceiling = ceiling();
1019        let caps = capabilities_block(None, Some("cache"));
1020        let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1021
1022        assert_eq!(grant.filesystem_scope.as_deref(), Some("cache"));
1023        assert!(grant.network_allow_rules.is_none());
1024        assert_eq!(
1025            effective_tool_network_rules(Some(&grant), &ceiling),
1026            ceiling.as_slice()
1027        );
1028    }
1029
1030    #[test]
1031    fn tool_grant_rejects_escaping_filesystem_scope() {
1032        for scope in ["../escape", "/etc"] {
1033            let caps = capabilities_block(None, Some(scope));
1034            let err =
1035                ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1036            assert!(
1037                matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
1038                "scope {scope} should fail staging, got: {err}"
1039            );
1040        }
1041    }
1042
1043    #[test]
1044    fn tool_grant_rejects_malformed_network_entry() {
1045        let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
1046        let err = ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1047
1048        assert!(
1049            matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
1050            "got: {err}"
1051        );
1052    }
1053
1054    /// An empty ceiling already denies everything, so every declared entry drops — a
1055    /// per-artifact block can never be the thing that turns network access on.
1056    #[test]
1057    fn tool_grant_cannot_widen_an_empty_ceiling() {
1058        let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1059        let grant = ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").unwrap();
1060
1061        assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
1062        assert_eq!(
1063            grant.dropped_network_entries,
1064            vec!["https://api.example.com".to_string()]
1065        );
1066    }
1067
1068    /// An unset scheme/port on the ceiling side is a wildcard the narrower rule fits under.
1069    #[test]
1070    fn ceiling_rule_with_wildcard_scheme_covers_a_scheme_bound_rule() {
1071        let ceiling = parse_network_allow_rules(&["api.example.com".to_string()]).unwrap();
1072        let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1073        let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1074
1075        assert!(grant.dropped_network_entries.is_empty());
1076        assert!(reaches(
1077            effective_tool_network_rules(Some(&grant), &ceiling),
1078            "api.example.com"
1079        ));
1080    }
1081
1082    #[test]
1083    fn filesystem_scope_validation_accepts_safe_relative_paths() {
1084        for scope in [".", "./workdir", "a/../b", "sandbox/subdir"] {
1085            assert!(
1086                validate_filesystem_scope(scope).is_ok(),
1087                "scope should be valid: {scope}"
1088            );
1089        }
1090    }
1091
1092    #[test]
1093    fn filesystem_scope_validation_rejects_escape_paths() {
1094        for scope in ["../x", "../../x", "/tmp"] {
1095            assert!(
1096                validate_filesystem_scope(scope).is_err(),
1097                "scope should be invalid: {scope}"
1098            );
1099        }
1100    }
1101
1102    #[test]
1103    fn network_allowlist_empty_denies_all_requests() {
1104        let rules = parse_network_allow_rules(&Vec::<String>::new()).unwrap();
1105
1106        let target = RequestTarget {
1107            scheme: "https".to_string(),
1108            host: "blocked.example.com".to_string(),
1109            port: Some(443),
1110        };
1111
1112        assert!(!rules.iter().any(|rule| rule.matches(&target)));
1113    }
1114
1115    #[test]
1116    fn network_allowlist_matches_allowed_host_with_default_port() {
1117        let rules =
1118            parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1119        let target = RequestTarget {
1120            scheme: "https".to_string(),
1121            host: "allowed.example.com".to_string(),
1122            port: Some(443),
1123        };
1124
1125        assert!(rules.iter().any(|rule| rule.matches(&target)));
1126    }
1127
1128    #[test]
1129    fn network_allowlist_blocks_non_matching_host() {
1130        let rules =
1131            parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1132        let target = RequestTarget {
1133            scheme: "https".to_string(),
1134            host: "blocked.example.com".to_string(),
1135            port: Some(443),
1136        };
1137
1138        assert!(!rules.iter().any(|rule| rule.matches(&target)));
1139    }
1140
1141    #[test]
1142    fn network_allowlist_normalizes_default_ports() {
1143        let rules = parse_network_allow_rules(&[
1144            "https://allowed.example.com".to_string(),
1145            "allowed-two.example.com:8080".to_string(),
1146        ])
1147        .unwrap();
1148
1149        let https_default = RequestTarget {
1150            scheme: "https".to_string(),
1151            host: "allowed.example.com".to_string(),
1152            port: Some(443),
1153        };
1154        let custom_port = RequestTarget {
1155            scheme: "http".to_string(),
1156            host: "allowed-two.example.com".to_string(),
1157            port: Some(8080),
1158        };
1159
1160        assert!(rules.iter().any(|rule| rule.matches(&https_default)));
1161        assert!(rules.iter().any(|rule| rule.matches(&custom_port)));
1162    }
1163
1164    /// The two opposite baselines, read off one manifest: a `runtime: tool` and a
1165    /// `runtime: driver` entry that declare nothing keep the whole accessible workdir, and a
1166    /// `runtime: hook` entry that declares nothing holds no descriptor at all.
1167    #[test]
1168    fn undeclared_entries_report_the_baseline_their_role_starts_from() {
1169        let tool = murmur_artifact::ArtifactRuntime::Tool;
1170        let driver = murmur_artifact::ArtifactRuntime::Driver;
1171        let hook = murmur_artifact::ArtifactRuntime::Hook;
1172
1173        let reports = preopen_reports(vec![
1174            ("notes-tool", &tool, None),
1175            ("anthropic-driver", &driver, None),
1176            ("telemetry-hook", &hook, None),
1177        ])
1178        .unwrap();
1179
1180        assert_eq!(
1181            reports,
1182            vec![
1183                PreopenReport {
1184                    artifact: "notes-tool".to_string(),
1185                    role: "tool".to_string(),
1186                    scope: None,
1187                    surface: PreopenSurface::WholeWorkdir,
1188                    runtime_dir_reach: Some(crate::containment::RuntimeDirReach::Unresolved),
1189                    extra_roots: Vec::new(),
1190                },
1191                PreopenReport {
1192                    artifact: "anthropic-driver".to_string(),
1193                    role: "driver".to_string(),
1194                    scope: None,
1195                    surface: PreopenSurface::WholeWorkdir,
1196                    runtime_dir_reach: None,
1197                    extra_roots: Vec::new(),
1198                },
1199                PreopenReport {
1200                    artifact: "telemetry-hook".to_string(),
1201                    role: "hook".to_string(),
1202                    scope: None,
1203                    surface: PreopenSurface::Nothing,
1204                    runtime_dir_reach: None,
1205                    extra_roots: Vec::new(),
1206                },
1207            ]
1208        );
1209    }
1210
1211    /// A declared scope resolves the same way whatever the role, and is carried verbatim so an
1212    /// operator reads back the line they wrote.
1213    #[test]
1214    fn a_declared_scope_narrows_every_role_to_a_subtree() {
1215        let tool = murmur_artifact::ArtifactRuntime::Tool;
1216        let hook = murmur_artifact::ArtifactRuntime::Hook;
1217        let tool_caps = capabilities_block(None, Some("cache"));
1218        let hook_caps = capabilities_block(None, Some("hook-state"));
1219
1220        let reports = preopen_reports(vec![
1221            ("notes-tool", &tool, Some(&tool_caps)),
1222            ("telemetry-hook", &hook, Some(&hook_caps)),
1223        ])
1224        .unwrap();
1225
1226        assert_eq!(
1227            reports,
1228            vec![
1229                PreopenReport {
1230                    artifact: "notes-tool".to_string(),
1231                    role: "tool".to_string(),
1232                    scope: Some("cache".to_string()),
1233                    surface: PreopenSurface::ScopedSubtree,
1234                    runtime_dir_reach: None,
1235                    extra_roots: Vec::new(),
1236                },
1237                PreopenReport {
1238                    artifact: "telemetry-hook".to_string(),
1239                    role: "hook".to_string(),
1240                    scope: Some("hook-state".to_string()),
1241                    surface: PreopenSurface::ScopedSubtree,
1242                    runtime_dir_reach: None,
1243                    extra_roots: Vec::new(),
1244                },
1245            ]
1246        );
1247    }
1248
1249    /// A skill is markdown staged into the workdir with no component behind it, so it produces no
1250    /// entry at all — not a `Nothing` entry, which would claim a guest was denied a descriptor.
1251    #[test]
1252    fn a_skill_produces_no_entry() {
1253        let skill = murmur_artifact::ArtifactRuntime::Skill;
1254        let tool = murmur_artifact::ArtifactRuntime::Tool;
1255
1256        let reports = preopen_reports(vec![
1257            ("notes-skill", &skill, None),
1258            ("notes-tool", &tool, None),
1259        ])
1260        .unwrap();
1261
1262        assert_eq!(reports.len(), 1);
1263        assert_eq!(reports[0].artifact, "notes-tool");
1264    }
1265
1266    /// The report runs the declared scope through the same validator the grant derivation does,
1267    /// so a scope that would refuse a launch refuses the report.
1268    #[test]
1269    fn an_escaping_scope_is_refused() {
1270        let tool = murmur_artifact::ArtifactRuntime::Tool;
1271        let caps = capabilities_block(None, Some("../escape"));
1272
1273        let err = preopen_reports(vec![("notes-tool", &tool, Some(&caps))]).unwrap_err();
1274
1275        assert!(
1276            matches!(
1277                err,
1278                RuntimeError::InvalidFilesystemScope { ref scope, .. } if scope == "../escape"
1279            ),
1280            "unexpected error: {err}"
1281        );
1282        assert!(
1283            ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").is_err(),
1284            "the grant derivation must refuse what the report refuses"
1285        );
1286    }
1287
1288    /// The three surfaces carry stable kebab-case wire names, and the enum has exactly three
1289    /// variants: a guest holds the workdir, one subtree of it, or no descriptor.
1290    #[test]
1291    fn preopen_surfaces_serialize_to_stable_wire_names() {
1292        for (surface, wire) in [
1293            (PreopenSurface::WholeWorkdir, "whole-workdir"),
1294            (PreopenSurface::ScopedSubtree, "scoped-subtree"),
1295            (PreopenSurface::Nothing, "nothing"),
1296        ] {
1297            assert_eq!(
1298                serde_json::to_value(surface).unwrap(),
1299                serde_json::Value::String(wire.to_string())
1300            );
1301        }
1302    }
1303}