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    ArtifactRuntime, ContextConfig, HookConfig, InferenceConfig, LifecycleConfig,
9    LifecycleOverride, Registry,
10};
11use wasmtime::{component::Component, Engine};
12
13use crate::{
14    bindings::host::murmur::tool::run::ToolResult,
15    formation::FormationId,
16    formation_credentials::FormationMember,
17    hooks::ShellDispatchInfo,
18    limits::{CallDeadlines, EpochTicker, ExecutionLimits},
19    network_policy::{HookCapabilityGrant, ToolCapabilityGrant},
20    spawn_credential::{SpawnApproval, SpawnCredential},
21};
22
23pub(crate) struct DispatchOutcome {
24    pub result: ToolResult,
25    pub shell: Option<ShellDispatchInfo>,
26    /// Set instead of [`Self::shell`] when the command outran its grace period and was demoted
27    /// to the background. The two are never both `Some`: a demoted command has no exit code yet,
28    /// which is exactly why `HookEvent::Shell` does not fire for one.
29    pub detached: Option<crate::detached::DetachedDispatchInfo>,
30    /// True when the dispatch was served by reading a SKILL.md file rather than running
31    /// a binary or WASM component. Used by the agent loop to write a `skill_call` trace
32    /// event instead of a `tool_call` event.
33    pub is_skill: bool,
34    /// The fence source `result.data` was wrapped under, or `None` when the result carries no
35    /// fence. Set by [`crate::runtime::CapsuleStoreState::dispatch_agent_tool_async`] on the
36    /// same branch that applies the fence, so the label and the wrapping cannot drift: a caller
37    /// reading this never re-derives a source name by parsing the content back.
38    ///
39    /// `Some("tool:<name>")` on every non-skill outcome, `Some("skill:<name>")` on a skill whose
40    /// `murmur.lock` origin is `runtime`, and `None` on an operator-declared skill and on every
41    /// outcome that leaves the private unfenced dispatch — that is,
42    /// [`crate::runtime::CapsuleStoreState::dispatch_submit_plan`]'s route, whose step output is
43    /// fenced later as one field of a report.
44    pub fence_source: Option<String>,
45    /// Set when this dispatch failed in a way that ends the *session* rather than just this
46    /// tool call — today only a `sealed` composed-root construction failure
47    /// ([`crate::shell::ShellExecError::session_fatal`]). `result` still describes the failed
48    /// call so the trace records what was attempted; the agent turn loop then returns this
49    /// error instead of feeding the failure back to the model for another turn.
50    ///
51    /// A dispatch that merely failed leaves this `None`: a capsule reacting to a broken tool
52    /// call is ordinary, whereas a capsule continuing to run after its declared containment
53    /// boundary stopped being establishable is the silent-degradation failure mode the whole
54    /// containment-class mechanism exists to prevent.
55    pub fatal: Option<crate::errors::RuntimeError>,
56    /// The runtime's own words about this call, appended by
57    /// [`crate::runtime::CapsuleStoreState::dispatch_agent_tool_async`] after the fence's closing
58    /// marker, so the model reads them as the runtime's rather than as the tool's data.
59    ///
60    /// `Some` on these outcomes:
61    ///
62    /// * a `call-member` call that started, carrying [`crate::member_call::started_note`];
63    /// * an accepted `end-without-answer`;
64    /// * a pull through `pull-artifact`, and a `remove-artifact` that removed something, each
65    ///   saying when the tool list changes;
66    /// * a shell call that finished and whose failure was attributed to a
67    ///   `capabilities.resources` limit, carrying [`crate::resources::resource_limit_line`].
68    ///
69    /// `None` on every other outcome. The unfenced route of
70    /// [`crate::runtime::CapsuleStoreState::dispatch_submit_plan`] never reads it.
71    ///
72    /// The session's one deadline notice is not a note: it is appended after the note, by
73    /// [`crate::runtime::CapsuleStoreState::deliver_deadline_notice`], on whichever dispatched
74    /// outcome is first handed back inside the notice margin.
75    pub runtime_note: Option<String>,
76    /// The runtime's own text, unfenced, when it could not run the call: the
77    /// [`crate::shell::ShellExecError`] a shell dispatch returned, the same text
78    /// `result.data` carries before the fence wraps it. The agent loop records it as the
79    /// `tool_call` trace record's `error`.
80    ///
81    /// `None` whenever the tool ran, including when the tool itself reported failure or exited
82    /// non-zero.
83    pub runtime_error: Option<String>,
84}
85
86impl DispatchOutcome {
87    pub(crate) fn tool(result: ToolResult) -> Self {
88        Self {
89            result,
90            shell: None,
91            detached: None,
92            is_skill: false,
93            fence_source: None,
94            fatal: None,
95            runtime_note: None,
96            runtime_error: None,
97        }
98    }
99
100    pub(crate) fn skill(result: ToolResult) -> Self {
101        Self {
102            result,
103            shell: None,
104            detached: None,
105            is_skill: true,
106            fence_source: None,
107            fatal: None,
108            runtime_note: None,
109            runtime_error: None,
110        }
111    }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ArtifactRequest {
116    pub name: String,
117    pub version: String,
118    pub runtime: ArtifactRuntime,
119    /// Optional local source path (skill artifacts only). When set, the runtime resolves the
120    /// skill's `SKILL.md` directly from this path and skips registry resolution entirely.
121    /// Relative paths resolve against the manifest: `StageRequest::manifest_dir`, or the
122    /// installed package of `StageRequest::capsule_artifact`.
123    pub source: Option<String>,
124    /// Per-artifact capability grant copied verbatim from this artifact's entry in the
125    /// capsule operator's own manifest (`murmur_artifact::RuntimeArtifact::capabilities`).
126    /// Consumed for `runtime: hook` (default-deny baseline, `None` = deny everything) and for
127    /// `runtime: tool`/`runtime: driver` (ceiling baseline, `None` = the capsule-wide policy
128    /// unchanged); the manifest parser rejects the key on `runtime: skill`. Never sourced
129    /// from the artifact's own bundled `murmur.yaml`, so an artifact cannot self-grant.
130    pub capabilities: Option<murmur_artifact::Capabilities>,
131    /// What the runtime does when this hook's async job queue is full, copied verbatim from
132    /// `on_overflow:` on this artifact's entry in the capsule operator's own manifest — the
133    /// same operator-only sourcing rule `capabilities` above follows. Consumed for
134    /// `runtime: hook` and inert for every other role (the manifest parser rejects the key
135    /// there); inert too for a hook that turns out to be `execution_mode: blocking`, which is
136    /// never queued.
137    pub on_overflow: murmur_artifact::HookOverflowPolicy,
138    /// This artifact's operator-declared `config:` block, copied verbatim from its entry in the
139    /// capsule operator's own manifest (`murmur_artifact::RuntimeArtifact::config`) — the same
140    /// operator-only sourcing rule `capabilities` above follows, so an artifact pulled from a
141    /// registry cannot configure itself.
142    ///
143    /// Lowered to compact JSON at staging by [`crate::artifact_config::lower_artifact_config`] and
144    /// delivered to this artifact alone as [`crate::artifact_config::ARTIFACT_CONFIG_ENV`]. `None`
145    /// means the variable is absent from the artifact's environment. Consumed for `runtime: hook`,
146    /// `runtime: tool` and `runtime: driver`; the manifest parser rejects the
147    /// key on `runtime: skill`.
148    pub config: Option<serde_norway::Value>,
149    /// This artifact's operator-declared `gateway:` block, copied verbatim from its entry in the
150    /// capsule operator's own manifest (`murmur_artifact::RuntimeArtifact::gateway`) — the same
151    /// operator-only sourcing rule `capabilities` above follows. Built into the artifact's
152    /// credential gateway at staging, which is also where the artifact's own `upstream_auth:`
153    /// declaration is read; an artifact without this block is never asked for one.
154    pub gateway: Option<murmur_artifact::ArtifactGateway>,
155}
156
157/// What `murmur.lock` pins one artifact at, already narrowed to this host.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct LockExpectation {
160    pub name: String,
161    pub resolved_version: String,
162    /// The hash the resolved payload must have: the lock entry's `sha256.any`.
163    pub sha256: String,
164    /// Who wrote the lock entry. A [`murmur_artifact::LockOrigin::Runtime`] pin stages only as a
165    /// tool or skill without a `gateway:` block; staging refuses anything else with
166    /// [`crate::RuntimeError::RuntimeOriginNotDeclarable`].
167    pub origin: murmur_artifact::LockOrigin,
168}
169
170/// What staging actually resolved, for the caller to write back into `murmur.lock`.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct ResolvedLockArtifact {
173    pub name: String,
174    pub resolved_version: String,
175    /// Hash of the payload that was resolved, pinned as the lock entry's `sha256.any`.
176    pub sha256: String,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Default)]
180pub struct CapabilityPolicy {
181    pub network_allow: Vec<String>,
182    /// From `capabilities.peer_fetch.allow`: the peers this capsule may redeem a peer-file handle
183    /// against. Empty by default, which is deny — and which is also why no `fetch-peer-file` tool
184    /// manifest is written and the capsule's model never sees the tool exists.
185    ///
186    /// A separate list from [`Self::network_allow`], enforced with the same rule matcher but
187    /// never merged into it. Ingesting a peer's bytes lands a file in this capsule's own workdir,
188    /// so it is a prompt-injection surface and deserves its own operator control; a destination
189    /// in `network.allow` is not redeemable unless it also appears here, and declaring it here
190    /// does not widen `network.allow`.
191    pub peer_fetch_allow: Vec<String>,
192    /// From `capabilities.network.unix_sockets`. `false` by default, which makes the shell
193    /// subprocess tree's `socket(AF_UNIX, ...)` calls fail with `EACCES` at the kernel level on
194    /// both Linux tiers — `network_allow` above governs IP destinations only and would otherwise
195    /// leave a local daemon socket (`/var/run/docker.sock` — host root) completely unmediated.
196    /// `true` simply omits that seccomp rule; it is capsule-wide, not per-socket-path. See
197    /// `sandbox::denied_socket_domains`.
198    pub unix_sockets_allowed: bool,
199    pub filesystem_scope: Option<String>,
200    /// From `capabilities.filesystem.workdir_exec`. `false` by default, which withholds the
201    /// Landlock `Execute` right from the workdir's own `PathBeneath` rule — see
202    /// `sandbox::linux_enforce::workdir_access_rights`. With it withheld the kernel refuses to exec
203    /// anything under the workdir on the resolved path itself, so `shell_allow` is enforced
204    /// completely: a binary copied or compiled into the workdir cannot run under *any* name.
205    ///
206    /// `true` gives the right back for compile-and-run workflows and accepts, explicitly, that
207    /// `shell_allow` is then unenforceable for anything inside the workdir. That is why this field
208    /// is the one *grant* in this struct that also lowers the session's achieved containment class
209    /// (see `crate::containment::achieved_containment_class`) — nothing else here can.
210    pub workdir_exec_allowed: bool,
211    /// From `capabilities.filesystem.read_only`: workdir subtrees the capsule may read but must
212    /// not write, in the same vocabulary [`Self::filesystem_scope`] uses. Empty by default, which
213    /// is the whole workdir writable.
214    ///
215    /// Carried here as the declared strings and lowered once at staging into
216    /// `crate::protected_paths::ProtectedPaths`, which is what the dispatch check consults — so a
217    /// malformed entry refuses the launch rather than one call at a time. Unlike every other field
218    /// here this one grants nothing: it can only subtract from what the workdir preopen already
219    /// allows.
220    pub read_only_paths: Vec<String>,
221    pub shell_allow: Vec<String>,
222    pub spawn_allow: Vec<String>,
223    /// From `capabilities.spawn.workdir_snapshot`: the full names in [`Self::spawn_allow`] whose
224    /// every delegation starts in a copy of this session's work folder, made by this runtime
225    /// before the child process starts. Empty by default, which starts every child in an empty
226    /// folder.
227    pub spawn_workdir_snapshot: Vec<String>,
228    pub shell_strip_env: Vec<String>,
229    pub shell_baseline_env: Vec<String>,
230    /// Typed interpreter-runtime grants from `capabilities.shell.interpreter_runtime`: each
231    /// names an already-allowlisted binary and the exact host directories outside the workdir
232    /// its import machinery needs, with a per-directory `list_dir` enumerability flag. Empty
233    /// unless the manifest declares them. Consumed on `KernelFull` to add narrow Landlock
234    /// grants (see `sandbox::resolve_interpreter_runtime_grants`) and to fire `W-SEC-009`.
235    pub shell_interpreter_runtime: Vec<murmur_artifact::InterpreterRuntimeGrant>,
236    /// Typed staged-runtime grants from `capabilities.shell.staged_runtime`: each names an
237    /// already-allowlisted binary, the absolute host path of a pinned runtime tree, and the `pin`
238    /// identifying that tree's build. Empty unless the manifest declares them.
239    ///
240    /// Consumed at staging to enforce that declaring one requires an effective `sealed` floor (see
241    /// `crate::staged_runtime::check_staged_runtime_floor`) and to render the `staged runtime`
242    /// section of `--explain-scope`. Per-binary mutually exclusive with
243    /// [`Self::shell_interpreter_runtime`], which the manifest parser already guarantees.
244    pub shell_staged_runtime: Vec<murmur_artifact::StagedRuntimeGrant>,
245    /// The runtime directories of each `shell_allow` binary the runtime recognises as an
246    /// interpreter, as that interpreter reported them (see [`crate::interpreter_runtime`]). Not a
247    /// manifest field: empty from [`capability_policy_from_runtime_manifest`], filled once by
248    /// `stage_session` and by `mur run --explain-scope` through
249    /// [`crate::interpreter_runtime::derive_interpreter_runtimes`]. Each becomes a readable,
250    /// non-executable Landlock grant (`sandbox::resolve_derived_interpreter_grants`) and, on
251    /// `sealed`, an optional composed-root bind.
252    pub shell_derived_interpreter_runtime:
253        Vec<crate::interpreter_runtime::DerivedInterpreterRuntime>,
254    /// The host's TLS trust store, as staging found it for a capsule with a non-empty
255    /// `shell_allow` and a non-empty `network_allow` (see [`crate::trust_store`]). Not a manifest
256    /// field: empty from [`capability_policy_from_runtime_manifest`], filled once by
257    /// `stage_session` and by `mur run --explain-scope` through
258    /// [`crate::trust_store::derive_trust_store`]. Each becomes a readable, non-executable
259    /// Landlock grant (`sandbox::resolve_trust_store_grants`), listable for a directory.
260    pub shell_trust_store: Vec<crate::trust_store::TrustStorePath>,
261    /// What the host programs the capsule's tools run need, as each tool's own `programs:` block
262    /// declares it, for the tools pinned with an `operator` lock origin and the programs
263    /// `shell_allow` grants (see [`crate::declared_needs`]). Not a manifest field: empty from
264    /// [`capability_policy_from_runtime_manifest`], filled once by `stage_session` and by
265    /// `mur run --explain-scope` through [`crate::declared_needs::derive_declared_needs`]. Each
266    /// granted path becomes a readable, non-executable Landlock grant
267    /// (`sandbox::resolve_declared_need_grants`), listable for a directory, and on `sealed` an
268    /// optional composed-root mirror at its own path.
269    pub shell_declared_needs: Vec<crate::declared_needs::ResolvedProgramNeeds>,
270    /// Which copy of each program the capsule's tools pin a download of runs, for the tools
271    /// pinned with an `operator` lock origin and the programs `shell_allow` grants (see
272    /// [`crate::pinned_programs`]). Not a manifest field: empty from
273    /// [`capability_policy_from_runtime_manifest`], filled once by `stage_session` and by
274    /// `mur run --explain-scope` through [`crate::pinned_programs::derive_pinned_programs`]. A
275    /// `pinned` choice puts its `bin/` folder last on every command's `PATH`, resolves the exec
276    /// allowlist there too, grants its store entry listable and executable (never writable) and,
277    /// on `sealed`, binds that entry read-only at its own path.
278    pub shell_pinned_programs: Vec<crate::pinned_programs::ResolvedPinnedProgram>,
279    /// The host folders `~/.murmur/config.yaml`'s `host_paths:` lends every contained capsule on
280    /// this host, checked on this host, in config order (see [`crate::host_paths`]). Not a
281    /// manifest field: empty from [`capability_policy_from_runtime_manifest`], filled by `mur run`
282    /// (and `--explain-scope`) from the global config alone. On `landlock+seccomp` each becomes a
283    /// Landlock rule for commands and plan shell steps; on `sealed` also a required
284    /// bind at its own path. Never executable. WASM tools' preopens do not include them.
285    pub host_paths: Vec<crate::host_paths::HostPath>,
286    /// `capabilities.harness.paths`, verbatim and in declaration order — the host directories this
287    /// `transport: process` capsule grants its harness, beyond what its class gives every command.
288    /// Empty by default, which is deny. A grant here reaches the process harness and what it runs,
289    /// never a shell command. The allow rule, resolution and reporting live in
290    /// [`crate::harness_needs`]; this is only the declared manifest entries.
291    pub harness_paths: Vec<murmur_artifact::HarnessPathEntry>,
292    /// Whether `inference.transport` is `process`. Not a grant: a fact the subprocess-route and
293    /// harness-grant resolution read, so a process capsule needs the network namespace and cgroup
294    /// scope a subprocess capsule needs ([`crate::runtime::has_subprocess_route_at_staging`]) and
295    /// `capabilities.harness.paths` is valid only where it is `true`.
296    pub process_transport: bool,
297    /// Host env var names a WASM guest may observe, from `capabilities.env.allow`. Empty by
298    /// default: a guest sees only the runtime's own `MURMUR_*` injection unless the manifest
299    /// names a variable here. `shell_strip_env` and the credential patterns still apply on
300    /// top, so a credential-shaped name declared here is stripped anyway.
301    pub env_allow: Vec<String>,
302    /// Execution limits applied to every guest store in the session. Always fully
303    /// resolved — `capability_policy_from_runtime_manifest` substitutes a default for
304    /// each field `capabilities.limits` omits, so there is no "unset" state here and a
305    /// silent manifest yields [`ExecutionLimits::default`] rather than no limits.
306    pub limits: ExecutionLimits,
307    /// OS-level bounds applied to every subprocess the session spawns — rlimits, the
308    /// Linux cgroup v2 scope, and the workdir-size ceiling. Resolved on exactly the same terms
309    /// as `limits` above (each omitted `capabilities.resources` field replaced by its default,
310    /// no "unset" state), but a completely different subject: `limits` bounds a WASM guest
311    /// inside its wasmtime store, this bounds the processes the host forks. See
312    /// [`crate::resources`].
313    pub resources: crate::resources::HostResourceLimits,
314    /// The manifest's own `capabilities.resources` block, every omitted field still `None`.
315    /// [`Self::resources`] is what a contained session applies; an unconfined session (a
316    /// [`Self::containment_floor`] of `ContainmentClass::Unconfined`) applies only what this
317    /// declares, creates a cgroup scope only when it declares a `cgroup_*` field and runs the
318    /// workdir guard only when it declares `workdir_max_bytes`.
319    pub declared_resources: murmur_artifact::ResourceCapabilities,
320    /// The containment class this capsule asked for, from `capabilities.containment`.
321    ///
322    /// Unlike every other field here it is a *requirement*, not a grant — and unlike
323    /// `StageRequest::declared_containment_floor` it is only the manifest's own vote, before the
324    /// workspace config and `--containment` are folded in. `stage_session` overwrites it with the
325    /// combined floor as soon as that is known, so anything reading it off a `StagedSession` sees
326    /// the effective value; the manifest-only value exists so a `CapabilityPolicy` built straight
327    /// from a manifest is still self-consistent.
328    ///
329    /// Read by `sandbox::ShellEnforcement::resolve` to decide whether a sealed-capable host
330    /// actually installs a composed root for this session — a capsule that declared `scoped` must
331    /// keep getting `scoped`'s mechanism. See `sandbox::applied_tier`.
332    pub containment_floor: murmur_artifact::ContainmentClass,
333    /// Whether the capsule's own top-level `capabilities.state` block was declared.
334    ///
335    /// Not a grant, and the only field here that is not: a durable state store is granted per
336    /// *artifact*, so a capsule-wide declaration reaches nothing at all. It is recorded so
337    /// `stage_session` can say that once (`W-SEC-014`) instead of leaving an operator to discover
338    /// an empty directory, and it is read by nothing else.
339    pub state_declared: bool,
340    /// Recorded for the same reason [`Self::state_declared`] is, and applied just as little: the
341    /// `murmur:conversation/read` grant is per artifact, so a capsule-wide declaration reaches
342    /// nothing and `stage_session` says so once (`W-SEC-016`).
343    pub conversation_declared: bool,
344    /// From `capabilities.plan.submit`: whether this capsule's model is offered the
345    /// runtime-provided `submit-plan` tool. `false` by default, which is deny — and which is also
346    /// why no `submit-plan` tool manifest is written and the capsule's model never sees the tool
347    /// exists.
348    ///
349    /// It grants no reach of its own: every step of a submitted plan runs through this same
350    /// session's tool dispatch, its shell allowlist and its spawn grant, so a plan reaches exactly
351    /// what the model could already call one call at a time.
352    pub plan_submit: bool,
353    /// From `capabilities.install.skill`: the entries naming which skill artifacts a pull
354    /// (`manage.pull` or the `pull-artifact` tool) may install into this session, verbatim and in
355    /// declaration order. Empty by default, which is deny. With [`Self::install_tool`] also
356    /// empty, every pull is refused.
357    ///
358    /// A pulled skill gains no grant of its own; see `install_grant`.
359    pub install_skill: Vec<String>,
360    /// From `capabilities.install.tool`: the entries naming which tool artifacts
361    /// a pull may install, verbatim and in declaration order. Empty by default, which is deny.
362    ///
363    /// A pulled tool joins the session's dispatch allowlist and dispatches on this policy's own
364    /// ceiling; it gains no grant of its own.
365    pub install_tool: Vec<String>,
366    /// From `capabilities.install.from`: `true` for `[stores, sources]`, under which a pull the
367    /// stores cannot answer is fetched from the scope's GitHub source. `false` by default.
368    pub install_from_sources: bool,
369    /// `capabilities.limits.deadline_seconds` exactly as the manifest declared it — `None`
370    /// when it declared nothing. Retained undefaulted alongside the fully-resolved `limits`
371    /// above purely so hook calls can apply their own, lower default without mistaking an
372    /// explicit `600` for silence. See [`Self::hook_limits`].
373    pub declared_deadline_seconds: Option<u64>,
374    /// Whether the capsule runs a WASM guest in a guest worker process: a registry-resolved
375    /// `runtime: tool`, `runtime: driver` or `runtime: hook` entry, a `capabilities.install.tool`
376    /// grant (a pulled tool runs in the tools worker). A worker is a subprocess spawned under the
377    /// session's containment, so this counts as a subprocess route for
378    /// [`crate::runtime::has_subprocess_route_at_staging`]. A script capsule's own component also
379    /// runs in a worker but is not counted: its worker's spawn fails (`E-RUN-066`) where the
380    /// session's namespace cannot be made, rather than running unconfined.
381    pub guest_workers: bool,
382}
383
384impl CapabilityPolicy {
385    /// Execution limits for hook lifecycle calls — [`Self::limits`] with the hook-specific
386    /// deadline default substituted when the manifest declared none.
387    ///
388    /// A hook call is one bounded piece of work, so it does not inherit the capsule-wide
389    /// ten-minute budget that exists for a capsule's whole `run`: a wedged hook would
390    /// otherwise stall the session for most of that, every event. An explicitly declared
391    /// `deadline_seconds` still wins, for hooks as for every other guest.
392    #[must_use]
393    pub fn hook_limits(&self) -> ExecutionLimits {
394        self.limits.for_hook_calls(self.declared_deadline_seconds)
395    }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct InstalledArtifactSummary {
400    pub name: String,
401    pub version: String,
402    pub runtime: ArtifactRuntime,
403    /// Who pinned the artifact: its `murmur.lock` entry's origin, both for an artifact staged
404    /// from the lock and for one `manage.pull()` installed mid-session. An artifact staged
405    /// without a lock, and a `source:` skill, are [`murmur_artifact::LockOrigin::Operator`].
406    pub origin: murmur_artifact::LockOrigin,
407}
408
409#[derive(Clone)]
410pub(crate) struct StagedHookArtifact {
411    pub name: String,
412    /// The name this hook's `artifacts:` entry declares, scope included: what a message about a
413    /// setting on that entry names it by.
414    pub entry: String,
415    pub version: String,
416    pub component: Component,
417    /// Behavioral contract (binding/execution_mode/commit_policy) from the hook artifact's
418    /// *own* bundled manifest. Carries no capability information — see `grant`.
419    pub config: HookConfig,
420    /// What this hook is allowed to do, lowered and validated at staging time from the
421    /// operator's own manifest entry. Applied identically by all three hook instantiation
422    /// paths in `hooks.rs`; `HookCapabilityGrant::default()` is full default-deny.
423    pub grant: HookCapabilityGrant,
424    /// What to do when this hook's async job queue is full — from the operator's manifest
425    /// entry (via [`ArtifactRequest::on_overflow`]), on the same terms as `grant` and never
426    /// from `config`. Read only when `config.execution_mode` is `Async`; a blocking hook has
427    /// no queue to overflow.
428    pub on_overflow: murmur_artifact::HookOverflowPolicy,
429    /// This hook's own credential gateway, when its operator entry declares `gateway:`. Attached
430    /// to every store the hook is instantiated on, `on-stage` included. Filled once the session's
431    /// gateways are staged, before any hook runs.
432    pub gateway: Option<Arc<crate::credential_gateway::CredentialGateway>>,
433}
434
435/// How `mur run --resume` puts the loaded conversation in front of the model.
436///
437/// `Full` is often the cheaper of the two: a verbatim reload can land on the provider's own
438/// prompt cache, while compaction changes the prefix from the first altered token, guarantees a
439/// cache miss, and costs an inference call to produce the summary. `Compact` is the answer when
440/// the conversation would not fit the context window at all.
441#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
442pub enum ResumeMode {
443    #[default]
444    Full,
445    Compact,
446}
447
448/// One operator act: continue *this* conversation, whatever the capsule's own
449/// `lifecycle.conversation` policy says about carrying one between tasks.
450///
451/// Sugar over [`StageRequest::context_id`], never a second load path: the CLI resolves a session
452/// address to the context id that session ran under and fills both this and `context_id`. What
453/// this adds is the override — the record is loaded even under `lifecycle.conversation:
454/// stateless` — and the provenance the trace records.
455#[derive(Debug, Clone)]
456pub struct ResumeRequest {
457    /// The prior session's id, recorded verbatim as `session_start.resumed_from`: the id its
458    /// trace's own `session_start` carries, or the trace's directory name when it records none.
459    pub from_session: String,
460    /// The absolute directory holding the resumed trace's `trace.jsonl`: the prior session's
461    /// records directory, or wherever that trace was copied. The lost-work reconciliation reads
462    /// this file and appends its `shell_lost` markers to it.
463    pub trace_dir: PathBuf,
464    pub mode: ResumeMode,
465}
466
467#[derive(Debug, Clone)]
468pub struct StageRequest {
469    pub manifest_dir: PathBuf,
470    pub capsule_name: String,
471    pub capsule_version: String,
472    /// The installed capsule, for one launched by name (`mur run --capsule`): the full
473    /// `<scope>/<name>` it was resolved by, which the session registers with mur-roost and reports
474    /// its completion under, and the package the relative paths its manifest names are read from.
475    /// `None` for a capsule run from a project directory, which is `local/<capsule_name>` and
476    /// reads those paths from `manifest_dir`.
477    pub capsule_artifact: Option<crate::manifest_files::InstalledCapsule>,
478    pub capsule_component_bytes: Vec<u8>,
479    pub artifacts: Vec<ArtifactRequest>,
480    pub allowlisted_tools: HashSet<String>,
481    pub lock_expectations: Option<Vec<LockExpectation>>,
482    pub capability_policy: CapabilityPolicy,
483    pub inference: Option<InferenceConfig>,
484    /// The global config file a `${NAME}` in an artifact's `gateway.api_key` is looked up in first, as
485    /// `credentials.NAME`, and re-read from while the session runs. `None` looks in the launching
486    /// environment only.
487    pub credentials_file: Option<PathBuf>,
488    /// Whether `inference` above had its system-prompt declaration replaced by the caller
489    /// (`mur run --system-prompt`) rather than read from the manifest. Purely a provenance
490    /// signal for the trace: the override itself is already applied to `inference`, which by
491    /// this point is indistinguishable from a manifest that declared the same prompt inline.
492    /// `true` even when the override cleared the prompt to nothing.
493    pub system_prompt_overridden: bool,
494    pub context: Option<ContextConfig>,
495    /// Context id every `task.md` task of this launch runs under, from `mur run --context`.
496    /// `None` mints a fresh one per task. Validated by `stage_session` as one path segment: it is
497    /// a directory name in the conversation record path.
498    pub context_id: Option<String>,
499    /// `mur run --resume <session>`, already resolved: `context_id` above holds the context that
500    /// session ran under, and this carries the session's own id and the mode. `None` is every
501    /// ordinary launch. `stage_session` refuses a resume whose context kept no record on disk,
502    /// and a `--resume-mode compact` with no hook bound to `on-compaction`.
503    pub resume: Option<ResumeRequest>,
504    /// `mur run --forget-session`: drop the harness session `context_id` names before this
505    /// launch's first task, so that task starts a new conversation under the same context id.
506    /// `false` on every ordinary launch. `stage_session` refuses it for a capsule whose transport
507    /// keeps no harness session.
508    pub forget_session: bool,
509    /// OTLP/HTTP endpoint for span export; None = no external OTel emission.
510    pub otel_endpoint: Option<String>,
511    /// JSON-serialized EvalConfig injected into hook WASI env as MURMUR_EVAL_CONFIG.
512    pub eval_config_json: Option<String>,
513    /// Dataset case identifier injected as MURMUR_CASE_ID when running a dataset.
514    pub case_id: Option<String>,
515    /// Dataset identifier injected as MURMUR_DATASET_ID when running a dataset.
516    pub dataset_id: Option<String>,
517    /// Lifecycle config from the manifest (None = use LifecycleConfig::default()).
518    pub lifecycle: Option<LifecycleConfig>,
519    /// Optional override applied on top of lifecycle (e.g. from CLI flags or mur-roost).
520    pub lifecycle_override: Option<LifecycleOverride>,
521    /// Trace config from the manifest (None = defaults apply: input captured, output not).
522    pub trace: Option<murmur_artifact::TraceConfig>,
523    /// The operator's directory to use as the capsule's work folder, `mur run --workdir`; the
524    /// runtime creates nothing in it. `None` uses `<manifest_dir>/workdir/<session_id>`.
525    pub workdir: Option<std::path::PathBuf>,
526    /// Address to bind the HTTP server on. Defaults to "127.0.0.1" for local runs.
527    /// Use "0.0.0.0" when deploying to a VM so the capsule is reachable externally.
528    pub bind_addr: String,
529    /// Internal port from `network.internal_port` in the manifest. When `Some`, the runtime
530    /// binds strictly to this port and errors if it is already in use. When `None`, the OS
531    /// assigns a port.
532    pub internal_port: Option<u16>,
533    /// Minimum containment class this session must achieve, already combined across every
534    /// source that asked for one (manifest / workspace config / `--containment`) by taking the
535    /// strongest. Required rather than optional: "nobody declared anything" is
536    /// [`ContainmentClass::Advisory`], an explicit floor every host clears, not an absence.
537    /// `stage_session` refuses to stage when the host cannot meet it.
538    pub declared_containment_floor: murmur_artifact::ContainmentClass,
539    /// The manifest's top-level `exports:` block. `None` — and `Some` with no `files:` — means
540    /// the resource plane is not declared and every request to it is denied.
541    ///
542    /// Carried beside `capability_policy` rather than inside it: an export is a disclosure the
543    /// operator makes, not a capability the guest holds, and nothing derived from this field ever
544    /// reaches the achieved containment class.
545    pub exports: Option<murmur_artifact::Exports>,
546    /// The manifest's top-level `control:` block. `None` means the session has no control
547    /// surface: no token is minted and every request under `/control` is answered `404`.
548    ///
549    /// Beside `exports` and on the same terms: what a controller may change is not a capability
550    /// the guest holds, and nothing derived from it reaches the containment class.
551    pub control: Option<murmur_artifact::ControlConfig>,
552    /// The manifest's `network.authentication` block. `None` is a public door, which answers every
553    /// caller that reaches the port; `Some` makes staging mint this session's door key and tokens.
554    pub door_authentication: Option<murmur_artifact::NetworkAuthentication>,
555    /// The approval a delegated child was handed by its parent, presented once at registration.
556    ///
557    /// `None` for every top-level launch, which the daemon admits against its own `--spawn-allow`
558    /// instead. Unlike [`StagedSession::spawn_credential`], this exists before staging: it names
559    /// an artifact rather than a session, and the parent already held it when it started this
560    /// process.
561    pub spawn_grant: Option<SpawnApproval>,
562    /// `spend.machine_tokens_per_day` from the operator's effective `config.yaml`. `Some` makes
563    /// staging open the shared daily spend ledger for a `transport: http` session with a driver,
564    /// and refuse when it cannot; `None` — every launch that does not read the operator config —
565    /// keeps no ledger and is not counted.
566    pub machine_tokens_per_day: Option<u64>,
567    /// The formation this session is a member of.
568    ///
569    /// `None` is the ordinary case: a session nobody placed in a formation, including a
570    /// standalone capsule that delegates and every child it spawns. `mur run` sets it from
571    /// [`crate::formation::FormationId::from_env`], which reads what the launcher of a formation's
572    /// members, or a member's runtime for its child, put in the environment.
573    pub formation_id: Option<FormationId>,
574    /// What this session's formation launcher handed it: its roster name, what its door checks a
575    /// formation token with, and the tokens and addresses of the members it may call.
576    ///
577    /// `Some` only for a session `mur run --roster` launched as a member: `mur run` reads it from
578    /// the channel [`crate::formation_credentials::FormationMember::from_env`] names, and refuses
579    /// the launch when that cannot be read. The door verifies formation tokens with it, every WASM
580    /// guest is handed its callees' names, and the runtime's egress resolves and authorizes calls
581    /// to them. No subprocess and no delegated child is handed any of it.
582    pub formation_member: Option<Arc<FormationMember>>,
583    /// Where a pull the stores cannot answer is fetched from, for a capsule granted
584    /// `capabilities.install.from: [stores, sources]`. `None` fetches from no source: every pull
585    /// is answered by the stores alone.
586    pub source_supply: Option<Arc<dyn crate::artifact_supply::SourceSupply>>,
587    /// The `(st_dev, st_ino)` the accessible workdir must have, compared with `fstat` of the
588    /// directory `stage_session` opens at [`Self::workdir`] before anything is written beneath
589    /// it; a mismatch refuses the launch with [`crate::RuntimeError::WorkdirIdentityMismatch`].
590    /// Set by a delegating parent through `mur run --workdir-identity`, for the child directory it
591    /// created. `None` checks nothing.
592    pub expected_workdir_identity: Option<(u64, u64)>,
593    /// What a delegating parent copied into [`Self::workdir`] before starting this session, from
594    /// `mur run --parent-workdir-snapshot`. Reported as `session_start.effective_grants.
595    /// parent_workdir` and in `MURMUR.md`, and grants nothing. `None` for every other session.
596    pub parent_workdir_snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
597    /// The hard limit `mur run --deadline` set, counted from the moment `mur run` started. Only
598    /// [`crate::launch_session_handling_sigterm`] on an agent capsule enforces one: reached, it
599    /// ends the session through the termination routine a first `SIGTERM` begins. Every other
600    /// launch of a session staged with one is refused with
601    /// [`crate::RuntimeError::SessionDeadlineUnenforceable`]. `None` sets no limit.
602    pub session_deadline: Option<crate::session_status::SessionDeadline>,
603    /// The manifest file this launch read, copied into the runtime directory under its own file
604    /// name: `--manifest`, or the project's `murmur.yaml`. `None` for a launch that read none from
605    /// disk — an installed capsule run by name — which copies nothing.
606    pub manifest_file: Option<PathBuf>,
607}
608
609/// The `transport: process` driver this session runs its harness through, compiled and described
610/// once at staging.
611///
612/// Not a tool: it is granted nothing, never enters `tool_components`, and is reached only by the
613/// process runner, which instantiates a fresh [`crate::process_driver::ProcessDriver`] from
614/// `component` per harness run.
615pub struct StagedProcessDriver {
616    pub(crate) name: String,
617    pub(crate) version: String,
618    pub(crate) component: Component,
619    pub(crate) description: crate::process_driver::Description,
620    /// The absolute path of the executable to spawn, already resolved and checked.
621    pub(crate) binary: PathBuf,
622    /// Where [`Self::binary`]'s name came from, for diagnostics and the trace: `inference.command`
623    /// or the process driver's `describe()`.
624    pub(crate) binary_source: String,
625    /// The harness's resolved needs against the manifest ([`crate::harness_needs`]): the report
626    /// `session_start.effective_grants.harness` carries, set on the session's `ScopeReport`.
627    pub(crate) harness_report: crate::harness_needs::HarnessReport,
628    /// What the harness gets beyond every command — the granted host paths and the resolved binary
629    /// and `#!` interpreter — for [`crate::sandbox::ShellEnforcement::with_harness_grants`].
630    pub(crate) harness_grants: crate::sandbox::HarnessEnforcementGrants,
631}
632
633pub struct StagedSession {
634    pub session_id: String,
635    /// The work folder: the `--workdir` directory, or `<manifest-dir>/workdir/<session-id>`
636    /// without one. Every command's current directory and every WASM tool's `"."`. The runtime
637    /// creates nothing beneath it, and reads beneath it only through [`Self::accessible_root`].
638    pub accessible_workdir: std::path::PathBuf,
639    /// [`Self::accessible_workdir`], held open.
640    pub accessible_root: crate::workdir_fs::WorkdirRoot,
641    /// `<manifest-dir>/workdir`, which holds the work folder the runtime created for a launch
642    /// without `--workdir`, and those of earlier such launches that `trace.retain` prunes. `None`
643    /// under `--workdir`, whose folder is the operator's and is never pruned.
644    pub(crate) created_workdirs: Option<PathBuf>,
645    /// The session's runtime directory, `~/.murmur/sessions/<session-id>/runtime`: the value of
646    /// `$MURMUR_RUNTIME_DIR`, holding `MURMUR.md`, `task.md`, `murmur.yaml`, `tools/` and
647    /// `plans/`. Removed when the session ends; the runtime reads and writes beneath it only
648    /// through [`Self::runtime_root`].
649    pub runtime_dir: PathBuf,
650    /// [`Self::runtime_dir`], held open, sharing [`Self::accessible_root`]'s refusal latch.
651    pub runtime_root: crate::workdir_fs::WorkdirRoot,
652    /// The three held directories beneath the session's runtime directory, with the records
653    /// directory beside them.
654    pub(crate) runtime_dirs: crate::runtime_dir::RuntimeDirRoots,
655    /// The session's records directory, `~/.murmur/records/<workdir root>/<session-id>`: the
656    /// value of `$MURMUR_RECORDS_DIR`, holding `trace.jsonl`, `blobs/`, `logs/`,
657    /// `completion.json`, `hooks/`, `out/`, `tmp/`, `home/` and `children/`. Kept after the
658    /// session ends; the runtime reads and writes beneath it only through
659    /// [`Self::records_root`].
660    pub records_dir: PathBuf,
661    /// [`Self::records_dir`], held open, sharing [`Self::accessible_root`]'s refusal latch.
662    pub records_root: crate::workdir_fs::WorkdirRoot,
663    /// The file name the launched manifest was copied into the runtime directory under, which
664    /// the `[Capsule]` notice and `MURMUR.md` name. `None` when the launch read no manifest file.
665    pub(crate) manifest_file_name: Option<String>,
666    /// What `MURMUR.md` is rendered from, for an agent session; `None` for a script capsule,
667    /// which gets no `MURMUR.md`. Its identity's capsule URL is empty until launch.
668    pub(crate) murmur_md: Option<crate::murmur_md::MurmurMdInputs>,
669    /// What staging read for `inference.instruction_files`, once, for every task of the launch.
670    /// `None` when the manifest does not set the key, and for a script capsule.
671    pub(crate) instruction_files: Option<crate::instruction_files::InstructionFiles>,
672    /// Owns the runtime directory: dropping it removes the directory, so it is held until the
673    /// session ends — `launch` takes it and drops it on return, and a `StagedSession` dropped
674    /// unlaunched drops it with itself.
675    pub(crate) runtime_dir_guard: Option<crate::runtime_dir::RuntimeDir>,
676    pub(crate) manifest_dir: PathBuf,
677    pub(crate) capsule_name: String,
678    pub(crate) capsule_version: String,
679    /// [`StageRequest::capsule_artifact`]'s `<scope>/<name>`, or `local/<capsule_name>` for a
680    /// project run.
681    pub(crate) capsule_artifact: String,
682    /// What the relative paths the manifest names resolve against: the installed package for a
683    /// capsule launched by name, else `manifest_dir`.
684    pub(crate) manifest_files: crate::manifest_files::ManifestFiles,
685    pub(crate) capsule_url: String,
686    pub resolved_lock_artifacts: Vec<ResolvedLockArtifact>,
687    pub(crate) installed_artifacts: Vec<InstalledArtifactSummary>,
688    /// The session's guest workers, when this process runs its guests in worker processes.
689    pub(crate) guest_workers: Option<std::sync::Arc<crate::guest_worker::GuestWorkers>>,
690    /// Where staging took the facts about each allowlisted binary it examined, for
691    /// `session_start.host_facts`.
692    pub(crate) host_facts: Vec<crate::host_facts::HostFactsUse>,
693    /// Each staged artifact's full name, by the tool name the session knows it by.
694    pub(crate) artifact_full_names: std::collections::HashMap<String, String>,
695    pub(crate) inference: Option<InferenceConfig>,
696    /// Every credential gateway of the session, built at staging from each artifact entry's
697    /// `gateway:` block and the artifact's own `upstream_auth:` declaration. Empty when no entry
698    /// declares one.
699    pub(crate) gateways: crate::credential_gateway::GatewayTable,
700    /// This session's spend account, built at staging from `inference.max_session_tokens` and
701    /// [`StageRequest::machine_tokens_per_day`]. Always present: a session with no ceiling gets a
702    /// meter that admits everything, so every driver call has an admission to open.
703    pub(crate) spend: Arc<crate::spend::SpendMeter>,
704    /// What a controller has changed on this session, built at staging from
705    /// [`StageRequest::control`]. `None` for a capsule with no `control:` block, which mints no
706    /// control token and answers `404` under `/control`.
707    pub(crate) control: Option<Arc<crate::control_plane::ControlState>>,
708    /// The session's driver choices, the primary first, each with the credential staging left
709    /// it and, under `transport: http`, its model limits. `None` for a capsule with no inference
710    /// block.
711    pub(crate) driver_choices: Option<crate::driver_choice::DriverChoices>,
712    /// The output cap a hook's `run-inference` sends for each model it may call.
713    pub(crate) hook_output_caps: crate::model_limits::HookOutputCaps,
714    /// The `MURMUR_INFERENCE_DRIVER_CONFIG` a hook's `run-inference` driver sees while the hook
715    /// serves `on-compaction`: `inference.driver.config` with `inference.compaction.driver_config`
716    /// laid over it. `None` when the manifest sets no overlay, so the call sees the session's.
717    pub(crate) compaction_driver_config: Option<String>,
718    /// Copied from [`StageRequest::door_authentication`], which the cards are built from.
719    pub(crate) door_authentication: Option<murmur_artifact::NetworkAuthentication>,
720    /// This session's door key and tokens, minted at staging when
721    /// [`Self::door_authentication`] is declared. The key never leaves this value.
722    pub(crate) door_auth: Option<Arc<crate::door_auth::DoorAuth>>,
723    /// Copied from [`StageRequest::system_prompt_overridden`] — the only record left that the
724    /// prompt in `inference` came from `--system-prompt` and not from the manifest. Passed to
725    /// `TraceWriter::open`, which turns it into `session_start.system_prompt_source`.
726    pub(crate) system_prompt_overridden: bool,
727    pub(crate) context: Option<ContextConfig>,
728    /// Copied from [`StageRequest::context_id`] and already validated. Read by `launch_session`,
729    /// which uses it in place of a freshly minted id for every `task.md` task.
730    pub(crate) context_id: Option<String>,
731    /// Copied from [`StageRequest::resume`] and already checked. Read by `launch_session`, which
732    /// turns it into `session_start.resumed_from` and the agent loop's record-load override.
733    pub(crate) resume: Option<ResumeRequest>,
734    /// Copied from [`StageRequest::forget_session`] and already checked against the transport.
735    /// Read by `launch_session` as a one-shot: the launch's first task carries the forget, and no
736    /// later task of the same launch does.
737    pub(crate) forget_session: bool,
738    pub(crate) engine: Engine,
739    /// The compiled-form cache handle built for [`Self::engine`] and the capsule's writable root.
740    /// A mid-session pull compiles through it, so a pulled component is stored and
741    /// looked up under the same name a staged one is.
742    pub(crate) compiled_forms: crate::compiled_forms::CompiledForms,
743    /// `None` for manifest-only agent capsules; `Some` for script capsules with a WASM component.
744    pub(crate) capsule_component: Option<Component>,
745    pub(crate) tool_components: HashMap<String, Component>,
746    /// The process driver, for a `transport: process` session that named one. `None` on every
747    /// other transport.
748    pub(crate) process_driver: Option<Arc<StagedProcessDriver>>,
749    /// Per-artifact narrowing for the tool/driver dispatch path, keyed by artifact name and
750    /// lowered at staging time from the operator's own manifest entries. Holds an entry only
751    /// for artifacts that actually declared a `capabilities:` block — a name absent from this
752    /// map (the overwhelmingly common case) runs on the full capsule ceiling, exactly as
753    /// before per-artifact narrowing existed.
754    pub(crate) artifact_grants: HashMap<String, ToolCapabilityGrant>,
755    pub(crate) hook_components: Vec<StagedHookArtifact>,
756    pub(crate) allowlisted_tools: HashSet<String>,
757    pub(crate) capability_policy: CapabilityPolicy,
758    pub(crate) otel_endpoint: Option<String>,
759    pub(crate) eval_config_json: Option<String>,
760    pub(crate) case_id: Option<String>,
761    pub(crate) dataset_id: Option<String>,
762    /// Resolved lifecycle config (manifest + override already applied).
763    pub(crate) lifecycle: LifecycleConfig,
764    /// How much of each turn's driver request this session's trace keeps (resolved from the
765    /// manifest's `trace:` block, or [`murmur_artifact::TraceCapture::default`] when it has
766    /// none).
767    pub(crate) trace_capture: murmur_artifact::TraceCapture,
768    /// What bounds this workdir's session directories, from the manifest's `trace.retain` block.
769    /// `None` — the overwhelmingly common case — prunes nothing, ever.
770    pub(crate) trace_retain: Option<murmur_artifact::TraceRetainConfig>,
771    /// Address the HTTP server is bound to (copied from StageRequest::bind_addr).
772    pub(crate) bind_addr: String,
773    /// Internal port from the manifest (copied from StageRequest::internal_port).
774    pub(crate) internal_port: Option<u16>,
775    /// This session's containment, decided once by `stage_session` from [`Self::host_probe`],
776    /// `StageRequest::declared_containment_floor`, `workdir_exec` and where the runtime directory
777    /// lies. The floor refusal, [`Self::scope_report`], [`Self::temp_mapping`], `MURMUR.md`'s exec
778    /// facts and the tier `launch_session`'s `sandbox::ShellEnforcement::resolve` installs all
779    /// read it, so the class and tier the session reports are the ones its commands run on.
780    pub(crate) containment: crate::containment::ContainmentDecision,
781    /// The complete effective grant set this session was staged with — the same report
782    /// `mur run --explain-scope --json` prints, built by `containment::scope_report_for_tier`
783    /// from this session's own policy and [`Self::containment`]. Written verbatim to
784    /// `trace.jsonl`'s `session_start` as `effective_grants`, and the source of the
785    /// `containment_declared`/`containment_achieved`/`workdir_exec` fields alongside it, so the
786    /// summary and the full report cannot disagree.
787    ///
788    /// The session's class here, `scope_report.achieved_containment`, is copied from
789    /// [`Self::containment`] when the report is built, so the two cannot drift.
790    pub(crate) scope_report: crate::containment::ScopeReport,
791    /// The single host reading this session was staged against, taken at the top of
792    /// `runtime::stage_session`, which [`Self::containment`] was decided from. `launch_session`
793    /// passes both to `sandbox::ShellEnforcement::resolve` rather than taking a new reading, so the
794    /// mechanisms installed at launch are the ones [`Self::scope_report`] already claims.
795    pub(crate) host_probe: crate::sandbox::HostProbe,
796    /// The provider tools this session staged with and what a runtime pull of one is checked
797    /// against. Its `declared` list is empty for a capsule that declares no provider tool.
798    pub(crate) provider_tool_terms: crate::provider_tool::ProviderToolTerms,
799    /// Whether a literal `/tmp` reaches the session's temporary folder, decided once by
800    /// `stage_session` from [`Self::host_probe`]: [`Self::scope_report`]'s `temp_dir` says it and
801    /// `launch_session` hands it to the enforcement that installs it.
802    pub(crate) temp_mapping: crate::temp_dir::TempMapping,
803    /// The session's enforcement as staging resolved it to spawn the guest workers, `None` when
804    /// it started none. `launch_session` takes it instead of resolving another, so every command
805    /// joins the network and PID namespaces the workers already run in. Boxed: a staged session
806    /// is moved by value through frames that an in-process launch keeps on a 2 MiB stack.
807    pub(crate) staged_enforcement: Option<Box<crate::sandbox::ShellEnforcement>>,
808    /// The declared read-only surface, lowered and validated by `stage_session` — a malformed
809    /// entry refuses the launch there rather than surfacing at the first call. Empty for every
810    /// capsule that declared no `capabilities.filesystem.read_only`.
811    pub(crate) protected_paths: crate::protected_paths::ProtectedPaths,
812    /// What each staged tool's own `input_schema` declared about where its inputs go, lowered by
813    /// `stage_session` from the manifests it wrote into `<runtime dir>/tools/`. Empty for a capsule
814    /// that declared no `read_only`, which reads no schema at all, and for one whose tools
815    /// annotated nothing.
816    pub(crate) tool_annotations: crate::tool_annotations::ToolAnnotationMap,
817    /// The declared read-only file surface, with its root already checked against this session's
818    /// accessible workdir by `stage_session` — a root that resolves outside it refuses the launch
819    /// rather than being served. `None` means no resource plane.
820    pub(crate) exports_files: Option<murmur_artifact::FileExport>,
821    /// The declared peer-handoff surface, with its root already checked against this session's
822    /// accessible workdir and its `max_ttl` already checked against `lifecycle.after_task` by
823    /// `stage_session`. `None` means no peer plane: nothing mints, and `/resources/peer/` answers
824    /// `no_peer_plane`.
825    pub(crate) exports_peer_files: Option<murmur_artifact::PeerFilesExport>,
826    /// Whether the door serves a task a peer capsule's runtime sends, read from the manifest's
827    /// own `exports.peer_tasks` by [`murmur_artifact::Exports::accepts_peer_tasks`]. `false`
828    /// answers every peer-origin request on the door with `403 peer_not_accepted`.
829    pub(crate) accepts_peer_tasks: bool,
830    /// Registry used to resolve this session's artifacts, retained so a pull can
831    /// resolve additional artifacts at runtime after staging has completed.
832    pub(crate) registry: Arc<dyn Registry>,
833    /// Copied from [`StageRequest::source_supply`]: where a runtime pull the stores cannot answer
834    /// is fetched from. `None` unless the capsule declared `capabilities.install.from: [stores,
835    /// sources]` and the launcher supplied one.
836    pub(crate) source_supply: Option<Arc<dyn crate::artifact_supply::SourceSupply>>,
837    /// Keeps this session's epoch ticker running: without it no epoch deadline set on any
838    /// store built from `engine` can ever fire. Held here (rather than detached) so ticking
839    /// stops when the session's `StagedSession` drops. `launch_session` moves the other
840    /// fields out one by one, which leaves this one in place until the session returns.
841    pub(crate) _epoch_ticker: EpochTicker,
842    /// The session's record of component calls the wall-clock bound stopped, created at staging
843    /// beside the epoch ticker and cloned into every store that runs a component and into the
844    /// session's trace writer, which drains it.
845    pub(crate) call_deadlines: CallDeadlines,
846    /// The session's record of every request routed to a member's virtual address, created at
847    /// staging beside [`Self::call_deadlines`] and cloned the same way: into every store that can
848    /// route one and into the session's trace writer, which drains it.
849    pub(crate) member_requests: crate::member_request::MemberRequests,
850    /// The credential this session presents when it asks `mur-roost` to spawn a capsule.
851    ///
852    /// `None` for every session that cannot delegate, which is nearly all of them. Set by the
853    /// caller between `stage_session` and `launch_session` rather than carried in
854    /// [`StageRequest`]: the credential names a session id, and `stage_session` is what mints one,
855    /// so it cannot exist before staging returns.
856    pub(crate) spawn_credential: Option<SpawnCredential>,
857    /// Copied from [`StageRequest::spawn_grant`]. Read by `launch_session`, which presents it at
858    /// `POST /register` and never again — an approval covers one launch.
859    pub(crate) spawn_grant: Option<SpawnApproval>,
860    /// The handle the spawning capsule's launcher injected, read once per process at staging.
861    ///
862    /// `None` for every capsule nobody delegated. Its `session_id` and `delegation_id` become
863    /// `session_start.spawned_by` and `session_start.delegation_id`; its `report_to` — present
864    /// only for a parent that wants a completion — is what `launch_session`'s reporting guard
865    /// posts to. Read here rather than at each of those two points so one process reads
866    /// `MURMUR_SPAWNER` once and both readers see the same value.
867    pub(crate) spawner: Option<crate::delegation::SpawnerHandle>,
868    /// Copied from [`StageRequest::formation_id`]. Hooks see it as `MURMUR_FORMATION_ID` and the
869    /// delegation plane hands it to every child this session spawns.
870    pub(crate) formation_id: Option<FormationId>,
871    /// Copied from [`StageRequest::formation_member`].
872    pub(crate) formation_member: Option<Arc<FormationMember>>,
873    /// This member's formation lifeline, attached after staging by
874    /// [`StagedSession::attach_formation_lifeline`]. A process property rather than a staged
875    /// one, so it never travels on [`StageRequest`].
876    #[cfg(unix)]
877    pub(crate) formation_lifeline: Option<crate::lifeline::FormationLifeline>,
878    /// This delegated child's spawner lifeline, attached after staging by
879    /// [`StagedSession::attach_spawner_lifeline`]. A process property on the same terms as
880    /// [`Self::formation_lifeline`].
881    #[cfg(unix)]
882    pub(crate) spawner_lifeline: Option<crate::lifeline::SpawnerLifeline>,
883    /// [`StageRequest::session_deadline`], carried to the launch that enforces it.
884    pub(crate) session_deadline: Option<crate::session_status::SessionDeadline>,
885}
886
887impl StagedSession {
888    /// Writes `bytes` as the task input, `task.md` in the runtime directory, which the capsule's
889    /// commands can read and not change. A launch with one finds nothing to import from the
890    /// workdir.
891    pub fn write_task_input(&self, bytes: &[u8]) -> Result<(), crate::workdir_fs::WorkdirError> {
892        self.runtime_root.write_file(
893            crate::runtime::TASK_INPUT_FILE,
894            bytes,
895            crate::workdir_fs::DEFAULT_FILE_MODE,
896        )
897    }
898
899    /// Writes `bytes` as `rel` in the accessible workdir before launch, the folder the capsule's
900    /// tools see as their working directory. A link at `rel` is refused rather than written
901    /// through, and missing parent directories are created.
902    pub fn write_workdir_file(
903        &self,
904        rel: &str,
905        bytes: &[u8],
906    ) -> Result<(), crate::workdir_fs::WorkdirError> {
907        self.accessible_root
908            .write_file(rel, bytes, crate::workdir_fs::DEFAULT_FILE_MODE)
909    }
910
911    /// Hands this session the credential it will present to the spawning daemon.
912    pub fn set_spawn_credential(&mut self, credential: SpawnCredential) {
913        self.spawn_credential = Some(credential);
914    }
915
916    /// The credential this session presents, if it was granted one.
917    pub fn spawn_credential(&self) -> Option<&SpawnCredential> {
918        self.spawn_credential.as_ref()
919    }
920
921    /// The grant set this session was staged with, as written to the trace.
922    pub fn scope_report(&self) -> &crate::containment::ScopeReport {
923        &self.scope_report
924    }
925
926    /// Every door token this session minted, operator first, then the declared credentials by
927    /// name. Empty for a capsule that declares no `network.authentication`.
928    pub fn door_tokens(&self) -> Vec<(String, crate::door_auth::DoorToken)> {
929        self.door_auth
930            .as_ref()
931            .map(|auth| auth.tokens().to_vec())
932            .unwrap_or_default()
933    }
934
935    /// The formation this session is a member of, or `None` for a session nobody placed in one.
936    pub fn formation_id(&self) -> Option<&FormationId> {
937        self.formation_id.as_ref()
938    }
939
940    /// Hand this session the lifeline its process was launched with. Honoured only by
941    /// [`crate::launch_session_handling_sigterm`] on an agent capsule: every other launch of a
942    /// session holding one is refused with [`crate::RuntimeError::FormationLifelineUnreadable`]
943    /// before anything is instantiated.
944    #[cfg(unix)]
945    pub fn attach_formation_lifeline(&mut self, lifeline: crate::lifeline::FormationLifeline) {
946        self.formation_lifeline = Some(lifeline);
947    }
948
949    /// Hand this session the spawner lifeline its process was launched with. Honoured only by
950    /// [`crate::launch_session_handling_sigterm`], for an agent and a script capsule alike: every
951    /// other launch of a session holding one is refused with
952    /// [`crate::RuntimeError::SpawnerLifelineUnreadable`] before anything is instantiated.
953    #[cfg(unix)]
954    pub fn attach_spawner_lifeline(&mut self, lifeline: crate::lifeline::SpawnerLifeline) {
955        self.spawner_lifeline = Some(lifeline);
956    }
957}
958
959#[derive(Debug, Clone, PartialEq, Eq)]
960pub struct LaunchResult {
961    pub session_id: String,
962    /// The session's work folder, [`StagedSession::accessible_workdir`].
963    pub workdir: PathBuf,
964    /// The session's records directory, where its trace is, kept after the session.
965    pub records_dir: PathBuf,
966    /// How a launch that reports no error ended.
967    pub ending: LaunchEnding,
968}
969
970/// How a launch that reports no error ended. A launch whose task did not complete for any other
971/// reason is a [`crate::RuntimeError::TaskDidNotComplete`], not an ending.
972#[derive(Debug, Clone, Copy, PartialEq, Eq)]
973pub enum LaunchEnding {
974    /// Every task the launch ran completed, or it ran none: `session_end.exit_status` `ok`.
975    Completed,
976    /// The session's formation ended while it was running a task, and the wind-down that followed
977    /// cancelled that task: `session_end.exit_status` `formation_ended`.
978    FormationEnded,
979    /// The session reached its `mur run --deadline`, and the wind-down that followed cancelled
980    /// the task it was running, or it reached it before any task ran:
981    /// `session_end.exit_status` `deadline_reached`.
982    DeadlineReached,
983}
984
985impl LaunchEnding {
986    /// The `exit_status` `session_end` and `on-session-end` carry for this ending.
987    pub fn as_str(self) -> &'static str {
988        match self {
989            Self::Completed => "ok",
990            Self::FormationEnded => "formation_ended",
991            Self::DeadlineReached => "deadline_reached",
992        }
993    }
994}
995
996pub fn capability_policy_from_runtime_manifest(
997    manifest: &murmur_artifact::RuntimeManifest,
998) -> CapabilityPolicy {
999    let caps = manifest.capabilities.as_ref();
1000    let network_allow = caps
1001        .and_then(|c| c.network.as_ref())
1002        .map(|network| network.allow.clone())
1003        .unwrap_or_default();
1004    // Absent `capabilities.peer_fetch` block means denied, on the same terms — and separately
1005    // from `network.allow`, which this deliberately does not read.
1006    let peer_fetch_allow = caps
1007        .and_then(|c| c.peer_fetch.as_ref())
1008        .map(|peer_fetch| peer_fetch.allow.clone())
1009        .unwrap_or_default();
1010
1011    // Absent `capabilities.network` block, or absent key within it, both mean denied.
1012    let unix_sockets_allowed = caps
1013        .and_then(|c| c.network.as_ref())
1014        .is_some_and(|network| network.unix_sockets);
1015
1016    // Absent `capabilities.plan` block, or `submit: false` within it, both mean denied.
1017    let plan_submit = caps
1018        .and_then(|c| c.plan.as_ref())
1019        .is_some_and(|plan| plan.submit);
1020
1021    // Absent `capabilities.install` block means both lists are empty, which is deny.
1022    let install = caps.and_then(|c| c.install.as_ref());
1023    let install_skill = install.map(|i| i.skill.clone()).unwrap_or_default();
1024    let install_tool = install.map(|i| i.tool.clone()).unwrap_or_default();
1025    let install_from_sources = install.is_some_and(|i| i.from.sources());
1026
1027    // Recorded, never applied: see `CapabilityPolicy::state_declared`.
1028    let state_declared = caps.is_some_and(|c| c.state.is_some());
1029    let conversation_declared = caps.is_some_and(|c| c.conversation.is_some());
1030
1031    let filesystem_scope = caps
1032        .and_then(|c| c.filesystem.as_ref())
1033        .and_then(|filesystem| filesystem.scope.clone());
1034    // Absent `capabilities.filesystem` block, or absent key within it, both mean denied — the same
1035    // shape as `unix_sockets_allowed` above, for the same reason: the widening has to be declared.
1036    let workdir_exec_allowed = caps
1037        .and_then(|c| c.filesystem.as_ref())
1038        .is_some_and(|filesystem| filesystem.workdir_exec);
1039    // Absent block, absent key and an empty list are one declaration: nothing is protected.
1040    let read_only_paths = caps
1041        .and_then(|c| c.filesystem.as_ref())
1042        .map(|filesystem| filesystem.read_only.clone())
1043        .unwrap_or_default();
1044
1045    let shell_allow = caps
1046        .and_then(|c| c.shell.as_ref())
1047        .map(|shell| shell.allow.clone())
1048        .unwrap_or_default();
1049    let shell_strip_env = caps
1050        .and_then(|c| c.shell.as_ref())
1051        .and_then(|shell| shell.strip_env.clone())
1052        .unwrap_or_default();
1053    let shell_baseline_env = caps
1054        .and_then(|c| c.shell.as_ref())
1055        .and_then(|shell| shell.baseline_env.clone())
1056        .unwrap_or_default();
1057    let shell_interpreter_runtime = caps
1058        .and_then(|c| c.shell.as_ref())
1059        .map(|shell| shell.interpreter_runtime.clone())
1060        .unwrap_or_default();
1061    let shell_staged_runtime = caps
1062        .and_then(|c| c.shell.as_ref())
1063        .map(|shell| shell.staged_runtime.clone())
1064        .unwrap_or_default();
1065
1066    let spawn_allow = caps
1067        .and_then(|c| c.spawn.as_ref())
1068        .map(|spawn| spawn.allow.clone())
1069        .unwrap_or_default();
1070    let spawn_workdir_snapshot = caps
1071        .and_then(|c| c.spawn.as_ref())
1072        .map(|spawn| spawn.workdir_snapshot.clone())
1073        .unwrap_or_default();
1074
1075    let env_allow = caps
1076        .and_then(|c| c.env.as_ref())
1077        .map(|env| env.allow.clone())
1078        .unwrap_or_default();
1079
1080    let limits = ExecutionLimits::resolve(caps.and_then(|c| c.limits.as_ref()));
1081    let declared_deadline_seconds = caps
1082        .and_then(|c| c.limits.as_ref())
1083        .and_then(|l| l.deadline_seconds);
1084
1085    let resources = crate::resources::resolve(caps.and_then(|c| c.resources.as_ref()));
1086
1087    // The rule `runtime::guest_worker_kinds` applies to the staged artifacts, over the manifest
1088    // (plus a pulled tool, which starts the tools worker at its first call); keep the two in step.
1089    let guest_workers = !install_tool.is_empty()
1090        || manifest.artifacts.iter().any(|artifact| {
1091            artifact.source.is_none()
1092                && crate::guest_worker::WorkerKind::for_runtime(artifact.runtime.clone()).is_some()
1093        });
1094
1095    CapabilityPolicy {
1096        network_allow,
1097        peer_fetch_allow,
1098        unix_sockets_allowed,
1099        filesystem_scope,
1100        workdir_exec_allowed,
1101        read_only_paths,
1102        shell_allow,
1103        spawn_allow,
1104        spawn_workdir_snapshot,
1105        shell_strip_env,
1106        shell_baseline_env,
1107        shell_interpreter_runtime,
1108        shell_staged_runtime,
1109        shell_derived_interpreter_runtime: Vec::new(),
1110        shell_trust_store: Vec::new(),
1111        shell_declared_needs: Vec::new(),
1112        shell_pinned_programs: Vec::new(),
1113        host_paths: Vec::new(),
1114        harness_paths: caps
1115            .and_then(|c| c.harness.as_ref())
1116            .map(|harness| harness.paths.clone())
1117            .unwrap_or_default(),
1118        process_transport: manifest
1119            .inference
1120            .as_ref()
1121            .is_some_and(|inference| inference.transport == "process"),
1122        env_allow,
1123        limits,
1124        resources,
1125        declared_resources: caps.and_then(|c| c.resources).unwrap_or_default(),
1126        containment_floor: caps.and_then(|c| c.containment).unwrap_or_default(),
1127        state_declared,
1128        conversation_declared,
1129        plan_submit,
1130        install_skill,
1131        install_tool,
1132        install_from_sources,
1133        declared_deadline_seconds,
1134        guest_workers,
1135    }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141
1142    fn policy_for(manifest_yaml: &str) -> CapabilityPolicy {
1143        let manifest = murmur_artifact::RuntimeManifest::from_yaml_str(manifest_yaml)
1144            .expect("manifest fixture must parse");
1145        capability_policy_from_runtime_manifest(&manifest)
1146    }
1147
1148    /// `read_only` reaches the policy in declaration order, trimmed, with empty entries already
1149    /// dropped by the manifest parser — and a capsule that declares none gets an empty list, which
1150    /// is what makes the dispatch check free for it.
1151    #[test]
1152    fn read_only_paths_are_carried_through_and_default_to_empty() {
1153        let declared = policy_for(
1154            r#"
1155name: cap
1156version: 0.1.0
1157capabilities:
1158  filesystem:
1159    read_only:
1160      - tests
1161      - "  bench/fixtures  "
1162      - "   "
1163"#,
1164        );
1165        assert_eq!(declared.read_only_paths, vec!["tests", "bench/fixtures"]);
1166
1167        let silent = policy_for(
1168            "name: cap
1169version: 0.1.0
1170",
1171        );
1172        assert!(silent.read_only_paths.is_empty());
1173
1174        let no_key = policy_for(
1175            r#"
1176name: cap
1177version: 0.1.0
1178capabilities:
1179  filesystem:
1180    scope: work
1181"#,
1182        );
1183        assert!(no_key.read_only_paths.is_empty());
1184    }
1185
1186    /// A manifest that sets no deadline gives hook calls the hook-specific default while the
1187    /// capsule's own guests keep the capsule-wide one — one manifest, two budgets.
1188    #[test]
1189    fn a_silent_manifest_gives_hook_calls_the_lower_deadline() {
1190        let policy = policy_for(
1191            r#"
1192name: cap
1193version: 0.1.0
1194artifacts: []
1195"#,
1196        );
1197
1198        assert_eq!(
1199            policy.limits.deadline_seconds,
1200            crate::limits::DEFAULT_DEADLINE_SECONDS
1201        );
1202        assert_eq!(
1203            policy.hook_limits().deadline_seconds,
1204            crate::limits::HOOK_DEFAULT_DEADLINE_SECONDS
1205        );
1206        // The default policy a hand-built fixture gets must agree with the silent-manifest one.
1207        assert_eq!(
1208            CapabilityPolicy::default().hook_limits().deadline_seconds,
1209            crate::limits::HOOK_DEFAULT_DEADLINE_SECONDS
1210        );
1211    }
1212
1213    /// An explicit `deadline_seconds` applies to every guest, hooks included — the hook
1214    /// default only fills a silence.
1215    #[test]
1216    fn an_explicit_deadline_applies_to_hook_calls_too() {
1217        let policy = policy_for(
1218            r#"
1219name: cap
1220version: 0.1.0
1221artifacts: []
1222capabilities:
1223  limits:
1224    deadline_seconds: 120
1225"#,
1226        );
1227
1228        assert_eq!(policy.declared_deadline_seconds, Some(120));
1229        assert_eq!(policy.limits.deadline_seconds, 120);
1230        assert_eq!(policy.hook_limits().deadline_seconds, 120);
1231    }
1232
1233    /// `capabilities.shell.staged_runtime` lowers onto the policy verbatim — binary, source path
1234    /// and pin all survive, since the pin is what a human later compares across two hosts.
1235    #[test]
1236    fn staged_runtime_grants_lower_onto_the_policy() {
1237        let policy = policy_for(
1238            r#"
1239name: cap
1240version: 0.1.0
1241artifacts: []
1242capabilities:
1243  containment: sealed
1244  shell:
1245    allow:
1246      - python3
1247    staged_runtime:
1248      - binary: python3
1249        source_path: /opt/testbed/conda/envs/django__django
1250        pin: conda-4.10.3/python-3.9.19
1251"#,
1252        );
1253
1254        assert_eq!(
1255            policy.shell_staged_runtime,
1256            vec![murmur_artifact::StagedRuntimeGrant {
1257                binary: "python3".to_string(),
1258                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
1259                pin: "conda-4.10.3/python-3.9.19".to_string(),
1260            }]
1261        );
1262        // The two mechanisms stay separate all the way down: lowering one must not populate the
1263        // other.
1264        assert!(policy.shell_interpreter_runtime.is_empty());
1265        // Derived at staging, never lowered from the manifest.
1266        assert!(policy.shell_derived_interpreter_runtime.is_empty());
1267        assert!(policy.shell_trust_store.is_empty());
1268    }
1269
1270    /// A shell block that declares no staged runtime lowers to an empty list, not to a default
1271    /// grant of any kind.
1272    #[test]
1273    fn absent_staged_runtime_lowers_to_empty() {
1274        let policy = policy_for(
1275            r#"
1276name: cap
1277version: 0.1.0
1278artifacts: []
1279capabilities:
1280  shell:
1281    allow:
1282      - bash
1283"#,
1284        );
1285
1286        assert!(policy.shell_staged_runtime.is_empty());
1287    }
1288
1289    /// The default every existing manifest gets. Three separate "nothing was declared" shapes —
1290    /// no `capabilities:` at all, no `filesystem:` within it, and a `filesystem:` that only sets
1291    /// `scope` — must all lower to denied, because each one is a real manifest in the wild and any
1292    /// of them silently keeping workdir `Execute` would reopen the rename-to-an-allowed-basename
1293    /// bypass this default exists to close.
1294    #[test]
1295    fn workdir_exec_denied_for_every_shape_of_undeclared() {
1296        for manifest_yaml in [
1297            r#"
1298name: cap
1299version: 0.1.0
1300artifacts: []
1301"#,
1302            r#"
1303name: cap
1304version: 0.1.0
1305artifacts: []
1306capabilities:
1307  network:
1308    allow:
1309      - https://api.anthropic.com
1310"#,
1311            r#"
1312name: cap
1313version: 0.1.0
1314artifacts: []
1315capabilities:
1316  filesystem:
1317    scope: workdir
1318"#,
1319        ] {
1320            let policy = policy_for(manifest_yaml);
1321            assert!(
1322                !policy.workdir_exec_allowed,
1323                "an undeclared workdir_exec must lower to denied: {manifest_yaml}"
1324            );
1325        }
1326    }
1327
1328    #[test]
1329    fn workdir_exec_denied_when_declared_false() {
1330        let policy = policy_for(
1331            r#"
1332name: cap
1333version: 0.1.0
1334artifacts: []
1335capabilities:
1336  filesystem:
1337    workdir_exec: false
1338"#,
1339        );
1340
1341        assert!(!policy.workdir_exec_allowed);
1342    }
1343
1344    #[test]
1345    fn workdir_exec_allowed_when_declared_true() {
1346        let policy = policy_for(
1347            r#"
1348name: cap
1349version: 0.1.0
1350artifacts: []
1351capabilities:
1352  filesystem:
1353    scope: workdir
1354    workdir_exec: true
1355"#,
1356        );
1357
1358        assert!(policy.workdir_exec_allowed);
1359        // The two filesystem keys stay independent: lowering one must not disturb the other.
1360        assert_eq!(policy.filesystem_scope.as_deref(), Some("workdir"));
1361    }
1362
1363    /// A manifest with no `capabilities:` block at all denies unix sockets — the field must not
1364    /// pick up any implicit widening from the "nothing declared" path.
1365    #[test]
1366    fn unix_sockets_denied_when_capabilities_block_is_absent() {
1367        let policy = policy_for(
1368            r#"
1369name: cap
1370version: 0.1.0
1371artifacts: []
1372"#,
1373        );
1374
1375        assert!(!policy.unix_sockets_allowed);
1376    }
1377
1378    /// A declared `network:` block that never mentions the key still denies.
1379    #[test]
1380    fn unix_sockets_denied_when_network_block_omits_the_key() {
1381        let policy = policy_for(
1382            r#"
1383name: cap
1384version: 0.1.0
1385artifacts: []
1386capabilities:
1387  network:
1388    allow:
1389      - https://api.anthropic.com
1390"#,
1391        );
1392
1393        assert!(!policy.unix_sockets_allowed);
1394        assert_eq!(
1395            policy.network_allow,
1396            vec!["https://api.anthropic.com".to_string()]
1397        );
1398    }
1399
1400    #[test]
1401    fn unix_sockets_denied_when_declared_false() {
1402        let policy = policy_for(
1403            r#"
1404name: cap
1405version: 0.1.0
1406artifacts: []
1407capabilities:
1408  network:
1409    unix_sockets: false
1410"#,
1411        );
1412
1413        assert!(!policy.unix_sockets_allowed);
1414    }
1415
1416    #[test]
1417    fn unix_sockets_allowed_when_declared_true() {
1418        let policy = policy_for(
1419            r#"
1420name: cap
1421version: 0.1.0
1422artifacts: []
1423capabilities:
1424  network:
1425    unix_sockets: true
1426"#,
1427        );
1428
1429        assert!(policy.unix_sockets_allowed);
1430    }
1431
1432    /// The opt-in is independent of the IP allowlist in both directions: a non-empty `allow`
1433    /// does not imply unix sockets, and `unix_sockets: true` does not imply any IP destination.
1434    #[test]
1435    fn unix_sockets_opt_in_is_independent_of_the_ip_allowlist() {
1436        let policy = policy_for(
1437            r#"
1438name: cap
1439version: 0.1.0
1440artifacts: []
1441capabilities:
1442  network:
1443    unix_sockets: true
1444    allow:
1445      - https://api.anthropic.com
1446"#,
1447        );
1448
1449        assert!(policy.unix_sockets_allowed);
1450        assert_eq!(
1451            policy.network_allow,
1452            vec!["https://api.anthropic.com".to_string()]
1453        );
1454    }
1455
1456    /// `CapabilityPolicy::default()` is what the sandbox tests and every `..Default::default()`
1457    /// call site start from; it must deny, not inherit whatever a future field ordering implies.
1458    #[test]
1459    fn default_capability_policy_denies_unix_sockets() {
1460        assert!(!CapabilityPolicy::default().unix_sockets_allowed);
1461    }
1462}