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