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