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