Skip to main content

capsule_runtime/
types.rs

1use std::{
2    collections::{HashMap, HashSet},
3    path::PathBuf,
4    sync::Arc,
5};
6
7use murmur_artifact::{
8    ArtifactImplementation, ArtifactRuntime, ContextConfig, HookConfig, InferenceConfig,
9    LifecycleConfig, LifecycleOverride, Registry,
10};
11use wasmtime::{component::Component, Engine};
12
13use crate::{
14    bindings::host::murmur::tool::run::ToolResult,
15    hooks::ShellDispatchInfo,
16    limits::{EpochTicker, ExecutionLimits},
17    network_policy::{HookCapabilityGrant, ToolCapabilityGrant},
18};
19
20pub(crate) struct DispatchOutcome {
21    pub result: ToolResult,
22    pub shell: Option<ShellDispatchInfo>,
23    /// True when the dispatch was served by reading a skill.md file rather than running
24    /// a binary or WASM component. Used by the agent loop to write a `skill_call` trace
25    /// event instead of a `tool_call` event.
26    pub is_skill: bool,
27    /// Set when this dispatch failed in a way that ends the *session* rather than just this
28    /// tool call — today only a `sealed` composed-root construction failure
29    /// ([`crate::shell::ShellExecError::session_fatal`]). `result` still describes the failed
30    /// call so the trace records what was attempted; the agent turn loop then returns this
31    /// error instead of feeding the failure back to the model for another turn.
32    ///
33    /// A dispatch that merely failed leaves this `None`: a capsule reacting to a broken tool
34    /// call is ordinary, whereas a capsule continuing to run after its declared containment
35    /// boundary stopped being establishable is the silent-degradation failure mode the whole
36    /// containment-class mechanism exists to prevent.
37    pub fatal: Option<crate::errors::RuntimeError>,
38}
39
40impl DispatchOutcome {
41    pub(crate) fn tool(result: ToolResult) -> Self {
42        Self {
43            result,
44            shell: None,
45            is_skill: false,
46            fatal: None,
47        }
48    }
49
50    pub(crate) fn skill(result: ToolResult) -> Self {
51        Self {
52            result,
53            shell: None,
54            is_skill: true,
55            fatal: None,
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ArtifactRequest {
62    pub name: String,
63    pub version: String,
64    pub runtime: ArtifactRuntime,
65    /// Optional local source path (skill artifacts only). When set, the runtime resolves the
66    /// skill's `skill.md` directly from this path and skips registry resolution entirely.
67    /// Relative paths resolve against `StageRequest::manifest_dir`.
68    pub source: Option<String>,
69    /// Per-artifact capability grant copied verbatim from this artifact's entry in the
70    /// capsule operator's own manifest (`murmur_artifact::RuntimeArtifact::capabilities`).
71    /// Consumed for `runtime: hook` (default-deny baseline, `None` = deny everything) and for
72    /// `runtime: tool`/`runtime: driver` (ceiling baseline, `None` = the capsule-wide policy
73    /// unchanged); the manifest parser rejects the key on `runtime: skill`. Never sourced
74    /// from the artifact's own bundled `murmur.yaml`, so an artifact cannot self-grant.
75    pub capabilities: Option<murmur_artifact::Capabilities>,
76    /// What the runtime does when this hook's async job queue is full, copied verbatim from
77    /// `on_overflow:` on this artifact's entry in the capsule operator's own manifest — the
78    /// same operator-only sourcing rule `capabilities` above follows. Consumed for
79    /// `runtime: hook` and inert for every other role (the manifest parser rejects the key
80    /// there); inert too for a hook that turns out to be `execution_mode: blocking`, which is
81    /// never queued.
82    pub on_overflow: murmur_artifact::HookOverflowPolicy,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct LockExpectation {
87    pub name: String,
88    pub resolved_version: String,
89    pub sha256_wasm: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct ResolvedLockArtifact {
94    pub name: String,
95    pub resolved_version: String,
96    pub sha256_wasm: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub struct CapabilityPolicy {
101    pub network_allow: Vec<String>,
102    /// From `capabilities.network.unix_sockets`. `false` by default, which makes the shell
103    /// subprocess tree's `socket(AF_UNIX, ...)` calls fail with `EACCES` at the kernel level on
104    /// both Linux tiers — `network_allow` above governs IP destinations only and would otherwise
105    /// leave a local daemon socket (`/var/run/docker.sock` — host root) completely unmediated.
106    /// `true` simply omits that seccomp rule; it is capsule-wide, not per-socket-path. See
107    /// `sandbox::denied_socket_domains`.
108    pub unix_sockets_allowed: bool,
109    pub filesystem_scope: Option<String>,
110    /// From `capabilities.filesystem.workdir_exec`. `false` by default, which withholds the
111    /// Landlock `Execute` right from the workdir's own `PathBeneath` rule — see
112    /// `sandbox::linux_enforce::workdir_access_rights`. With it withheld the kernel refuses to exec
113    /// anything under the workdir on the resolved path itself, so `shell_allow` is enforced
114    /// completely: a binary copied or compiled into the workdir cannot run under *any* name.
115    ///
116    /// `true` gives the right back for compile-and-run workflows and accepts, explicitly, that
117    /// `shell_allow` is then unenforceable for anything inside the workdir. That is why this field
118    /// is the one *grant* in this struct that also lowers the session's achieved containment class
119    /// (see `crate::containment::achieved_containment_class`) — nothing else here can.
120    pub workdir_exec_allowed: bool,
121    pub shell_allow: Vec<String>,
122    pub spawn_allow: Vec<String>,
123    pub shell_strip_env: Vec<String>,
124    pub shell_baseline_env: Vec<String>,
125    /// Typed interpreter-runtime grants from `capabilities.shell.interpreter_runtime`: each
126    /// names an already-allowlisted binary and the exact host directories outside the workdir
127    /// its import machinery needs, with a per-directory `list_dir` enumerability flag. Empty
128    /// unless the manifest declares them. Consumed on `KernelFull` to add narrow Landlock
129    /// grants (see `sandbox::resolve_interpreter_runtime_grants`) and to fire `W-SEC-009`.
130    pub shell_interpreter_runtime: Vec<murmur_artifact::InterpreterRuntimeGrant>,
131    /// Typed staged-runtime grants from `capabilities.shell.staged_runtime`: each names an
132    /// already-allowlisted binary, the absolute host path of a pinned runtime tree, and the `pin`
133    /// identifying that tree's build. Empty unless the manifest declares them.
134    ///
135    /// Consumed at staging to enforce that declaring one requires an effective `sealed` floor (see
136    /// `crate::staged_runtime::check_staged_runtime_floor`) and to render the `staged runtime`
137    /// section of `--explain-scope`. Per-binary mutually exclusive with
138    /// [`Self::shell_interpreter_runtime`], which the manifest parser already guarantees.
139    pub shell_staged_runtime: Vec<murmur_artifact::StagedRuntimeGrant>,
140    /// Host env var names a WASM guest may observe, from `capabilities.env.allow`. Empty by
141    /// default: a guest sees only the runtime's own `MURMUR_*` injection unless the manifest
142    /// names a variable here. `shell_strip_env` and the credential patterns still apply on
143    /// top, so a credential-shaped name declared here is stripped anyway.
144    pub env_allow: Vec<String>,
145    /// Execution limits applied to every guest store in the session. Always fully
146    /// resolved — `capability_policy_from_runtime_manifest` substitutes a default for
147    /// each field `capabilities.limits` omits, so there is no "unset" state here and a
148    /// silent manifest yields [`ExecutionLimits::default`] rather than no limits.
149    pub limits: ExecutionLimits,
150    /// OS-level bounds applied to every *native subprocess* the session spawns — rlimits, the
151    /// Linux cgroup v2 scope, and the workdir-size ceiling. Resolved on exactly the same terms
152    /// as `limits` above (each omitted `capabilities.resources` field replaced by its default,
153    /// no "unset" state), but a completely different subject: `limits` bounds a WASM guest
154    /// inside its wasmtime store, this bounds the processes the host forks. See
155    /// [`crate::resources`].
156    pub resources: crate::resources::HostResourceLimits,
157    /// The containment class this capsule asked for, from `capabilities.containment`.
158    ///
159    /// Unlike every other field here it is a *requirement*, not a grant — and unlike
160    /// `StageRequest::declared_containment_floor` it is only the manifest's own vote, before the
161    /// workspace config and `--containment` are folded in. `stage_session` overwrites it with the
162    /// combined floor as soon as that is known, so anything reading it off a `StagedSession` sees
163    /// the effective value; the manifest-only value exists so a `CapabilityPolicy` built straight
164    /// from a manifest is still self-consistent.
165    ///
166    /// Read by `sandbox::ShellEnforcement::resolve` to decide whether a sealed-capable host
167    /// actually installs a composed root for this session — a capsule that declared `scoped` must
168    /// keep getting `scoped`'s mechanism. See `sandbox::applied_tier`.
169    pub containment_floor: murmur_artifact::ContainmentClass,
170    /// `capabilities.limits.deadline_seconds` exactly as the manifest declared it — `None`
171    /// when it declared nothing. Retained undefaulted alongside the fully-resolved `limits`
172    /// above purely so hook calls can apply their own, lower default without mistaking an
173    /// explicit `600` for silence. See [`Self::hook_limits`].
174    pub declared_deadline_seconds: Option<u64>,
175}
176
177impl CapabilityPolicy {
178    /// Execution limits for hook lifecycle calls — [`Self::limits`] with the hook-specific
179    /// deadline default substituted when the manifest declared none.
180    ///
181    /// A hook call is one bounded piece of work, so it does not inherit the capsule-wide
182    /// ten-minute budget that exists for a capsule's whole `run`: a wedged hook would
183    /// otherwise stall the session for most of that, every event. An explicitly declared
184    /// `deadline_seconds` still wins, for hooks as for every other guest.
185    #[must_use]
186    pub fn hook_limits(&self) -> ExecutionLimits {
187        self.limits.for_hook_calls(self.declared_deadline_seconds)
188    }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct InstalledArtifactSummary {
193    pub name: String,
194    pub version: String,
195    pub runtime: ArtifactRuntime,
196    pub implementation: Option<ArtifactImplementation>,
197}
198
199#[derive(Clone)]
200pub(crate) struct StagedHookArtifact {
201    pub name: String,
202    pub version: String,
203    pub component: Component,
204    /// Behavioral contract (binding/execution_mode/commit_policy) from the hook artifact's
205    /// *own* bundled manifest. Carries no capability information — see `grant`.
206    pub config: HookConfig,
207    /// What this hook is allowed to do, lowered and validated at staging time from the
208    /// operator's own manifest entry. Applied identically by all three hook instantiation
209    /// paths in `hooks.rs`; `HookCapabilityGrant::default()` is full default-deny.
210    pub grant: HookCapabilityGrant,
211    /// What to do when this hook's async job queue is full — from the operator's manifest
212    /// entry (via [`ArtifactRequest::on_overflow`]), on the same terms as `grant` and never
213    /// from `config`. Read only when `config.execution_mode` is `Async`; a blocking hook has
214    /// no queue to overflow.
215    pub on_overflow: murmur_artifact::HookOverflowPolicy,
216}
217
218#[derive(Debug, Clone)]
219pub struct StageRequest {
220    pub manifest_dir: PathBuf,
221    pub capsule_name: String,
222    pub capsule_version: String,
223    pub capsule_component_bytes: Vec<u8>,
224    pub artifacts: Vec<ArtifactRequest>,
225    pub allowlisted_tools: HashSet<String>,
226    pub lock_expectations: Option<Vec<LockExpectation>>,
227    pub capability_policy: CapabilityPolicy,
228    pub inference: Option<InferenceConfig>,
229    /// Whether `inference` above had its system-prompt declaration replaced by the caller
230    /// (`mur run --system-prompt`) rather than read from the manifest. Purely a provenance
231    /// signal for the trace: the override itself is already applied to `inference`, which by
232    /// this point is indistinguishable from a manifest that declared the same prompt inline.
233    /// `true` even when the override cleared the prompt to nothing.
234    pub system_prompt_overridden: bool,
235    pub context: Option<ContextConfig>,
236    /// OTLP/HTTP endpoint for span export; None = no external OTel emission.
237    pub otel_endpoint: Option<String>,
238    /// JSON-serialized EvalConfig injected into hook WASI env as MURMUR_EVAL_CONFIG.
239    pub eval_config_json: Option<String>,
240    /// Dataset case identifier injected as MURMUR_CASE_ID when running a dataset.
241    pub case_id: Option<String>,
242    /// Dataset identifier injected as MURMUR_DATASET_ID when running a dataset.
243    pub dataset_id: Option<String>,
244    /// Lifecycle config from the manifest (None = use LifecycleConfig::default()).
245    pub lifecycle: Option<LifecycleConfig>,
246    /// Optional override applied on top of lifecycle (e.g. from CLI flags or mur-roost).
247    pub lifecycle_override: Option<LifecycleOverride>,
248    /// Trace config from the manifest (None = defaults apply: input captured, output not).
249    pub trace: Option<murmur_artifact::TraceConfig>,
250    /// Optional user project directory to mount as the capsule's accessible workspace.
251    /// When set, session artifacts go into `<workdir>/.murmur/<session_id>/`.
252    /// When None, uses the default temp dir (existing behavior).
253    pub workdir: Option<std::path::PathBuf>,
254    /// Address to bind the HTTP server on. Defaults to "127.0.0.1" for local runs.
255    /// Use "0.0.0.0" when deploying to a VM so the capsule is reachable externally.
256    pub bind_addr: String,
257    /// Internal port from `network.internal_port` in the manifest. When `Some`, the runtime
258    /// binds strictly to this port and errors if it is already in use. When `None`, the OS
259    /// assigns a port.
260    pub internal_port: Option<u16>,
261    /// Minimum containment class this session must achieve, already combined across every
262    /// source that asked for one (manifest / workspace config / `--containment`) by taking the
263    /// strongest. Required rather than optional: "nobody declared anything" is
264    /// [`ContainmentClass::Advisory`], an explicit floor every host clears, not an absence.
265    /// `stage_session` refuses to stage when the host cannot meet it.
266    pub declared_containment_floor: murmur_artifact::ContainmentClass,
267}
268
269pub struct StagedSession {
270    pub session_id: String,
271    pub workdir: PathBuf,
272    /// User project directory (or session dir if no --workdir). WASM tools see this at ".".
273    pub accessible_workdir: std::path::PathBuf,
274    pub(crate) manifest_dir: PathBuf,
275    pub(crate) capsule_name: String,
276    pub(crate) capsule_version: String,
277    pub(crate) capsule_url: String,
278    pub resolved_lock_artifacts: Vec<ResolvedLockArtifact>,
279    pub(crate) installed_artifacts: Vec<InstalledArtifactSummary>,
280    pub(crate) inference: Option<InferenceConfig>,
281    /// Copied from [`StageRequest::system_prompt_overridden`] — the only record left that the
282    /// prompt in `inference` came from `--system-prompt` and not from the manifest. Passed to
283    /// `TraceWriter::open`, which turns it into `session_start.system_prompt_source`.
284    pub(crate) system_prompt_overridden: bool,
285    pub(crate) context: Option<ContextConfig>,
286    pub(crate) engine: Engine,
287    /// `None` for manifest-only agent capsules; `Some` for script capsules with a WASM component.
288    pub(crate) capsule_component: Option<Component>,
289    pub(crate) tool_components: HashMap<String, Component>,
290    /// Per-artifact narrowing for the tool/driver dispatch path, keyed by artifact name and
291    /// lowered at staging time from the operator's own manifest entries. Holds an entry only
292    /// for artifacts that actually declared a `capabilities:` block — a name absent from this
293    /// map (the overwhelmingly common case) runs on the full capsule ceiling, exactly as
294    /// before per-artifact narrowing existed.
295    pub(crate) artifact_grants: HashMap<String, ToolCapabilityGrant>,
296    pub(crate) hook_components: Vec<StagedHookArtifact>,
297    pub(crate) allowlisted_tools: HashSet<String>,
298    pub(crate) capability_policy: CapabilityPolicy,
299    pub(crate) otel_endpoint: Option<String>,
300    pub(crate) eval_config_json: Option<String>,
301    pub(crate) case_id: Option<String>,
302    pub(crate) dataset_id: Option<String>,
303    /// Resolved lifecycle config (manifest + override already applied).
304    pub(crate) lifecycle: LifecycleConfig,
305    /// Whether to capture tool output in trace events (resolved from manifest trace config).
306    pub(crate) trace_include_tool_output: bool,
307    /// Address the HTTP server is bound to (copied from StageRequest::bind_addr).
308    pub(crate) bind_addr: String,
309    /// Internal port from the manifest (copied from StageRequest::internal_port).
310    pub(crate) internal_port: Option<u16>,
311    /// Floor this session was staged against (copied from
312    /// StageRequest::declared_containment_floor).
313    pub(crate) declared_containment_floor: murmur_artifact::ContainmentClass,
314    /// The complete effective grant set this session was staged with — the same report
315    /// `mur run --explain-scope --json` prints, built by `containment::scope_report_for_tier`
316    /// from this session's own policy, declared floor and single host probe. Written verbatim to
317    /// `trace.jsonl`'s `session_start` as `effective_grants`, and the source of the
318    /// `containment_declared`/`containment_achieved`/`workdir_exec` fields alongside it, so the
319    /// summary and the full report cannot disagree.
320    ///
321    /// Replaces a former separate `achieved_containment` field: what the host actually provided
322    /// is `scope_report.achieved_containment`, and keeping a second copy beside it only created a
323    /// way for the two to drift.
324    pub(crate) scope_report: crate::containment::ScopeReport,
325    /// Registry used to resolve this session's artifacts, retained so `manage.pull()` can
326    /// resolve additional artifacts at runtime after staging has completed.
327    pub(crate) registry: Arc<dyn Registry>,
328    /// Keeps this session's epoch ticker running: without it no epoch deadline set on any
329    /// store built from `engine` can ever fire. Held here (rather than detached) so ticking
330    /// stops when the session's `StagedSession` drops. `launch_session` moves the other
331    /// fields out one by one, which leaves this one in place until the session returns.
332    pub(crate) _epoch_ticker: EpochTicker,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct LaunchResult {
337    pub session_id: String,
338    pub workdir: PathBuf,
339}
340
341pub fn capability_policy_from_runtime_manifest(
342    manifest: &murmur_artifact::RuntimeManifest,
343) -> CapabilityPolicy {
344    let caps = manifest.capabilities.as_ref();
345    let network_allow = caps
346        .and_then(|c| c.network.as_ref())
347        .map(|network| network.allow.clone())
348        .unwrap_or_default();
349    // Absent `capabilities.network` block, or absent key within it, both mean denied.
350    let unix_sockets_allowed = caps
351        .and_then(|c| c.network.as_ref())
352        .is_some_and(|network| network.unix_sockets);
353
354    let filesystem_scope = caps
355        .and_then(|c| c.filesystem.as_ref())
356        .and_then(|filesystem| filesystem.scope.clone());
357    // Absent `capabilities.filesystem` block, or absent key within it, both mean denied — the same
358    // shape as `unix_sockets_allowed` above, for the same reason: the widening has to be declared.
359    let workdir_exec_allowed = caps
360        .and_then(|c| c.filesystem.as_ref())
361        .is_some_and(|filesystem| filesystem.workdir_exec);
362
363    let shell_allow = caps
364        .and_then(|c| c.shell.as_ref())
365        .map(|shell| shell.allow.clone())
366        .unwrap_or_default();
367    let shell_strip_env = caps
368        .and_then(|c| c.shell.as_ref())
369        .and_then(|shell| shell.strip_env.clone())
370        .unwrap_or_default();
371    let shell_baseline_env = caps
372        .and_then(|c| c.shell.as_ref())
373        .and_then(|shell| shell.baseline_env.clone())
374        .unwrap_or_default();
375    let shell_interpreter_runtime = caps
376        .and_then(|c| c.shell.as_ref())
377        .map(|shell| shell.interpreter_runtime.clone())
378        .unwrap_or_default();
379    let shell_staged_runtime = caps
380        .and_then(|c| c.shell.as_ref())
381        .map(|shell| shell.staged_runtime.clone())
382        .unwrap_or_default();
383
384    let spawn_allow = caps
385        .and_then(|c| c.spawn.as_ref())
386        .map(|spawn| spawn.allow.clone())
387        .unwrap_or_default();
388
389    let env_allow = caps
390        .and_then(|c| c.env.as_ref())
391        .map(|env| env.allow.clone())
392        .unwrap_or_default();
393
394    let limits = ExecutionLimits::resolve(caps.and_then(|c| c.limits.as_ref()));
395    let declared_deadline_seconds = caps
396        .and_then(|c| c.limits.as_ref())
397        .and_then(|l| l.deadline_seconds);
398
399    let resources = crate::resources::resolve(caps.and_then(|c| c.resources.as_ref()));
400
401    CapabilityPolicy {
402        network_allow,
403        unix_sockets_allowed,
404        filesystem_scope,
405        workdir_exec_allowed,
406        shell_allow,
407        spawn_allow,
408        shell_strip_env,
409        shell_baseline_env,
410        shell_interpreter_runtime,
411        shell_staged_runtime,
412        env_allow,
413        limits,
414        resources,
415        containment_floor: caps.and_then(|c| c.containment).unwrap_or_default(),
416        declared_deadline_seconds,
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn policy_for(manifest_yaml: &str) -> CapabilityPolicy {
425        let manifest = murmur_artifact::RuntimeManifest::from_yaml_str(manifest_yaml)
426            .expect("manifest fixture must parse");
427        capability_policy_from_runtime_manifest(&manifest)
428    }
429
430    /// A manifest that sets no deadline gives hook calls the hook-specific default while the
431    /// capsule's own guests keep the capsule-wide one — one manifest, two budgets.
432    #[test]
433    fn a_silent_manifest_gives_hook_calls_the_lower_deadline() {
434        let policy = policy_for(
435            r#"
436name: cap
437version: 0.1.0
438artifacts: []
439"#,
440        );
441
442        assert_eq!(
443            policy.limits.deadline_seconds,
444            crate::limits::DEFAULT_DEADLINE_SECONDS
445        );
446        assert_eq!(
447            policy.hook_limits().deadline_seconds,
448            crate::limits::HOOK_DEFAULT_DEADLINE_SECONDS
449        );
450        // The default policy a hand-built fixture gets must agree with the silent-manifest one.
451        assert_eq!(
452            CapabilityPolicy::default().hook_limits().deadline_seconds,
453            crate::limits::HOOK_DEFAULT_DEADLINE_SECONDS
454        );
455    }
456
457    /// An explicit `deadline_seconds` applies to every guest, hooks included — the hook
458    /// default only fills a silence.
459    #[test]
460    fn an_explicit_deadline_applies_to_hook_calls_too() {
461        let policy = policy_for(
462            r#"
463name: cap
464version: 0.1.0
465artifacts: []
466capabilities:
467  limits:
468    deadline_seconds: 120
469"#,
470        );
471
472        assert_eq!(policy.declared_deadline_seconds, Some(120));
473        assert_eq!(policy.limits.deadline_seconds, 120);
474        assert_eq!(policy.hook_limits().deadline_seconds, 120);
475    }
476
477    /// `capabilities.shell.staged_runtime` lowers onto the policy verbatim — binary, source path
478    /// and pin all survive, since the pin is what a human later compares across two hosts.
479    #[test]
480    fn staged_runtime_grants_lower_onto_the_policy() {
481        let policy = policy_for(
482            r#"
483name: cap
484version: 0.1.0
485artifacts: []
486capabilities:
487  containment: sealed
488  shell:
489    allow:
490      - python3
491    staged_runtime:
492      - binary: python3
493        source_path: /opt/testbed/conda/envs/django__django
494        pin: conda-4.10.3/python-3.9.19
495"#,
496        );
497
498        assert_eq!(
499            policy.shell_staged_runtime,
500            vec![murmur_artifact::StagedRuntimeGrant {
501                binary: "python3".to_string(),
502                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
503                pin: "conda-4.10.3/python-3.9.19".to_string(),
504            }]
505        );
506        // The two mechanisms stay separate all the way down: lowering one must not populate the
507        // other.
508        assert!(policy.shell_interpreter_runtime.is_empty());
509    }
510
511    /// A shell block that declares no staged runtime lowers to an empty list, not to a default
512    /// grant of any kind.
513    #[test]
514    fn absent_staged_runtime_lowers_to_empty() {
515        let policy = policy_for(
516            r#"
517name: cap
518version: 0.1.0
519artifacts: []
520capabilities:
521  shell:
522    allow:
523      - bash
524"#,
525        );
526
527        assert!(policy.shell_staged_runtime.is_empty());
528    }
529
530    /// The default every existing manifest gets. Three separate "nothing was declared" shapes —
531    /// no `capabilities:` at all, no `filesystem:` within it, and a `filesystem:` that only sets
532    /// `scope` — must all lower to denied, because each one is a real manifest in the wild and any
533    /// of them silently keeping workdir `Execute` would reopen the rename-to-an-allowed-basename
534    /// bypass this default exists to close.
535    #[test]
536    fn workdir_exec_denied_for_every_shape_of_undeclared() {
537        for manifest_yaml in [
538            r#"
539name: cap
540version: 0.1.0
541artifacts: []
542"#,
543            r#"
544name: cap
545version: 0.1.0
546artifacts: []
547capabilities:
548  network:
549    allow:
550      - https://api.anthropic.com
551"#,
552            r#"
553name: cap
554version: 0.1.0
555artifacts: []
556capabilities:
557  filesystem:
558    scope: workdir
559"#,
560        ] {
561            let policy = policy_for(manifest_yaml);
562            assert!(
563                !policy.workdir_exec_allowed,
564                "an undeclared workdir_exec must lower to denied: {manifest_yaml}"
565            );
566        }
567    }
568
569    #[test]
570    fn workdir_exec_denied_when_declared_false() {
571        let policy = policy_for(
572            r#"
573name: cap
574version: 0.1.0
575artifacts: []
576capabilities:
577  filesystem:
578    workdir_exec: false
579"#,
580        );
581
582        assert!(!policy.workdir_exec_allowed);
583    }
584
585    #[test]
586    fn workdir_exec_allowed_when_declared_true() {
587        let policy = policy_for(
588            r#"
589name: cap
590version: 0.1.0
591artifacts: []
592capabilities:
593  filesystem:
594    scope: workdir
595    workdir_exec: true
596"#,
597        );
598
599        assert!(policy.workdir_exec_allowed);
600        // The two filesystem keys stay independent: lowering one must not disturb the other.
601        assert_eq!(policy.filesystem_scope.as_deref(), Some("workdir"));
602    }
603
604    /// A manifest with no `capabilities:` block at all denies unix sockets — the field must not
605    /// pick up any implicit widening from the "nothing declared" path.
606    #[test]
607    fn unix_sockets_denied_when_capabilities_block_is_absent() {
608        let policy = policy_for(
609            r#"
610name: cap
611version: 0.1.0
612artifacts: []
613"#,
614        );
615
616        assert!(!policy.unix_sockets_allowed);
617    }
618
619    /// A declared `network:` block that never mentions the key still denies.
620    #[test]
621    fn unix_sockets_denied_when_network_block_omits_the_key() {
622        let policy = policy_for(
623            r#"
624name: cap
625version: 0.1.0
626artifacts: []
627capabilities:
628  network:
629    allow:
630      - https://api.anthropic.com
631"#,
632        );
633
634        assert!(!policy.unix_sockets_allowed);
635        assert_eq!(
636            policy.network_allow,
637            vec!["https://api.anthropic.com".to_string()]
638        );
639    }
640
641    #[test]
642    fn unix_sockets_denied_when_declared_false() {
643        let policy = policy_for(
644            r#"
645name: cap
646version: 0.1.0
647artifacts: []
648capabilities:
649  network:
650    unix_sockets: false
651"#,
652        );
653
654        assert!(!policy.unix_sockets_allowed);
655    }
656
657    #[test]
658    fn unix_sockets_allowed_when_declared_true() {
659        let policy = policy_for(
660            r#"
661name: cap
662version: 0.1.0
663artifacts: []
664capabilities:
665  network:
666    unix_sockets: true
667"#,
668        );
669
670        assert!(policy.unix_sockets_allowed);
671    }
672
673    /// The opt-in is independent of the IP allowlist in both directions: a non-empty `allow`
674    /// does not imply unix sockets, and `unix_sockets: true` does not imply any IP destination.
675    #[test]
676    fn unix_sockets_opt_in_is_independent_of_the_ip_allowlist() {
677        let policy = policy_for(
678            r#"
679name: cap
680version: 0.1.0
681artifacts: []
682capabilities:
683  network:
684    unix_sockets: true
685    allow:
686      - https://api.anthropic.com
687"#,
688        );
689
690        assert!(policy.unix_sockets_allowed);
691        assert_eq!(
692            policy.network_allow,
693            vec!["https://api.anthropic.com".to_string()]
694        );
695    }
696
697    /// `CapabilityPolicy::default()` is what the sandbox tests and every `..Default::default()`
698    /// call site start from; it must deny, not inherit whatever a future field ordering implies.
699    #[test]
700    fn default_capability_policy_denies_unix_sockets() {
701        assert!(!CapabilityPolicy::default().unix_sockets_allowed);
702    }
703}