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};
19
20pub(crate) struct DispatchOutcome {
21 pub result: ToolResult,
22 pub shell: Option<ShellDispatchInfo>,
23 /// True when the dispatch was served by reading a skill.md file rather than running
24 /// a binary or WASM component. Used by the agent loop to write a `skill_call` trace
25 /// event instead of a `tool_call` event.
26 pub is_skill: bool,
27 /// Set when this dispatch failed in a way that ends the *session* rather than just this
28 /// tool call — today only a `sealed` composed-root construction failure
29 /// ([`crate::shell::ShellExecError::session_fatal`]). `result` still describes the failed
30 /// call so the trace records what was attempted; the agent turn loop then returns this
31 /// error instead of feeding the failure back to the model for another turn.
32 ///
33 /// A dispatch that merely failed leaves this `None`: a capsule reacting to a broken tool
34 /// call is ordinary, whereas a capsule continuing to run after its declared containment
35 /// boundary stopped being establishable is the silent-degradation failure mode the whole
36 /// containment-class mechanism exists to prevent.
37 pub fatal: Option<crate::errors::RuntimeError>,
38}
39
40impl DispatchOutcome {
41 pub(crate) fn tool(result: ToolResult) -> Self {
42 Self {
43 result,
44 shell: None,
45 is_skill: false,
46 fatal: None,
47 }
48 }
49
50 pub(crate) fn skill(result: ToolResult) -> Self {
51 Self {
52 result,
53 shell: None,
54 is_skill: true,
55 fatal: None,
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ArtifactRequest {
62 pub name: String,
63 pub version: String,
64 pub runtime: ArtifactRuntime,
65 /// Optional local source path (skill artifacts only). When set, the runtime resolves the
66 /// skill's `skill.md` directly from this path and skips registry resolution entirely.
67 /// Relative paths resolve against `StageRequest::manifest_dir`.
68 pub source: Option<String>,
69 /// Per-artifact capability grant copied verbatim from this artifact's entry in the
70 /// capsule operator's own manifest (`murmur_artifact::RuntimeArtifact::capabilities`).
71 /// Consumed for `runtime: hook` (default-deny baseline, `None` = deny everything) and for
72 /// `runtime: tool`/`runtime: driver` (ceiling baseline, `None` = the capsule-wide policy
73 /// unchanged); the manifest parser rejects the key on `runtime: skill`. Never sourced
74 /// from the artifact's own bundled `murmur.yaml`, so an artifact cannot self-grant.
75 pub capabilities: Option<murmur_artifact::Capabilities>,
76 /// What the runtime does when this hook's async job queue is full, copied verbatim from
77 /// `on_overflow:` on this artifact's entry in the capsule operator's own manifest — the
78 /// same operator-only sourcing rule `capabilities` above follows. Consumed for
79 /// `runtime: hook` and inert for every other role (the manifest parser rejects the key
80 /// there); inert too for a hook that turns out to be `execution_mode: blocking`, which is
81 /// never queued.
82 pub on_overflow: murmur_artifact::HookOverflowPolicy,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct LockExpectation {
87 pub name: String,
88 pub resolved_version: String,
89 pub sha256_wasm: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct ResolvedLockArtifact {
94 pub name: String,
95 pub resolved_version: String,
96 pub sha256_wasm: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub struct CapabilityPolicy {
101 pub network_allow: Vec<String>,
102 /// From `capabilities.network.unix_sockets`. `false` by default, which makes the shell
103 /// subprocess tree's `socket(AF_UNIX, ...)` calls fail with `EACCES` at the kernel level on
104 /// both Linux tiers — `network_allow` above governs IP destinations only and would otherwise
105 /// leave a local daemon socket (`/var/run/docker.sock` — host root) completely unmediated.
106 /// `true` simply omits that seccomp rule; it is capsule-wide, not per-socket-path. See
107 /// `sandbox::denied_socket_domains`.
108 pub unix_sockets_allowed: bool,
109 pub filesystem_scope: Option<String>,
110 /// From `capabilities.filesystem.workdir_exec`. `false` by default, which withholds the
111 /// Landlock `Execute` right from the workdir's own `PathBeneath` rule — see
112 /// `sandbox::linux_enforce::workdir_access_rights`. With it withheld the kernel refuses to exec
113 /// anything under the workdir on the resolved path itself, so `shell_allow` is enforced
114 /// completely: a binary copied or compiled into the workdir cannot run under *any* name.
115 ///
116 /// `true` gives the right back for compile-and-run workflows and accepts, explicitly, that
117 /// `shell_allow` is then unenforceable for anything inside the workdir. That is why this field
118 /// is the one *grant* in this struct that also lowers the session's achieved containment class
119 /// (see `crate::containment::achieved_containment_class`) — nothing else here can.
120 pub workdir_exec_allowed: bool,
121 pub shell_allow: Vec<String>,
122 pub spawn_allow: Vec<String>,
123 pub shell_strip_env: Vec<String>,
124 pub shell_baseline_env: Vec<String>,
125 /// Typed interpreter-runtime grants from `capabilities.shell.interpreter_runtime`: each
126 /// names an already-allowlisted binary and the exact host directories outside the workdir
127 /// its import machinery needs, with a per-directory `list_dir` enumerability flag. Empty
128 /// unless the manifest declares them. Consumed on `KernelFull` to add narrow Landlock
129 /// grants (see `sandbox::resolve_interpreter_runtime_grants`) and to fire `W-SEC-009`.
130 pub shell_interpreter_runtime: Vec<murmur_artifact::InterpreterRuntimeGrant>,
131 /// Typed staged-runtime grants from `capabilities.shell.staged_runtime`: each names an
132 /// already-allowlisted binary, the absolute host path of a pinned runtime tree, and the `pin`
133 /// identifying that tree's build. Empty unless the manifest declares them.
134 ///
135 /// Consumed at staging to enforce that declaring one requires an effective `sealed` floor (see
136 /// `crate::staged_runtime::check_staged_runtime_floor`) and to render the `staged runtime`
137 /// section of `--explain-scope`. Per-binary mutually exclusive with
138 /// [`Self::shell_interpreter_runtime`], which the manifest parser already guarantees.
139 pub shell_staged_runtime: Vec<murmur_artifact::StagedRuntimeGrant>,
140 /// Host env var names a WASM guest may observe, from `capabilities.env.allow`. Empty by
141 /// default: a guest sees only the runtime's own `MURMUR_*` injection unless the manifest
142 /// names a variable here. `shell_strip_env` and the credential patterns still apply on
143 /// top, so a credential-shaped name declared here is stripped anyway.
144 pub env_allow: Vec<String>,
145 /// Execution limits applied to every guest store in the session. Always fully
146 /// resolved — `capability_policy_from_runtime_manifest` substitutes a default for
147 /// each field `capabilities.limits` omits, so there is no "unset" state here and a
148 /// silent manifest yields [`ExecutionLimits::default`] rather than no limits.
149 pub limits: ExecutionLimits,
150 /// OS-level bounds applied to every *native subprocess* the session spawns — rlimits, the
151 /// Linux cgroup v2 scope, and the workdir-size ceiling. Resolved on exactly the same terms
152 /// as `limits` above (each omitted `capabilities.resources` field replaced by its default,
153 /// no "unset" state), but a completely different subject: `limits` bounds a WASM guest
154 /// inside its wasmtime store, this bounds the processes the host forks. See
155 /// [`crate::resources`].
156 pub resources: crate::resources::HostResourceLimits,
157 /// The containment class this capsule asked for, from `capabilities.containment`.
158 ///
159 /// Unlike every other field here it is a *requirement*, not a grant — and unlike
160 /// `StageRequest::declared_containment_floor` it is only the manifest's own vote, before the
161 /// workspace config and `--containment` are folded in. `stage_session` overwrites it with the
162 /// combined floor as soon as that is known, so anything reading it off a `StagedSession` sees
163 /// the effective value; the manifest-only value exists so a `CapabilityPolicy` built straight
164 /// from a manifest is still self-consistent.
165 ///
166 /// Read by `sandbox::ShellEnforcement::resolve` to decide whether a sealed-capable host
167 /// actually installs a composed root for this session — a capsule that declared `scoped` must
168 /// keep getting `scoped`'s mechanism. See `sandbox::applied_tier`.
169 pub containment_floor: murmur_artifact::ContainmentClass,
170 /// `capabilities.limits.deadline_seconds` exactly as the manifest declared it — `None`
171 /// when it declared nothing. Retained undefaulted alongside the fully-resolved `limits`
172 /// above purely so hook calls can apply their own, lower default without mistaking an
173 /// explicit `600` for silence. See [`Self::hook_limits`].
174 pub declared_deadline_seconds: Option<u64>,
175}
176
177impl CapabilityPolicy {
178 /// Execution limits for hook lifecycle calls — [`Self::limits`] with the hook-specific
179 /// deadline default substituted when the manifest declared none.
180 ///
181 /// A hook call is one bounded piece of work, so it does not inherit the capsule-wide
182 /// ten-minute budget that exists for a capsule's whole `run`: a wedged hook would
183 /// otherwise stall the session for most of that, every event. An explicitly declared
184 /// `deadline_seconds` still wins, for hooks as for every other guest.
185 #[must_use]
186 pub fn hook_limits(&self) -> ExecutionLimits {
187 self.limits.for_hook_calls(self.declared_deadline_seconds)
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct InstalledArtifactSummary {
193 pub name: String,
194 pub version: String,
195 pub runtime: ArtifactRuntime,
196 pub implementation: Option<ArtifactImplementation>,
197}
198
199#[derive(Clone)]
200pub(crate) struct StagedHookArtifact {
201 pub name: String,
202 pub version: String,
203 pub component: Component,
204 /// Behavioral contract (binding/execution_mode/commit_policy) from the hook artifact's
205 /// *own* bundled manifest. Carries no capability information — see `grant`.
206 pub config: HookConfig,
207 /// What this hook is allowed to do, lowered and validated at staging time from the
208 /// operator's own manifest entry. Applied identically by all three hook instantiation
209 /// paths in `hooks.rs`; `HookCapabilityGrant::default()` is full default-deny.
210 pub grant: HookCapabilityGrant,
211 /// What to do when this hook's async job queue is full — from the operator's manifest
212 /// entry (via [`ArtifactRequest::on_overflow`]), on the same terms as `grant` and never
213 /// from `config`. Read only when `config.execution_mode` is `Async`; a blocking hook has
214 /// no queue to overflow.
215 pub on_overflow: murmur_artifact::HookOverflowPolicy,
216}
217
218#[derive(Debug, Clone)]
219pub struct StageRequest {
220 pub manifest_dir: PathBuf,
221 pub capsule_name: String,
222 pub capsule_version: String,
223 pub capsule_component_bytes: Vec<u8>,
224 pub artifacts: Vec<ArtifactRequest>,
225 pub allowlisted_tools: HashSet<String>,
226 pub lock_expectations: Option<Vec<LockExpectation>>,
227 pub capability_policy: CapabilityPolicy,
228 pub inference: Option<InferenceConfig>,
229 /// Whether `inference` above had its system-prompt declaration replaced by the caller
230 /// (`mur run --system-prompt`) rather than read from the manifest. Purely a provenance
231 /// signal for the trace: the override itself is already applied to `inference`, which by
232 /// this point is indistinguishable from a manifest that declared the same prompt inline.
233 /// `true` even when the override cleared the prompt to nothing.
234 pub system_prompt_overridden: bool,
235 pub context: Option<ContextConfig>,
236 /// OTLP/HTTP endpoint for span export; None = no external OTel emission.
237 pub otel_endpoint: Option<String>,
238 /// JSON-serialized EvalConfig injected into hook WASI env as MURMUR_EVAL_CONFIG.
239 pub eval_config_json: Option<String>,
240 /// Dataset case identifier injected as MURMUR_CASE_ID when running a dataset.
241 pub case_id: Option<String>,
242 /// Dataset identifier injected as MURMUR_DATASET_ID when running a dataset.
243 pub dataset_id: Option<String>,
244 /// Lifecycle config from the manifest (None = use LifecycleConfig::default()).
245 pub lifecycle: Option<LifecycleConfig>,
246 /// Optional override applied on top of lifecycle (e.g. from CLI flags or mur-roost).
247 pub lifecycle_override: Option<LifecycleOverride>,
248 /// Trace config from the manifest (None = defaults apply: input captured, output not).
249 pub trace: Option<murmur_artifact::TraceConfig>,
250 /// Optional user project directory to mount as the capsule's accessible workspace.
251 /// When set, session artifacts go into `<workdir>/.murmur/<session_id>/`.
252 /// When None, uses the default temp dir (existing behavior).
253 pub workdir: Option<std::path::PathBuf>,
254 /// Address to bind the HTTP server on. Defaults to "127.0.0.1" for local runs.
255 /// Use "0.0.0.0" when deploying to a VM so the capsule is reachable externally.
256 pub bind_addr: String,
257 /// Internal port from `network.internal_port` in the manifest. When `Some`, the runtime
258 /// binds strictly to this port and errors if it is already in use. When `None`, the OS
259 /// assigns a port.
260 pub internal_port: Option<u16>,
261 /// Minimum containment class this session must achieve, already combined across every
262 /// source that asked for one (manifest / workspace config / `--containment`) by taking the
263 /// strongest. Required rather than optional: "nobody declared anything" is
264 /// [`ContainmentClass::Advisory`], an explicit floor every host clears, not an absence.
265 /// `stage_session` refuses to stage when the host cannot meet it.
266 pub declared_containment_floor: murmur_artifact::ContainmentClass,
267}
268
269pub struct StagedSession {
270 pub session_id: String,
271 pub workdir: PathBuf,
272 /// User project directory (or session dir if no --workdir). WASM tools see this at ".".
273 pub accessible_workdir: std::path::PathBuf,
274 pub(crate) manifest_dir: PathBuf,
275 pub(crate) capsule_name: String,
276 pub(crate) capsule_version: String,
277 pub(crate) capsule_url: String,
278 pub resolved_lock_artifacts: Vec<ResolvedLockArtifact>,
279 pub(crate) installed_artifacts: Vec<InstalledArtifactSummary>,
280 pub(crate) inference: Option<InferenceConfig>,
281 /// Copied from [`StageRequest::system_prompt_overridden`] — the only record left that the
282 /// prompt in `inference` came from `--system-prompt` and not from the manifest. Passed to
283 /// `TraceWriter::open`, which turns it into `session_start.system_prompt_source`.
284 pub(crate) system_prompt_overridden: bool,
285 pub(crate) context: Option<ContextConfig>,
286 pub(crate) engine: Engine,
287 /// `None` for manifest-only agent capsules; `Some` for script capsules with a WASM component.
288 pub(crate) capsule_component: Option<Component>,
289 pub(crate) tool_components: HashMap<String, Component>,
290 /// Per-artifact narrowing for the tool/driver dispatch path, keyed by artifact name and
291 /// lowered at staging time from the operator's own manifest entries. Holds an entry only
292 /// for artifacts that actually declared a `capabilities:` block — a name absent from this
293 /// map (the overwhelmingly common case) runs on the full capsule ceiling, exactly as
294 /// before per-artifact narrowing existed.
295 pub(crate) artifact_grants: HashMap<String, ToolCapabilityGrant>,
296 pub(crate) hook_components: Vec<StagedHookArtifact>,
297 pub(crate) allowlisted_tools: HashSet<String>,
298 pub(crate) capability_policy: CapabilityPolicy,
299 pub(crate) otel_endpoint: Option<String>,
300 pub(crate) eval_config_json: Option<String>,
301 pub(crate) case_id: Option<String>,
302 pub(crate) dataset_id: Option<String>,
303 /// Resolved lifecycle config (manifest + override already applied).
304 pub(crate) lifecycle: LifecycleConfig,
305 /// Whether to capture tool output in trace events (resolved from manifest trace config).
306 pub(crate) trace_include_tool_output: bool,
307 /// Address the HTTP server is bound to (copied from StageRequest::bind_addr).
308 pub(crate) bind_addr: String,
309 /// Internal port from the manifest (copied from StageRequest::internal_port).
310 pub(crate) internal_port: Option<u16>,
311 /// Floor this session was staged against (copied from
312 /// StageRequest::declared_containment_floor).
313 pub(crate) declared_containment_floor: murmur_artifact::ContainmentClass,
314 /// The complete effective grant set this session was staged with — the same report
315 /// `mur run --explain-scope --json` prints, built by `containment::scope_report_for_tier`
316 /// from this session's own policy, declared floor and single host probe. Written verbatim to
317 /// `trace.jsonl`'s `session_start` as `effective_grants`, and the source of the
318 /// `containment_declared`/`containment_achieved`/`workdir_exec` fields alongside it, so the
319 /// summary and the full report cannot disagree.
320 ///
321 /// Replaces a former separate `achieved_containment` field: what the host actually provided
322 /// is `scope_report.achieved_containment`, and keeping a second copy beside it only created a
323 /// way for the two to drift.
324 pub(crate) scope_report: crate::containment::ScopeReport,
325 /// Registry used to resolve this session's artifacts, retained so `manage.pull()` can
326 /// resolve additional artifacts at runtime after staging has completed.
327 pub(crate) registry: Arc<dyn Registry>,
328 /// Keeps this session's epoch ticker running: without it no epoch deadline set on any
329 /// store built from `engine` can ever fire. Held here (rather than detached) so ticking
330 /// stops when the session's `StagedSession` drops. `launch_session` moves the other
331 /// fields out one by one, which leaves this one in place until the session returns.
332 pub(crate) _epoch_ticker: EpochTicker,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct LaunchResult {
337 pub session_id: String,
338 pub workdir: PathBuf,
339}
340
341pub fn capability_policy_from_runtime_manifest(
342 manifest: &murmur_artifact::RuntimeManifest,
343) -> CapabilityPolicy {
344 let caps = manifest.capabilities.as_ref();
345 let network_allow = caps
346 .and_then(|c| c.network.as_ref())
347 .map(|network| network.allow.clone())
348 .unwrap_or_default();
349 // Absent `capabilities.network` block, or absent key within it, both mean denied.
350 let unix_sockets_allowed = caps
351 .and_then(|c| c.network.as_ref())
352 .is_some_and(|network| network.unix_sockets);
353
354 let filesystem_scope = caps
355 .and_then(|c| c.filesystem.as_ref())
356 .and_then(|filesystem| filesystem.scope.clone());
357 // Absent `capabilities.filesystem` block, or absent key within it, both mean denied — the same
358 // shape as `unix_sockets_allowed` above, for the same reason: the widening has to be declared.
359 let workdir_exec_allowed = caps
360 .and_then(|c| c.filesystem.as_ref())
361 .is_some_and(|filesystem| filesystem.workdir_exec);
362
363 let shell_allow = caps
364 .and_then(|c| c.shell.as_ref())
365 .map(|shell| shell.allow.clone())
366 .unwrap_or_default();
367 let shell_strip_env = caps
368 .and_then(|c| c.shell.as_ref())
369 .and_then(|shell| shell.strip_env.clone())
370 .unwrap_or_default();
371 let shell_baseline_env = caps
372 .and_then(|c| c.shell.as_ref())
373 .and_then(|shell| shell.baseline_env.clone())
374 .unwrap_or_default();
375 let shell_interpreter_runtime = caps
376 .and_then(|c| c.shell.as_ref())
377 .map(|shell| shell.interpreter_runtime.clone())
378 .unwrap_or_default();
379 let shell_staged_runtime = caps
380 .and_then(|c| c.shell.as_ref())
381 .map(|shell| shell.staged_runtime.clone())
382 .unwrap_or_default();
383
384 let spawn_allow = caps
385 .and_then(|c| c.spawn.as_ref())
386 .map(|spawn| spawn.allow.clone())
387 .unwrap_or_default();
388
389 let env_allow = caps
390 .and_then(|c| c.env.as_ref())
391 .map(|env| env.allow.clone())
392 .unwrap_or_default();
393
394 let limits = ExecutionLimits::resolve(caps.and_then(|c| c.limits.as_ref()));
395 let declared_deadline_seconds = caps
396 .and_then(|c| c.limits.as_ref())
397 .and_then(|l| l.deadline_seconds);
398
399 let resources = crate::resources::resolve(caps.and_then(|c| c.resources.as_ref()));
400
401 CapabilityPolicy {
402 network_allow,
403 unix_sockets_allowed,
404 filesystem_scope,
405 workdir_exec_allowed,
406 shell_allow,
407 spawn_allow,
408 shell_strip_env,
409 shell_baseline_env,
410 shell_interpreter_runtime,
411 shell_staged_runtime,
412 env_allow,
413 limits,
414 resources,
415 containment_floor: caps.and_then(|c| c.containment).unwrap_or_default(),
416 declared_deadline_seconds,
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn policy_for(manifest_yaml: &str) -> CapabilityPolicy {
425 let manifest = murmur_artifact::RuntimeManifest::from_yaml_str(manifest_yaml)
426 .expect("manifest fixture must parse");
427 capability_policy_from_runtime_manifest(&manifest)
428 }
429
430 /// A manifest that sets no deadline gives hook calls the hook-specific default while the
431 /// capsule's own guests keep the capsule-wide one — one manifest, two budgets.
432 #[test]
433 fn a_silent_manifest_gives_hook_calls_the_lower_deadline() {
434 let policy = policy_for(
435 r#"
436name: cap
437version: 0.1.0
438artifacts: []
439"#,
440 );
441
442 assert_eq!(
443 policy.limits.deadline_seconds,
444 crate::limits::DEFAULT_DEADLINE_SECONDS
445 );
446 assert_eq!(
447 policy.hook_limits().deadline_seconds,
448 crate::limits::HOOK_DEFAULT_DEADLINE_SECONDS
449 );
450 // The default policy a hand-built fixture gets must agree with the silent-manifest one.
451 assert_eq!(
452 CapabilityPolicy::default().hook_limits().deadline_seconds,
453 crate::limits::HOOK_DEFAULT_DEADLINE_SECONDS
454 );
455 }
456
457 /// An explicit `deadline_seconds` applies to every guest, hooks included — the hook
458 /// default only fills a silence.
459 #[test]
460 fn an_explicit_deadline_applies_to_hook_calls_too() {
461 let policy = policy_for(
462 r#"
463name: cap
464version: 0.1.0
465artifacts: []
466capabilities:
467 limits:
468 deadline_seconds: 120
469"#,
470 );
471
472 assert_eq!(policy.declared_deadline_seconds, Some(120));
473 assert_eq!(policy.limits.deadline_seconds, 120);
474 assert_eq!(policy.hook_limits().deadline_seconds, 120);
475 }
476
477 /// `capabilities.shell.staged_runtime` lowers onto the policy verbatim — binary, source path
478 /// and pin all survive, since the pin is what a human later compares across two hosts.
479 #[test]
480 fn staged_runtime_grants_lower_onto_the_policy() {
481 let policy = policy_for(
482 r#"
483name: cap
484version: 0.1.0
485artifacts: []
486capabilities:
487 containment: sealed
488 shell:
489 allow:
490 - python3
491 staged_runtime:
492 - binary: python3
493 source_path: /opt/testbed/conda/envs/django__django
494 pin: conda-4.10.3/python-3.9.19
495"#,
496 );
497
498 assert_eq!(
499 policy.shell_staged_runtime,
500 vec![murmur_artifact::StagedRuntimeGrant {
501 binary: "python3".to_string(),
502 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
503 pin: "conda-4.10.3/python-3.9.19".to_string(),
504 }]
505 );
506 // The two mechanisms stay separate all the way down: lowering one must not populate the
507 // other.
508 assert!(policy.shell_interpreter_runtime.is_empty());
509 }
510
511 /// A shell block that declares no staged runtime lowers to an empty list, not to a default
512 /// grant of any kind.
513 #[test]
514 fn absent_staged_runtime_lowers_to_empty() {
515 let policy = policy_for(
516 r#"
517name: cap
518version: 0.1.0
519artifacts: []
520capabilities:
521 shell:
522 allow:
523 - bash
524"#,
525 );
526
527 assert!(policy.shell_staged_runtime.is_empty());
528 }
529
530 /// The default every existing manifest gets. Three separate "nothing was declared" shapes —
531 /// no `capabilities:` at all, no `filesystem:` within it, and a `filesystem:` that only sets
532 /// `scope` — must all lower to denied, because each one is a real manifest in the wild and any
533 /// of them silently keeping workdir `Execute` would reopen the rename-to-an-allowed-basename
534 /// bypass this default exists to close.
535 #[test]
536 fn workdir_exec_denied_for_every_shape_of_undeclared() {
537 for manifest_yaml in [
538 r#"
539name: cap
540version: 0.1.0
541artifacts: []
542"#,
543 r#"
544name: cap
545version: 0.1.0
546artifacts: []
547capabilities:
548 network:
549 allow:
550 - https://api.anthropic.com
551"#,
552 r#"
553name: cap
554version: 0.1.0
555artifacts: []
556capabilities:
557 filesystem:
558 scope: workdir
559"#,
560 ] {
561 let policy = policy_for(manifest_yaml);
562 assert!(
563 !policy.workdir_exec_allowed,
564 "an undeclared workdir_exec must lower to denied: {manifest_yaml}"
565 );
566 }
567 }
568
569 #[test]
570 fn workdir_exec_denied_when_declared_false() {
571 let policy = policy_for(
572 r#"
573name: cap
574version: 0.1.0
575artifacts: []
576capabilities:
577 filesystem:
578 workdir_exec: false
579"#,
580 );
581
582 assert!(!policy.workdir_exec_allowed);
583 }
584
585 #[test]
586 fn workdir_exec_allowed_when_declared_true() {
587 let policy = policy_for(
588 r#"
589name: cap
590version: 0.1.0
591artifacts: []
592capabilities:
593 filesystem:
594 scope: workdir
595 workdir_exec: true
596"#,
597 );
598
599 assert!(policy.workdir_exec_allowed);
600 // The two filesystem keys stay independent: lowering one must not disturb the other.
601 assert_eq!(policy.filesystem_scope.as_deref(), Some("workdir"));
602 }
603
604 /// A manifest with no `capabilities:` block at all denies unix sockets — the field must not
605 /// pick up any implicit widening from the "nothing declared" path.
606 #[test]
607 fn unix_sockets_denied_when_capabilities_block_is_absent() {
608 let policy = policy_for(
609 r#"
610name: cap
611version: 0.1.0
612artifacts: []
613"#,
614 );
615
616 assert!(!policy.unix_sockets_allowed);
617 }
618
619 /// A declared `network:` block that never mentions the key still denies.
620 #[test]
621 fn unix_sockets_denied_when_network_block_omits_the_key() {
622 let policy = policy_for(
623 r#"
624name: cap
625version: 0.1.0
626artifacts: []
627capabilities:
628 network:
629 allow:
630 - https://api.anthropic.com
631"#,
632 );
633
634 assert!(!policy.unix_sockets_allowed);
635 assert_eq!(
636 policy.network_allow,
637 vec!["https://api.anthropic.com".to_string()]
638 );
639 }
640
641 #[test]
642 fn unix_sockets_denied_when_declared_false() {
643 let policy = policy_for(
644 r#"
645name: cap
646version: 0.1.0
647artifacts: []
648capabilities:
649 network:
650 unix_sockets: false
651"#,
652 );
653
654 assert!(!policy.unix_sockets_allowed);
655 }
656
657 #[test]
658 fn unix_sockets_allowed_when_declared_true() {
659 let policy = policy_for(
660 r#"
661name: cap
662version: 0.1.0
663artifacts: []
664capabilities:
665 network:
666 unix_sockets: true
667"#,
668 );
669
670 assert!(policy.unix_sockets_allowed);
671 }
672
673 /// The opt-in is independent of the IP allowlist in both directions: a non-empty `allow`
674 /// does not imply unix sockets, and `unix_sockets: true` does not imply any IP destination.
675 #[test]
676 fn unix_sockets_opt_in_is_independent_of_the_ip_allowlist() {
677 let policy = policy_for(
678 r#"
679name: cap
680version: 0.1.0
681artifacts: []
682capabilities:
683 network:
684 unix_sockets: true
685 allow:
686 - https://api.anthropic.com
687"#,
688 );
689
690 assert!(policy.unix_sockets_allowed);
691 assert_eq!(
692 policy.network_allow,
693 vec!["https://api.anthropic.com".to_string()]
694 );
695 }
696
697 /// `CapabilityPolicy::default()` is what the sandbox tests and every `..Default::default()`
698 /// call site start from; it must deny, not inherit whatever a future field ordering implies.
699 #[test]
700 fn default_capability_policy_denies_unix_sockets() {
701 assert!(!CapabilityPolicy::default().unix_sockets_allowed);
702 }
703}