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    spawn_credential::{SpawnApproval, SpawnCredential},
19};
20
21pub(crate) struct DispatchOutcome {
22    pub result: ToolResult,
23    pub shell: Option<ShellDispatchInfo>,
24    /// Set instead of [`Self::shell`] when the command outran its grace period and was demoted
25    /// to the background. The two are never both `Some`: a demoted command has no exit code yet,
26    /// which is exactly why `HookEvent::Shell` does not fire for one.
27    pub detached: Option<crate::detached::DetachedDispatchInfo>,
28    /// True when the dispatch was served by reading a skill.md file rather than running
29    /// a binary or WASM component. Used by the agent loop to write a `skill_call` trace
30    /// event instead of a `tool_call` event.
31    pub is_skill: bool,
32    /// Set when this dispatch failed in a way that ends the *session* rather than just this
33    /// tool call — today only a `sealed` composed-root construction failure
34    /// ([`crate::shell::ShellExecError::session_fatal`]). `result` still describes the failed
35    /// call so the trace records what was attempted; the agent turn loop then returns this
36    /// error instead of feeding the failure back to the model for another turn.
37    ///
38    /// A dispatch that merely failed leaves this `None`: a capsule reacting to a broken tool
39    /// call is ordinary, whereas a capsule continuing to run after its declared containment
40    /// boundary stopped being establishable is the silent-degradation failure mode the whole
41    /// containment-class mechanism exists to prevent.
42    pub fatal: Option<crate::errors::RuntimeError>,
43}
44
45impl DispatchOutcome {
46    pub(crate) fn tool(result: ToolResult) -> Self {
47        Self {
48            result,
49            shell: None,
50            detached: None,
51            is_skill: false,
52            fatal: None,
53        }
54    }
55
56    pub(crate) fn skill(result: ToolResult) -> Self {
57        Self {
58            result,
59            shell: None,
60            detached: None,
61            is_skill: true,
62            fatal: None,
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ArtifactRequest {
69    pub name: String,
70    pub version: String,
71    pub runtime: ArtifactRuntime,
72    /// Optional local source path (skill artifacts only). When set, the runtime resolves the
73    /// skill's `skill.md` directly from this path and skips registry resolution entirely.
74    /// Relative paths resolve against `StageRequest::manifest_dir`.
75    pub source: Option<String>,
76    /// Per-artifact capability grant copied verbatim from this artifact's entry in the
77    /// capsule operator's own manifest (`murmur_artifact::RuntimeArtifact::capabilities`).
78    /// Consumed for `runtime: hook` (default-deny baseline, `None` = deny everything) and for
79    /// `runtime: tool`/`runtime: driver` (ceiling baseline, `None` = the capsule-wide policy
80    /// unchanged); the manifest parser rejects the key on `runtime: skill`. Never sourced
81    /// from the artifact's own bundled `murmur.yaml`, so an artifact cannot self-grant.
82    pub capabilities: Option<murmur_artifact::Capabilities>,
83    /// What the runtime does when this hook's async job queue is full, copied verbatim from
84    /// `on_overflow:` on this artifact's entry in the capsule operator's own manifest — the
85    /// same operator-only sourcing rule `capabilities` above follows. Consumed for
86    /// `runtime: hook` and inert for every other role (the manifest parser rejects the key
87    /// there); inert too for a hook that turns out to be `execution_mode: blocking`, which is
88    /// never queued.
89    pub on_overflow: murmur_artifact::HookOverflowPolicy,
90    /// This artifact's operator-declared `config:` block, copied verbatim from its entry in the
91    /// capsule operator's own manifest (`murmur_artifact::RuntimeArtifact::config`) — the same
92    /// operator-only sourcing rule `capabilities` above follows, so an artifact pulled from a
93    /// registry cannot configure itself.
94    ///
95    /// Lowered to compact JSON at staging by [`crate::artifact_config::lower_artifact_config`] and
96    /// delivered to this artifact alone as [`crate::artifact_config::ARTIFACT_CONFIG_ENV`]. `None`
97    /// means the variable is absent from the guest environment. Consumed for `runtime: hook`,
98    /// `runtime: tool` and `runtime: driver`; the manifest parser rejects the key on
99    /// `runtime: skill`, and a native tool warns `W-SEC-015` because it reads no per-artifact
100    /// environment at all.
101    pub config: Option<serde_yaml::Value>,
102}
103
104/// What `murmur.lock` pins one artifact at, already narrowed to this host.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct LockExpectation {
107    pub name: String,
108    pub resolved_version: String,
109    /// The hash the resolved payload must have. Selected by the caller from the lock entry's
110    /// `sha256` for the platform staging runs on, so it is whichever of `any` or
111    /// `platforms.<platform>` applies to this host.
112    pub sha256: String,
113}
114
115/// What staging actually resolved, for the caller to write back into `murmur.lock`.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct ResolvedLockArtifact {
118    pub name: String,
119    pub resolved_version: String,
120    /// Hash of the payload that was resolved on this host.
121    pub sha256: String,
122    /// Which platform the resolved payload is filed under, `None` for a platform-independent
123    /// one. Decides whether the lock entry pins `sha256.any` or `sha256.platforms.<platform>`.
124    pub platform: Option<String>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Default)]
128pub struct CapabilityPolicy {
129    pub network_allow: Vec<String>,
130    /// From `capabilities.peer_fetch.allow`: the peers this capsule may redeem a peer-file handle
131    /// against. Empty by default, which is deny — and which is also why no `fetch-peer-file` tool
132    /// manifest is written and the capsule's model never sees the tool exists.
133    ///
134    /// A separate list from [`Self::network_allow`], enforced with the same rule matcher but
135    /// never merged into it. Ingesting a peer's bytes lands a file in this capsule's own workdir,
136    /// so it is a prompt-injection surface and deserves its own operator control; a destination
137    /// in `network.allow` is not redeemable unless it also appears here, and declaring it here
138    /// does not widen `network.allow`.
139    pub peer_fetch_allow: Vec<String>,
140    /// From `capabilities.network.unix_sockets`. `false` by default, which makes the shell
141    /// subprocess tree's `socket(AF_UNIX, ...)` calls fail with `EACCES` at the kernel level on
142    /// both Linux tiers — `network_allow` above governs IP destinations only and would otherwise
143    /// leave a local daemon socket (`/var/run/docker.sock` — host root) completely unmediated.
144    /// `true` simply omits that seccomp rule; it is capsule-wide, not per-socket-path. See
145    /// `sandbox::denied_socket_domains`.
146    pub unix_sockets_allowed: bool,
147    pub filesystem_scope: Option<String>,
148    /// From `capabilities.filesystem.workdir_exec`. `false` by default, which withholds the
149    /// Landlock `Execute` right from the workdir's own `PathBeneath` rule — see
150    /// `sandbox::linux_enforce::workdir_access_rights`. With it withheld the kernel refuses to exec
151    /// anything under the workdir on the resolved path itself, so `shell_allow` is enforced
152    /// completely: a binary copied or compiled into the workdir cannot run under *any* name.
153    ///
154    /// `true` gives the right back for compile-and-run workflows and accepts, explicitly, that
155    /// `shell_allow` is then unenforceable for anything inside the workdir. That is why this field
156    /// is the one *grant* in this struct that also lowers the session's achieved containment class
157    /// (see `crate::containment::achieved_containment_class`) — nothing else here can.
158    pub workdir_exec_allowed: bool,
159    /// From `capabilities.filesystem.read_only`: workdir subtrees the capsule may read but must
160    /// not write, in the same vocabulary [`Self::filesystem_scope`] uses. Empty by default, which
161    /// is the whole workdir writable.
162    ///
163    /// Carried here as the declared strings and lowered once at staging into
164    /// `crate::protected_paths::ProtectedPaths`, which is what the dispatch check consults — so a
165    /// malformed entry refuses the launch rather than one call at a time. Unlike every other field
166    /// here this one grants nothing: it can only subtract from what the workdir preopen already
167    /// allows.
168    pub read_only_paths: Vec<String>,
169    pub shell_allow: Vec<String>,
170    pub spawn_allow: Vec<String>,
171    pub shell_strip_env: Vec<String>,
172    pub shell_baseline_env: Vec<String>,
173    /// Typed interpreter-runtime grants from `capabilities.shell.interpreter_runtime`: each
174    /// names an already-allowlisted binary and the exact host directories outside the workdir
175    /// its import machinery needs, with a per-directory `list_dir` enumerability flag. Empty
176    /// unless the manifest declares them. Consumed on `KernelFull` to add narrow Landlock
177    /// grants (see `sandbox::resolve_interpreter_runtime_grants`) and to fire `W-SEC-009`.
178    pub shell_interpreter_runtime: Vec<murmur_artifact::InterpreterRuntimeGrant>,
179    /// Typed staged-runtime grants from `capabilities.shell.staged_runtime`: each names an
180    /// already-allowlisted binary, the absolute host path of a pinned runtime tree, and the `pin`
181    /// identifying that tree's build. Empty unless the manifest declares them.
182    ///
183    /// Consumed at staging to enforce that declaring one requires an effective `sealed` floor (see
184    /// `crate::staged_runtime::check_staged_runtime_floor`) and to render the `staged runtime`
185    /// section of `--explain-scope`. Per-binary mutually exclusive with
186    /// [`Self::shell_interpreter_runtime`], which the manifest parser already guarantees.
187    pub shell_staged_runtime: Vec<murmur_artifact::StagedRuntimeGrant>,
188    /// Host env var names a WASM guest may observe, from `capabilities.env.allow`. Empty by
189    /// default: a guest sees only the runtime's own `MURMUR_*` injection unless the manifest
190    /// names a variable here. `shell_strip_env` and the credential patterns still apply on
191    /// top, so a credential-shaped name declared here is stripped anyway.
192    pub env_allow: Vec<String>,
193    /// Execution limits applied to every guest store in the session. Always fully
194    /// resolved — `capability_policy_from_runtime_manifest` substitutes a default for
195    /// each field `capabilities.limits` omits, so there is no "unset" state here and a
196    /// silent manifest yields [`ExecutionLimits::default`] rather than no limits.
197    pub limits: ExecutionLimits,
198    /// OS-level bounds applied to every *native subprocess* the session spawns — rlimits, the
199    /// Linux cgroup v2 scope, and the workdir-size ceiling. Resolved on exactly the same terms
200    /// as `limits` above (each omitted `capabilities.resources` field replaced by its default,
201    /// no "unset" state), but a completely different subject: `limits` bounds a WASM guest
202    /// inside its wasmtime store, this bounds the processes the host forks. See
203    /// [`crate::resources`].
204    pub resources: crate::resources::HostResourceLimits,
205    /// The containment class this capsule asked for, from `capabilities.containment`.
206    ///
207    /// Unlike every other field here it is a *requirement*, not a grant — and unlike
208    /// `StageRequest::declared_containment_floor` it is only the manifest's own vote, before the
209    /// workspace config and `--containment` are folded in. `stage_session` overwrites it with the
210    /// combined floor as soon as that is known, so anything reading it off a `StagedSession` sees
211    /// the effective value; the manifest-only value exists so a `CapabilityPolicy` built straight
212    /// from a manifest is still self-consistent.
213    ///
214    /// Read by `sandbox::ShellEnforcement::resolve` to decide whether a sealed-capable host
215    /// actually installs a composed root for this session — a capsule that declared `scoped` must
216    /// keep getting `scoped`'s mechanism. See `sandbox::applied_tier`.
217    pub containment_floor: murmur_artifact::ContainmentClass,
218    /// Whether the capsule's own top-level `capabilities.state` block was declared.
219    ///
220    /// Not a grant, and the only field here that is not: a durable state store is granted per
221    /// *artifact*, so a capsule-wide declaration reaches nothing at all. It is recorded so
222    /// `stage_session` can say that once (`W-SEC-014`) instead of leaving an operator to discover
223    /// an empty directory, and it is read by nothing else.
224    pub state_declared: bool,
225    /// Recorded for the same reason [`Self::state_declared`] is, and applied just as little: the
226    /// `murmur:conversation/read` grant is per artifact, so a capsule-wide declaration reaches
227    /// nothing and `stage_session` says so once (`W-SEC-016`).
228    pub conversation_declared: bool,
229    /// From `capabilities.plan.submit`: whether this capsule's model is offered the
230    /// runtime-provided `submit-plan` tool. `false` by default, which is deny — and which is also
231    /// why no `submit-plan` tool manifest is written and the capsule's model never sees the tool
232    /// exists.
233    ///
234    /// It grants no reach of its own: every step of a submitted plan runs through this same
235    /// session's tool dispatch, its shell allowlist and its spawn grant, so a plan reaches exactly
236    /// what the model could already call one call at a time.
237    pub plan_submit: bool,
238    /// `capabilities.limits.deadline_seconds` exactly as the manifest declared it — `None`
239    /// when it declared nothing. Retained undefaulted alongside the fully-resolved `limits`
240    /// above purely so hook calls can apply their own, lower default without mistaking an
241    /// explicit `600` for silence. See [`Self::hook_limits`].
242    pub declared_deadline_seconds: Option<u64>,
243}
244
245impl CapabilityPolicy {
246    /// Execution limits for hook lifecycle calls — [`Self::limits`] with the hook-specific
247    /// deadline default substituted when the manifest declared none.
248    ///
249    /// A hook call is one bounded piece of work, so it does not inherit the capsule-wide
250    /// ten-minute budget that exists for a capsule's whole `run`: a wedged hook would
251    /// otherwise stall the session for most of that, every event. An explicitly declared
252    /// `deadline_seconds` still wins, for hooks as for every other guest.
253    #[must_use]
254    pub fn hook_limits(&self) -> ExecutionLimits {
255        self.limits.for_hook_calls(self.declared_deadline_seconds)
256    }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct InstalledArtifactSummary {
261    pub name: String,
262    pub version: String,
263    pub runtime: ArtifactRuntime,
264    pub implementation: Option<ArtifactImplementation>,
265}
266
267#[derive(Clone)]
268pub(crate) struct StagedHookArtifact {
269    pub name: String,
270    pub version: String,
271    pub component: Component,
272    /// Behavioral contract (binding/execution_mode/commit_policy) from the hook artifact's
273    /// *own* bundled manifest. Carries no capability information — see `grant`.
274    pub config: HookConfig,
275    /// What this hook is allowed to do, lowered and validated at staging time from the
276    /// operator's own manifest entry. Applied identically by all three hook instantiation
277    /// paths in `hooks.rs`; `HookCapabilityGrant::default()` is full default-deny.
278    pub grant: HookCapabilityGrant,
279    /// What to do when this hook's async job queue is full — from the operator's manifest
280    /// entry (via [`ArtifactRequest::on_overflow`]), on the same terms as `grant` and never
281    /// from `config`. Read only when `config.execution_mode` is `Async`; a blocking hook has
282    /// no queue to overflow.
283    pub on_overflow: murmur_artifact::HookOverflowPolicy,
284}
285
286/// How `mur run --resume` puts the loaded conversation in front of the model.
287///
288/// `Full` is often the cheaper of the two: a verbatim reload can land on the provider's own
289/// prompt cache, while compaction changes the prefix from the first altered token, guarantees a
290/// cache miss, and costs an inference call to produce the summary. `Compact` is the answer when
291/// the conversation would not fit the context window at all.
292#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
293pub enum ResumeMode {
294    #[default]
295    Full,
296    Compact,
297}
298
299/// One operator act: continue *this* conversation, whatever the capsule's own
300/// `lifecycle.conversation` policy says about carrying one between tasks.
301///
302/// Sugar over [`StageRequest::context_id`], never a second load path: the CLI resolves a session
303/// address to the context id that session ran under and fills both this and `context_id`. What
304/// this adds is the override — the record is loaded even under `lifecycle.conversation:
305/// stateless` — and the provenance the trace records.
306#[derive(Debug, Clone)]
307pub struct ResumeRequest {
308    /// The prior session's id, recorded verbatim as `session_start.resumed_from`.
309    pub from_session: String,
310    pub mode: ResumeMode,
311}
312
313#[derive(Debug, Clone)]
314pub struct StageRequest {
315    pub manifest_dir: PathBuf,
316    pub capsule_name: String,
317    pub capsule_version: String,
318    pub capsule_component_bytes: Vec<u8>,
319    pub artifacts: Vec<ArtifactRequest>,
320    pub allowlisted_tools: HashSet<String>,
321    pub lock_expectations: Option<Vec<LockExpectation>>,
322    pub capability_policy: CapabilityPolicy,
323    pub inference: Option<InferenceConfig>,
324    /// The global config file a `${NAME}` in `inference.api_key` is looked up in first, as
325    /// `credentials.NAME`, and re-read from while the session runs. `None` looks in the launching
326    /// environment only.
327    pub credentials_file: Option<PathBuf>,
328    /// Whether `inference` above had its system-prompt declaration replaced by the caller
329    /// (`mur run --system-prompt`) rather than read from the manifest. Purely a provenance
330    /// signal for the trace: the override itself is already applied to `inference`, which by
331    /// this point is indistinguishable from a manifest that declared the same prompt inline.
332    /// `true` even when the override cleared the prompt to nothing.
333    pub system_prompt_overridden: bool,
334    pub context: Option<ContextConfig>,
335    /// Context id every `task.md` task of this launch runs under, from `mur run --context`.
336    /// `None` mints a fresh one per task. Validated by `stage_session` as one path segment: it is
337    /// a directory name in the conversation record path.
338    pub context_id: Option<String>,
339    /// `mur run --resume <session>`, already resolved: `context_id` above holds the context that
340    /// session ran under, and this carries the session's own id and the mode. `None` is every
341    /// ordinary launch. `stage_session` refuses a resume whose context kept no record on disk,
342    /// and a `--resume-mode compact` with no hook bound to `on-compaction`.
343    pub resume: Option<ResumeRequest>,
344    /// OTLP/HTTP endpoint for span export; None = no external OTel emission.
345    pub otel_endpoint: Option<String>,
346    /// JSON-serialized EvalConfig injected into hook WASI env as MURMUR_EVAL_CONFIG.
347    pub eval_config_json: Option<String>,
348    /// Dataset case identifier injected as MURMUR_CASE_ID when running a dataset.
349    pub case_id: Option<String>,
350    /// Dataset identifier injected as MURMUR_DATASET_ID when running a dataset.
351    pub dataset_id: Option<String>,
352    /// Lifecycle config from the manifest (None = use LifecycleConfig::default()).
353    pub lifecycle: Option<LifecycleConfig>,
354    /// Optional override applied on top of lifecycle (e.g. from CLI flags or mur-roost).
355    pub lifecycle_override: Option<LifecycleOverride>,
356    /// Trace config from the manifest (None = defaults apply: input captured, output not).
357    pub trace: Option<murmur_artifact::TraceConfig>,
358    /// Optional user project directory to mount as the capsule's accessible workspace.
359    /// When set, session artifacts go into `<workdir>/.murmur/<session_id>/`.
360    /// When None, uses the default temp dir (existing behavior).
361    pub workdir: Option<std::path::PathBuf>,
362    /// Address to bind the HTTP server on. Defaults to "127.0.0.1" for local runs.
363    /// Use "0.0.0.0" when deploying to a VM so the capsule is reachable externally.
364    pub bind_addr: String,
365    /// Internal port from `network.internal_port` in the manifest. When `Some`, the runtime
366    /// binds strictly to this port and errors if it is already in use. When `None`, the OS
367    /// assigns a port.
368    pub internal_port: Option<u16>,
369    /// Minimum containment class this session must achieve, already combined across every
370    /// source that asked for one (manifest / workspace config / `--containment`) by taking the
371    /// strongest. Required rather than optional: "nobody declared anything" is
372    /// [`ContainmentClass::Advisory`], an explicit floor every host clears, not an absence.
373    /// `stage_session` refuses to stage when the host cannot meet it.
374    pub declared_containment_floor: murmur_artifact::ContainmentClass,
375    /// The manifest's top-level `exports:` block. `None` — and `Some` with no `files:` — means
376    /// the resource plane is not declared and every request to it is denied.
377    ///
378    /// Carried beside `capability_policy` rather than inside it: an export is a disclosure the
379    /// operator makes, not a capability the guest holds, and nothing derived from this field ever
380    /// reaches the achieved containment class.
381    pub exports: Option<murmur_artifact::Exports>,
382    /// The approval a delegated child was handed by its parent, presented once at registration.
383    ///
384    /// `None` for every top-level launch, which the daemon admits against its own `--spawn-allow`
385    /// instead. Unlike [`StagedSession::spawn_credential`], this exists before staging: it names
386    /// an artifact rather than a session, and the parent already held it when it started this
387    /// process.
388    pub spawn_grant: Option<SpawnApproval>,
389    /// `spend.machine_tokens_per_day` from the operator's effective `config.yaml`. `Some` makes
390    /// staging open the shared daily spend ledger for a `transport: http` session with a driver,
391    /// and refuse when it cannot; `None` — every launch that does not read the operator config —
392    /// keeps no ledger and is not counted.
393    pub machine_tokens_per_day: Option<u64>,
394}
395
396pub struct StagedSession {
397    pub session_id: String,
398    pub workdir: PathBuf,
399    /// User project directory (or session dir if no --workdir). WASM tools see this at ".".
400    pub accessible_workdir: std::path::PathBuf,
401    pub(crate) manifest_dir: PathBuf,
402    pub(crate) capsule_name: String,
403    pub(crate) capsule_version: String,
404    pub(crate) capsule_url: String,
405    pub resolved_lock_artifacts: Vec<ResolvedLockArtifact>,
406    pub(crate) installed_artifacts: Vec<InstalledArtifactSummary>,
407    pub(crate) inference: Option<InferenceConfig>,
408    /// The gateway the configured `transport: http` driver reaches its provider through, built at
409    /// staging from `inference` and the driver's own `inference_auth:` declaration. `None` for
410    /// `transport: process` and for a capsule with no inference.
411    pub(crate) inference_gateway: Option<Arc<crate::inference_gateway::InferenceGateway>>,
412    /// This session's spend account, built at staging from `inference.max_session_tokens` and
413    /// [`StageRequest::machine_tokens_per_day`]. Always present: a session with no ceiling gets a
414    /// meter that admits everything, so every driver call has an admission to open.
415    pub(crate) spend: Arc<crate::spend::SpendMeter>,
416    /// Copied from [`StageRequest::system_prompt_overridden`] — the only record left that the
417    /// prompt in `inference` came from `--system-prompt` and not from the manifest. Passed to
418    /// `TraceWriter::open`, which turns it into `session_start.system_prompt_source`.
419    pub(crate) system_prompt_overridden: bool,
420    pub(crate) context: Option<ContextConfig>,
421    /// Copied from [`StageRequest::context_id`] and already validated. Read by `launch_session`,
422    /// which uses it in place of a freshly minted id for every `task.md` task.
423    pub(crate) context_id: Option<String>,
424    /// Copied from [`StageRequest::resume`] and already checked. Read by `launch_session`, which
425    /// turns it into `session_start.resumed_from` and the agent loop's record-load override.
426    pub(crate) resume: Option<ResumeRequest>,
427    pub(crate) engine: Engine,
428    /// `None` for manifest-only agent capsules; `Some` for script capsules with a WASM component.
429    pub(crate) capsule_component: Option<Component>,
430    pub(crate) tool_components: HashMap<String, Component>,
431    /// Per-artifact narrowing for the tool/driver dispatch path, keyed by artifact name and
432    /// lowered at staging time from the operator's own manifest entries. Holds an entry only
433    /// for artifacts that actually declared a `capabilities:` block — a name absent from this
434    /// map (the overwhelmingly common case) runs on the full capsule ceiling, exactly as
435    /// before per-artifact narrowing existed.
436    pub(crate) artifact_grants: HashMap<String, ToolCapabilityGrant>,
437    pub(crate) hook_components: Vec<StagedHookArtifact>,
438    pub(crate) allowlisted_tools: HashSet<String>,
439    pub(crate) capability_policy: CapabilityPolicy,
440    pub(crate) otel_endpoint: Option<String>,
441    pub(crate) eval_config_json: Option<String>,
442    pub(crate) case_id: Option<String>,
443    pub(crate) dataset_id: Option<String>,
444    /// Resolved lifecycle config (manifest + override already applied).
445    pub(crate) lifecycle: LifecycleConfig,
446    /// How much of each turn's driver request this session's trace keeps (resolved from the
447    /// manifest's `trace:` block, or [`murmur_artifact::TraceCapture::default`] when it has
448    /// none).
449    pub(crate) trace_capture: murmur_artifact::TraceCapture,
450    /// What bounds this workdir's session directories, from the manifest's `trace.retain` block.
451    /// `None` — the overwhelmingly common case — prunes nothing, ever.
452    pub(crate) trace_retain: Option<murmur_artifact::TraceRetainConfig>,
453    /// Address the HTTP server is bound to (copied from StageRequest::bind_addr).
454    pub(crate) bind_addr: String,
455    /// Internal port from the manifest (copied from StageRequest::internal_port).
456    pub(crate) internal_port: Option<u16>,
457    /// Floor this session was staged against (copied from
458    /// StageRequest::declared_containment_floor).
459    pub(crate) declared_containment_floor: murmur_artifact::ContainmentClass,
460    /// The complete effective grant set this session was staged with — the same report
461    /// `mur run --explain-scope --json` prints, built by `containment::scope_report_for_tier`
462    /// from this session's own policy, declared floor and single host probe. Written verbatim to
463    /// `trace.jsonl`'s `session_start` as `effective_grants`, and the source of the
464    /// `containment_declared`/`containment_achieved`/`workdir_exec` fields alongside it, so the
465    /// summary and the full report cannot disagree.
466    ///
467    /// Replaces a former separate `achieved_containment` field: what the host actually provided
468    /// is `scope_report.achieved_containment`, and keeping a second copy beside it only created a
469    /// way for the two to drift.
470    pub(crate) scope_report: crate::containment::ScopeReport,
471    /// The single host reading this session was staged against, taken at the top of
472    /// `runtime::stage_session`. `launch_session` passes it to `sandbox::ShellEnforcement::resolve`
473    /// rather than taking a new one, so the mechanisms installed at launch are the ones
474    /// [`Self::scope_report`] already claims.
475    pub(crate) host_probe: crate::sandbox::HostProbe,
476    /// The declared read-only surface, lowered and validated by `stage_session` — a malformed
477    /// entry refuses the launch there rather than surfacing at the first call. Empty for every
478    /// capsule that declared no `capabilities.filesystem.read_only`.
479    pub(crate) protected_paths: crate::protected_paths::ProtectedPaths,
480    /// What each staged tool's own `input_schema` declared about where its inputs go, lowered by
481    /// `stage_session` from the manifests it wrote into `<workdir>/tools/`. Empty for a capsule
482    /// that declared no `read_only`, which reads no schema at all, and for one whose tools
483    /// annotated nothing.
484    pub(crate) tool_annotations: crate::tool_annotations::ToolAnnotationMap,
485    /// The declared read-only file surface, with its root already checked against this session's
486    /// accessible workdir by `stage_session` — a root that resolves outside it refuses the launch
487    /// rather than being served. `None` means no resource plane.
488    pub(crate) exports_files: Option<murmur_artifact::FileExport>,
489    /// The declared peer-handoff surface, with its root already checked against this session's
490    /// accessible workdir and its `max_ttl` already checked against `lifecycle.after_task` by
491    /// `stage_session`. `None` means no peer plane: nothing mints, and `/resources/peer/` answers
492    /// `no_peer_plane`.
493    pub(crate) exports_peer_files: Option<murmur_artifact::PeerFilesExport>,
494    /// Registry used to resolve this session's artifacts, retained so `manage.pull()` can
495    /// resolve additional artifacts at runtime after staging has completed.
496    pub(crate) registry: Arc<dyn Registry>,
497    /// Keeps this session's epoch ticker running: without it no epoch deadline set on any
498    /// store built from `engine` can ever fire. Held here (rather than detached) so ticking
499    /// stops when the session's `StagedSession` drops. `launch_session` moves the other
500    /// fields out one by one, which leaves this one in place until the session returns.
501    pub(crate) _epoch_ticker: EpochTicker,
502    /// The credential this session presents when it asks `mur-roost` to spawn a capsule.
503    ///
504    /// `None` for every session that cannot delegate, which is nearly all of them. Set by the
505    /// caller between `stage_session` and `launch_session` rather than carried in
506    /// [`StageRequest`]: the credential names a session id, and `stage_session` is what mints one,
507    /// so it cannot exist before staging returns.
508    pub(crate) spawn_credential: Option<SpawnCredential>,
509    /// Copied from [`StageRequest::spawn_grant`]. Read by `launch_session`, which presents it at
510    /// `POST /register` and never again — an approval covers one launch.
511    pub(crate) spawn_grant: Option<SpawnApproval>,
512    /// The handle the spawning capsule's launcher injected, read once per process at staging.
513    ///
514    /// `None` for every capsule nobody delegated. Its `session_id` and `delegation_id` become
515    /// `session_start.spawned_by` and `session_start.delegation_id`; its `report_to` — present
516    /// only for a parent that wants a completion — is what `launch_session`'s reporting guard
517    /// posts to. Read here rather than at each of those two points so one process reads
518    /// `MURMUR_SPAWNER` once and both readers see the same value.
519    pub(crate) spawner: Option<crate::delegation::SpawnerHandle>,
520}
521
522impl StagedSession {
523    /// Hands this session the credential it will present to the spawning daemon.
524    pub fn set_spawn_credential(&mut self, credential: SpawnCredential) {
525        self.spawn_credential = Some(credential);
526    }
527
528    /// The credential this session presents, if it was granted one.
529    pub fn spawn_credential(&self) -> Option<&SpawnCredential> {
530        self.spawn_credential.as_ref()
531    }
532
533    /// The grant set this session was staged with, as written to the trace.
534    pub fn scope_report(&self) -> &crate::containment::ScopeReport {
535        &self.scope_report
536    }
537}
538
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct LaunchResult {
541    pub session_id: String,
542    pub workdir: PathBuf,
543}
544
545pub fn capability_policy_from_runtime_manifest(
546    manifest: &murmur_artifact::RuntimeManifest,
547) -> CapabilityPolicy {
548    let caps = manifest.capabilities.as_ref();
549    let network_allow = caps
550        .and_then(|c| c.network.as_ref())
551        .map(|network| network.allow.clone())
552        .unwrap_or_default();
553    // Absent `capabilities.peer_fetch` block means denied, on the same terms — and separately
554    // from `network.allow`, which this deliberately does not read.
555    let peer_fetch_allow = caps
556        .and_then(|c| c.peer_fetch.as_ref())
557        .map(|peer_fetch| peer_fetch.allow.clone())
558        .unwrap_or_default();
559
560    // Absent `capabilities.network` block, or absent key within it, both mean denied.
561    let unix_sockets_allowed = caps
562        .and_then(|c| c.network.as_ref())
563        .is_some_and(|network| network.unix_sockets);
564
565    // Absent `capabilities.plan` block, or `submit: false` within it, both mean denied.
566    let plan_submit = caps
567        .and_then(|c| c.plan.as_ref())
568        .is_some_and(|plan| plan.submit);
569
570    // Recorded, never applied: see `CapabilityPolicy::state_declared`.
571    let state_declared = caps.is_some_and(|c| c.state.is_some());
572    let conversation_declared = caps.is_some_and(|c| c.conversation.is_some());
573
574    let filesystem_scope = caps
575        .and_then(|c| c.filesystem.as_ref())
576        .and_then(|filesystem| filesystem.scope.clone());
577    // Absent `capabilities.filesystem` block, or absent key within it, both mean denied — the same
578    // shape as `unix_sockets_allowed` above, for the same reason: the widening has to be declared.
579    let workdir_exec_allowed = caps
580        .and_then(|c| c.filesystem.as_ref())
581        .is_some_and(|filesystem| filesystem.workdir_exec);
582    // Absent block, absent key and an empty list are one declaration: nothing is protected.
583    let read_only_paths = caps
584        .and_then(|c| c.filesystem.as_ref())
585        .map(|filesystem| filesystem.read_only.clone())
586        .unwrap_or_default();
587
588    let shell_allow = caps
589        .and_then(|c| c.shell.as_ref())
590        .map(|shell| shell.allow.clone())
591        .unwrap_or_default();
592    let shell_strip_env = caps
593        .and_then(|c| c.shell.as_ref())
594        .and_then(|shell| shell.strip_env.clone())
595        .unwrap_or_default();
596    let shell_baseline_env = caps
597        .and_then(|c| c.shell.as_ref())
598        .and_then(|shell| shell.baseline_env.clone())
599        .unwrap_or_default();
600    let shell_interpreter_runtime = caps
601        .and_then(|c| c.shell.as_ref())
602        .map(|shell| shell.interpreter_runtime.clone())
603        .unwrap_or_default();
604    let shell_staged_runtime = caps
605        .and_then(|c| c.shell.as_ref())
606        .map(|shell| shell.staged_runtime.clone())
607        .unwrap_or_default();
608
609    let spawn_allow = caps
610        .and_then(|c| c.spawn.as_ref())
611        .map(|spawn| spawn.allow.clone())
612        .unwrap_or_default();
613
614    let env_allow = caps
615        .and_then(|c| c.env.as_ref())
616        .map(|env| env.allow.clone())
617        .unwrap_or_default();
618
619    let limits = ExecutionLimits::resolve(caps.and_then(|c| c.limits.as_ref()));
620    let declared_deadline_seconds = caps
621        .and_then(|c| c.limits.as_ref())
622        .and_then(|l| l.deadline_seconds);
623
624    let resources = crate::resources::resolve(caps.and_then(|c| c.resources.as_ref()));
625
626    CapabilityPolicy {
627        network_allow,
628        peer_fetch_allow,
629        unix_sockets_allowed,
630        filesystem_scope,
631        workdir_exec_allowed,
632        read_only_paths,
633        shell_allow,
634        spawn_allow,
635        shell_strip_env,
636        shell_baseline_env,
637        shell_interpreter_runtime,
638        shell_staged_runtime,
639        env_allow,
640        limits,
641        resources,
642        containment_floor: caps.and_then(|c| c.containment).unwrap_or_default(),
643        state_declared,
644        conversation_declared,
645        plan_submit,
646        declared_deadline_seconds,
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn policy_for(manifest_yaml: &str) -> CapabilityPolicy {
655        let manifest = murmur_artifact::RuntimeManifest::from_yaml_str(manifest_yaml)
656            .expect("manifest fixture must parse");
657        capability_policy_from_runtime_manifest(&manifest)
658    }
659
660    /// `read_only` reaches the policy in declaration order, trimmed, with empty entries already
661    /// dropped by the manifest parser — and a capsule that declares none gets an empty list, which
662    /// is what makes the dispatch check free for it.
663    #[test]
664    fn read_only_paths_are_carried_through_and_default_to_empty() {
665        let declared = policy_for(
666            r#"
667name: cap
668version: 0.1.0
669capabilities:
670  filesystem:
671    read_only:
672      - tests
673      - "  bench/fixtures  "
674      - "   "
675"#,
676        );
677        assert_eq!(declared.read_only_paths, vec!["tests", "bench/fixtures"]);
678
679        let silent = policy_for(
680            "name: cap
681version: 0.1.0
682",
683        );
684        assert!(silent.read_only_paths.is_empty());
685
686        let no_key = policy_for(
687            r#"
688name: cap
689version: 0.1.0
690capabilities:
691  filesystem:
692    scope: work
693"#,
694        );
695        assert!(no_key.read_only_paths.is_empty());
696    }
697
698    /// A manifest that sets no deadline gives hook calls the hook-specific default while the
699    /// capsule's own guests keep the capsule-wide one — one manifest, two budgets.
700    #[test]
701    fn a_silent_manifest_gives_hook_calls_the_lower_deadline() {
702        let policy = policy_for(
703            r#"
704name: cap
705version: 0.1.0
706artifacts: []
707"#,
708        );
709
710        assert_eq!(
711            policy.limits.deadline_seconds,
712            crate::limits::DEFAULT_DEADLINE_SECONDS
713        );
714        assert_eq!(
715            policy.hook_limits().deadline_seconds,
716            crate::limits::HOOK_DEFAULT_DEADLINE_SECONDS
717        );
718        // The default policy a hand-built fixture gets must agree with the silent-manifest one.
719        assert_eq!(
720            CapabilityPolicy::default().hook_limits().deadline_seconds,
721            crate::limits::HOOK_DEFAULT_DEADLINE_SECONDS
722        );
723    }
724
725    /// An explicit `deadline_seconds` applies to every guest, hooks included — the hook
726    /// default only fills a silence.
727    #[test]
728    fn an_explicit_deadline_applies_to_hook_calls_too() {
729        let policy = policy_for(
730            r#"
731name: cap
732version: 0.1.0
733artifacts: []
734capabilities:
735  limits:
736    deadline_seconds: 120
737"#,
738        );
739
740        assert_eq!(policy.declared_deadline_seconds, Some(120));
741        assert_eq!(policy.limits.deadline_seconds, 120);
742        assert_eq!(policy.hook_limits().deadline_seconds, 120);
743    }
744
745    /// `capabilities.shell.staged_runtime` lowers onto the policy verbatim — binary, source path
746    /// and pin all survive, since the pin is what a human later compares across two hosts.
747    #[test]
748    fn staged_runtime_grants_lower_onto_the_policy() {
749        let policy = policy_for(
750            r#"
751name: cap
752version: 0.1.0
753artifacts: []
754capabilities:
755  containment: sealed
756  shell:
757    allow:
758      - python3
759    staged_runtime:
760      - binary: python3
761        source_path: /opt/testbed/conda/envs/django__django
762        pin: conda-4.10.3/python-3.9.19
763"#,
764        );
765
766        assert_eq!(
767            policy.shell_staged_runtime,
768            vec![murmur_artifact::StagedRuntimeGrant {
769                binary: "python3".to_string(),
770                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
771                pin: "conda-4.10.3/python-3.9.19".to_string(),
772            }]
773        );
774        // The two mechanisms stay separate all the way down: lowering one must not populate the
775        // other.
776        assert!(policy.shell_interpreter_runtime.is_empty());
777    }
778
779    /// A shell block that declares no staged runtime lowers to an empty list, not to a default
780    /// grant of any kind.
781    #[test]
782    fn absent_staged_runtime_lowers_to_empty() {
783        let policy = policy_for(
784            r#"
785name: cap
786version: 0.1.0
787artifacts: []
788capabilities:
789  shell:
790    allow:
791      - bash
792"#,
793        );
794
795        assert!(policy.shell_staged_runtime.is_empty());
796    }
797
798    /// The default every existing manifest gets. Three separate "nothing was declared" shapes —
799    /// no `capabilities:` at all, no `filesystem:` within it, and a `filesystem:` that only sets
800    /// `scope` — must all lower to denied, because each one is a real manifest in the wild and any
801    /// of them silently keeping workdir `Execute` would reopen the rename-to-an-allowed-basename
802    /// bypass this default exists to close.
803    #[test]
804    fn workdir_exec_denied_for_every_shape_of_undeclared() {
805        for manifest_yaml in [
806            r#"
807name: cap
808version: 0.1.0
809artifacts: []
810"#,
811            r#"
812name: cap
813version: 0.1.0
814artifacts: []
815capabilities:
816  network:
817    allow:
818      - https://api.anthropic.com
819"#,
820            r#"
821name: cap
822version: 0.1.0
823artifacts: []
824capabilities:
825  filesystem:
826    scope: workdir
827"#,
828        ] {
829            let policy = policy_for(manifest_yaml);
830            assert!(
831                !policy.workdir_exec_allowed,
832                "an undeclared workdir_exec must lower to denied: {manifest_yaml}"
833            );
834        }
835    }
836
837    #[test]
838    fn workdir_exec_denied_when_declared_false() {
839        let policy = policy_for(
840            r#"
841name: cap
842version: 0.1.0
843artifacts: []
844capabilities:
845  filesystem:
846    workdir_exec: false
847"#,
848        );
849
850        assert!(!policy.workdir_exec_allowed);
851    }
852
853    #[test]
854    fn workdir_exec_allowed_when_declared_true() {
855        let policy = policy_for(
856            r#"
857name: cap
858version: 0.1.0
859artifacts: []
860capabilities:
861  filesystem:
862    scope: workdir
863    workdir_exec: true
864"#,
865        );
866
867        assert!(policy.workdir_exec_allowed);
868        // The two filesystem keys stay independent: lowering one must not disturb the other.
869        assert_eq!(policy.filesystem_scope.as_deref(), Some("workdir"));
870    }
871
872    /// A manifest with no `capabilities:` block at all denies unix sockets — the field must not
873    /// pick up any implicit widening from the "nothing declared" path.
874    #[test]
875    fn unix_sockets_denied_when_capabilities_block_is_absent() {
876        let policy = policy_for(
877            r#"
878name: cap
879version: 0.1.0
880artifacts: []
881"#,
882        );
883
884        assert!(!policy.unix_sockets_allowed);
885    }
886
887    /// A declared `network:` block that never mentions the key still denies.
888    #[test]
889    fn unix_sockets_denied_when_network_block_omits_the_key() {
890        let policy = policy_for(
891            r#"
892name: cap
893version: 0.1.0
894artifacts: []
895capabilities:
896  network:
897    allow:
898      - https://api.anthropic.com
899"#,
900        );
901
902        assert!(!policy.unix_sockets_allowed);
903        assert_eq!(
904            policy.network_allow,
905            vec!["https://api.anthropic.com".to_string()]
906        );
907    }
908
909    #[test]
910    fn unix_sockets_denied_when_declared_false() {
911        let policy = policy_for(
912            r#"
913name: cap
914version: 0.1.0
915artifacts: []
916capabilities:
917  network:
918    unix_sockets: false
919"#,
920        );
921
922        assert!(!policy.unix_sockets_allowed);
923    }
924
925    #[test]
926    fn unix_sockets_allowed_when_declared_true() {
927        let policy = policy_for(
928            r#"
929name: cap
930version: 0.1.0
931artifacts: []
932capabilities:
933  network:
934    unix_sockets: true
935"#,
936        );
937
938        assert!(policy.unix_sockets_allowed);
939    }
940
941    /// The opt-in is independent of the IP allowlist in both directions: a non-empty `allow`
942    /// does not imply unix sockets, and `unix_sockets: true` does not imply any IP destination.
943    #[test]
944    fn unix_sockets_opt_in_is_independent_of_the_ip_allowlist() {
945        let policy = policy_for(
946            r#"
947name: cap
948version: 0.1.0
949artifacts: []
950capabilities:
951  network:
952    unix_sockets: true
953    allow:
954      - https://api.anthropic.com
955"#,
956        );
957
958        assert!(policy.unix_sockets_allowed);
959        assert_eq!(
960            policy.network_allow,
961            vec!["https://api.anthropic.com".to_string()]
962        );
963    }
964
965    /// `CapabilityPolicy::default()` is what the sandbox tests and every `..Default::default()`
966    /// call site start from; it must deny, not inherit whatever a future field ordering implies.
967    #[test]
968    fn default_capability_policy_denies_unix_sockets() {
969        assert!(!CapabilityPolicy::default().unix_sockets_allowed);
970    }
971}