Skip to main content

murmur_artifact/
runtime_manifest.rs

1use std::{fs, net::IpAddr, path::Path};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use url::Url;
6
7use crate::manifest_path::MANIFEST_FILENAME;
8
9// ── Lifecycle types ───────────────────────────────────────────────────────────
10
11#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum TaskAcceptance {
14    None,
15    #[default]
16    Single,
17    Queue,
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AfterTask {
23    #[default]
24    Exit,
25    Sleep,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ConversationMode {
31    #[default]
32    Stateless,
33    Threaded,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct LifecycleConfig {
38    pub task_acceptance: TaskAcceptance,
39    pub after_task: AfterTask,
40    pub queue_depth: usize,
41    /// If set, request-input calls that receive no follow-up within this many seconds
42    /// transition the task to "failed" with reason "input-timeout". Absent = wait indefinitely.
43    pub input_timeout_secs: Option<u64>,
44    /// Whether tasks sharing a contextId accumulate conversation history across turns.
45    /// Stateless (default): each task is fully independent; Threaded: history is loaded
46    /// and persisted per contextId within a session.
47    pub conversation_mode: ConversationMode,
48}
49
50impl Default for LifecycleConfig {
51    fn default() -> Self {
52        Self {
53            task_acceptance: TaskAcceptance::Single,
54            after_task: AfterTask::Exit,
55            queue_depth: 1,
56            input_timeout_secs: None,
57            conversation_mode: ConversationMode::Stateless,
58        }
59    }
60}
61
62/// Lifecycle override: allows a parent (mur-roost) or CLI flag to constrain
63/// the lifecycle down from what the manifest declares.
64#[derive(Debug, Clone, Default)]
65pub struct LifecycleOverride {
66    pub task_acceptance: Option<TaskAcceptance>,
67    pub after_task: Option<AfterTask>,
68}
69
70/// Artifact taxonomy for entries declared in a capsule manifest.
71///
72/// Variant meanings:
73///
74/// - `Tool`: agent-callable tool (WASM component or native binary). Visible in MURMUR.md and the
75///   LLM tool list. Implementation details (wasm vs native) are declared in the artifact's own
76///   murmur.yaml via `implementation:`.
77/// - `Driver`: inference driver implemented as a WASM component. The runtime calls it as a
78///   processor. Hidden from the LLM.
79/// - `Hook`: event-triggered artifact implemented as a WASM component. The runtime calls it at
80///   lifecycle points; behavioral contract (binding, execution_mode, commit_policy) is declared
81///   in the artifact's own murmur.yaml. Hidden from the LLM.
82/// - `Skill`: documentation artifact; no executable binary. Visible in the LLM tool inventory.
83///   Invoking a skill by name returns its `skill.md` content as the tool result (just-in-time
84///   injection). No WASM/native dispatch occurs. A skill bound via
85///   `inference.system_prompt_artifact` is excluded from the callable inventory because it is
86///   already part of the system prompt.
87#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
88#[serde(rename_all = "lowercase")]
89pub enum ArtifactRuntime {
90    Tool,
91    Driver,
92    Hook,
93    Skill,
94}
95
96impl ArtifactRuntime {
97    /// Whether artifacts of this role appear in the LLM's tool inventory.
98    ///
99    /// Deliberately an exhaustive match with one arm per variant and no wildcard: a new
100    /// `ArtifactRuntime` variant must state its own visibility here, so adding a role that
101    /// should be LLM-visible cannot silently default to hidden.
102    pub fn is_llm_visible(&self) -> bool {
103        match self {
104            ArtifactRuntime::Tool => true,
105            ArtifactRuntime::Skill => true,
106            ArtifactRuntime::Driver => false,
107            ArtifactRuntime::Hook => false,
108        }
109    }
110
111    pub fn as_str(&self) -> &'static str {
112        match self {
113            ArtifactRuntime::Tool => "tool",
114            ArtifactRuntime::Driver => "driver",
115            ArtifactRuntime::Hook => "hook",
116            ArtifactRuntime::Skill => "skill",
117        }
118    }
119}
120
121/// How a `runtime: tool` artifact is implemented.
122///
123/// Declared via `implementation:` in the artifact's own murmur.yaml.
124/// Defaults to `Wasm` when the field is absent.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum ArtifactImplementation {
127    Wasm,
128    Native,
129}
130
131/// Read the `implementation:` field from an artifact's murmur.yaml.
132/// Defaults to `Wasm` when the field is absent or unparseable.
133pub fn read_tool_implementation(
134    artifact_manifest_path: &Path,
135) -> Result<ArtifactImplementation, RuntimeManifestError> {
136    let content =
137        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
138            path: artifact_manifest_path.display().to_string(),
139            source,
140        })?;
141    Ok(parse_tool_implementation_from_yaml(&content))
142}
143
144/// Parse `implementation:` from an in-memory manifest YAML string.
145/// Defaults to `Wasm` when the field is absent or unparseable.
146pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
147    serde_yaml::from_str::<serde_yaml::Value>(yaml)
148        .map(|v| {
149            v.as_mapping()
150                .and_then(|m| m.get(serde_yaml::Value::String("implementation".to_string())))
151                .and_then(serde_yaml::Value::as_str)
152                .map(|s| match s {
153                    "native" => ArtifactImplementation::Native,
154                    _ => ArtifactImplementation::Wasm,
155                })
156                .unwrap_or(ArtifactImplementation::Wasm)
157        })
158        .unwrap_or(ArtifactImplementation::Wasm)
159}
160
161// ── Hook configuration types ──────────────────────────────────────────────────
162
163/// Which lifecycle event(s) a hook binds to.
164///
165/// Declared via `binding:` in the artifact's own murmur.yaml.
166/// Defaults to `All` when the field is absent (receives all session events).
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum HookBinding {
169    /// `"on-stage"` — fires during `stage_session`; always blocking.
170    OnStage,
171    /// `"on-session-start"` — fires once before the first inference turn.
172    OnSessionStart,
173    /// `"on-task-start"` — fires once per task, before that task's first inference turn.
174    OnTaskStart,
175    /// `"on-inference"` — fires after each inference response is parsed.
176    OnInference,
177    /// `"on-tool-call"` — fires after each model-requested tool invocation.
178    OnToolCall,
179    /// `"on-shell"` — fires after each shell command returns.
180    OnShell,
181    /// `"on-compaction"` — fires when the session token threshold is reached.
182    OnCompaction,
183    /// `"on-task-end"` — fires once per task, after that task's agent loop returns.
184    OnTaskEnd,
185    /// `"on-session-end"` — fires at session teardown.
186    OnSessionEnd,
187    /// No `binding:` field — receives all session events (not on-stage).
188    All,
189}
190
191impl HookBinding {
192    /// The manifest spelling of this binding, which is also the WIT lifecycle function
193    /// name the runtime dispatches to — the key `capsule-runtime`'s honored-arm table is
194    /// written against. [`HookBinding::All`] has no spelling of its own (it is what an
195    /// omitted `binding:` means), so it renders as `"all"` for diagnostics only.
196    #[must_use]
197    pub fn as_str(&self) -> &'static str {
198        match self {
199            Self::OnStage => "on-stage",
200            Self::OnSessionStart => "on-session-start",
201            Self::OnTaskStart => "on-task-start",
202            Self::OnInference => "on-inference",
203            Self::OnToolCall => "on-tool-call",
204            Self::OnShell => "on-shell",
205            Self::OnCompaction => "on-compaction",
206            Self::OnTaskEnd => "on-task-end",
207            Self::OnSessionEnd => "on-session-end",
208            Self::All => "all",
209        }
210    }
211}
212
213/// Whether the runtime waits for the hook result.
214///
215/// Declared via `execution_mode:` in the artifact's own murmur.yaml.
216/// Defaults to `Blocking` when the field is absent.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum HookExecutionMode {
219    /// `"blocking"` — runtime waits for result before proceeding.
220    Blocking,
221    /// `"async"` — runtime proceeds immediately; result not committed.
222    Async,
223}
224
225/// What the runtime does with a successful hook output.
226///
227/// Declared via `commit_policy:` in the artifact's own murmur.yaml.
228/// Defaults to `None` when the field is absent.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum HookCommitPolicy {
231    /// `"none"` — output discarded.
232    None,
233    /// `"replace-context"` — runtime replaces conversation history. Valid only with on-compaction.
234    ReplaceContext,
235    /// `"write-manifests"` — runtime writes tool manifests to workdir/tools/. Valid only with on-stage.
236    WriteManifests,
237    /// `"reopen-task"` — runtime re-runs the task's agent loop with the hook's feedback.
238    /// Valid only with on-task-end.
239    ReopenTask,
240}
241
242impl HookCommitPolicy {
243    /// The manifest spelling of this policy, for diagnostics.
244    #[must_use]
245    pub fn as_str(&self) -> &'static str {
246        match self {
247            Self::None => "none",
248            Self::ReplaceContext => "replace-context",
249            Self::WriteManifests => "write-manifests",
250            Self::ReopenTask => "reopen-task",
251        }
252    }
253}
254
255/// What the runtime does with a lifecycle event for an `execution_mode: async` hook
256/// whose job queue is already full.
257///
258/// Declared via `on_overflow:` on the hook's entry in the **capsule operator's own**
259/// manifest — not in the hook artifact's bundled murmur.yaml, because keeping up with
260/// the agent loop is the operator's trade-off, not the hook author's. Inert on a hook
261/// that turns out to be `execution_mode: blocking` (a blocking hook is never queued);
262/// the operator-manifest parser cannot tell the two apart, since the mode lives in the
263/// artifact's own manifest and is only known at staging time.
264#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
265#[serde(rename_all = "snake_case")]
266pub enum HookOverflowPolicy {
267    /// `"drop"` (the default) — discard the event and count it. The agent loop never
268    /// waits on a slow hook; telemetry is lossy under sustained overload, and the loss
269    /// is reported rather than silent.
270    #[default]
271    Drop,
272    /// `"block"` — the agent loop waits for the hook's worker to make room. No event is
273    /// lost, at the cost of putting a slow hook back on the critical path.
274    Block,
275}
276
277impl HookOverflowPolicy {
278    /// The manifest spelling of this policy, for diagnostics.
279    #[must_use]
280    pub fn as_str(&self) -> &'static str {
281        match self {
282            Self::Drop => "drop",
283            Self::Block => "block",
284        }
285    }
286}
287
288/// Behavioral contract for a hook artifact, read from its own murmur.yaml.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct HookConfig {
291    pub binding: HookBinding,
292    pub execution_mode: HookExecutionMode,
293    pub commit_policy: HookCommitPolicy,
294}
295
296impl Default for HookConfig {
297    fn default() -> Self {
298        Self {
299            binding: HookBinding::All,
300            execution_mode: HookExecutionMode::Blocking,
301            commit_policy: HookCommitPolicy::None,
302        }
303    }
304}
305
306/// The one `commit_policy` a hook bound to `binding` can actually have committed, or
307/// `None` when no `commit_policy` value other than `none` is ever honored for it.
308///
309/// This is the manifest-side mirror of `capsule-runtime`'s `HONORED_OUTPUT_ARM` table,
310/// which decides — keyed on the same lifecycle function names [`HookBinding::as_str`]
311/// produces — which `hook-output` arm the runtime commits for each event. Two bindings
312/// return `None` for different reasons, and the distinction matters:
313///
314/// - `on-session-start`, `on-task-start`, `on-tool-call`, `on-shell`, `on-session-end`
315///   honor no arm at all; every output from them is discarded.
316/// - `on-inference` *does* honor an arm (`artifact`), but that arm has no
317///   `commit_policy` spelling — [`HookCommitPolicy`] has no `Artifact` variant — so no
318///   non-`none` policy is declarable for it either.
319///
320/// [`HookBinding::All`] (an omitted `binding:`) also returns `None`, and that is a third
321/// reason again: an `All`-bound hook is dispatched to *every* event, so there is no single
322/// honored policy to name. It is deliberately **not** validated against this function —
323/// [`parse_hook_config_from_yaml`] accepts every `commit_policy` for it.
324#[must_use]
325pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
326    match binding {
327        HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
328        HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
329        HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
330        HookBinding::OnSessionStart
331        | HookBinding::OnTaskStart
332        | HookBinding::OnInference
333        | HookBinding::OnToolCall
334        | HookBinding::OnShell
335        | HookBinding::OnSessionEnd
336        | HookBinding::All => None,
337    }
338}
339
340/// Read a hook's behavioral contract from its murmur.yaml at the given path.
341pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
342    let content =
343        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
344            path: artifact_manifest_path.display().to_string(),
345            source,
346        })?;
347    parse_hook_config_from_yaml(&content).map_err(|msg| {
348        RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
349    })
350}
351
352/// Parse hook behavioral contract from an in-memory manifest YAML string.
353pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
354    let v: serde_yaml::Value =
355        serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
356
357    let binding = v
358        .as_mapping()
359        .and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
360        .and_then(serde_yaml::Value::as_str)
361        .map(|s| match s {
362            "on-stage" => Ok(HookBinding::OnStage),
363            "on-session-start" => Ok(HookBinding::OnSessionStart),
364            "on-task-start" => Ok(HookBinding::OnTaskStart),
365            "on-inference" => Ok(HookBinding::OnInference),
366            "on-tool-call" => Ok(HookBinding::OnToolCall),
367            "on-shell" => Ok(HookBinding::OnShell),
368            "on-compaction" => Ok(HookBinding::OnCompaction),
369            "on-task-end" => Ok(HookBinding::OnTaskEnd),
370            "on-session-end" => Ok(HookBinding::OnSessionEnd),
371            other => Err(format!(
372                "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
373                 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
374            )),
375        })
376        .transpose()?
377        .unwrap_or(HookBinding::All);
378
379    let execution_mode = v
380        .as_mapping()
381        .and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
382        .and_then(serde_yaml::Value::as_str)
383        .map(|s| match s {
384            "blocking" => Ok(HookExecutionMode::Blocking),
385            "async" => Ok(HookExecutionMode::Async),
386            other => Err(format!(
387                "unknown execution_mode '{other}'; expected: blocking, async"
388            )),
389        })
390        .transpose()?
391        .unwrap_or(HookExecutionMode::Blocking);
392
393    let commit_policy = v
394        .as_mapping()
395        .and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
396        .and_then(serde_yaml::Value::as_str)
397        .map(|s| match s {
398            "none" => Ok(HookCommitPolicy::None),
399            "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
400            "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
401            "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
402            other => Err(format!(
403                "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
404                 reopen-task"
405            )),
406        })
407        .transpose()?
408        .unwrap_or(HookCommitPolicy::None);
409
410    // Validation
411    if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
412        return Err(format!(
413            "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
414             (got '{}')",
415            commit_policy.as_str()
416        ));
417    }
418    if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
419        return Err(
420            "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
421             'on-stage'"
422                .to_string(),
423        );
424    }
425    // `binding` is the single source of truth for what the runtime commits; a declared
426    // `commit_policy` the binding can never honor is a mistake that is fully knowable here,
427    // at staging time, rather than mid-session as a `hook_dispatch_error`. An omitted
428    // `binding:` (`All`) reaches every event, so every policy stays valid for it.
429    if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
430        let honored = commit_policy_for_binding(&binding);
431        if honored.as_ref() != Some(&commit_policy) {
432            let note = if binding == HookBinding::OnInference {
433                " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
434            } else {
435                ""
436            };
437            return Err(format!(
438                "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
439                 commit_policy '{}'{note}",
440                commit_policy.as_str(),
441                binding.as_str(),
442                binding.as_str(),
443                honored.as_ref().map_or("none", HookCommitPolicy::as_str)
444            ));
445        }
446    }
447
448    Ok(HookConfig {
449        binding,
450        execution_mode,
451        commit_policy,
452    })
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct RuntimeManifest {
457    pub name: String,
458    pub version: String,
459    pub artifacts: Vec<RuntimeArtifact>,
460    pub capabilities: Option<Capabilities>,
461    pub inference: Option<InferenceConfig>,
462    pub context: Option<ContextConfig>,
463    pub observability: Option<ObservabilityConfig>,
464    pub trace: Option<TraceConfig>,
465    pub network: Option<NetworkConfig>,
466    pub lifecycle: Option<LifecycleConfig>,
467    /// Pins the mur runtime version required by this capsule.
468    /// Used by `mur deploy` to select the binary version to install on the VM,
469    /// and by `mur run` to warn on version mismatch.
470    /// If absent, the running mur binary's version is used.
471    pub mur_version: Option<String>,
472}
473
474impl RuntimeManifest {
475    pub fn effective_lifecycle(&self) -> LifecycleConfig {
476        self.lifecycle.clone().unwrap_or_default()
477    }
478}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct NetworkConfig {
482    /// Internal port the capsule expects to listen on. Default 14159 when absent.
483    pub internal_port: Option<u16>,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct RuntimeArtifact {
488    pub name: String,
489    pub version: String,
490    pub runtime: ArtifactRuntime,
491    /// Optional local source path. When set, the runtime resolves the artifact directly from
492    /// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
493    /// points at a `skill.md` file or at a directory containing one (case-insensitive).
494    /// Relative paths resolve against the directory containing `murmur.yaml`.
495    /// Only permitted when `local_source` is true.
496    pub source: Option<String>,
497    /// Whether this artifact may be resolved from a local `source:` path. Declared via
498    /// `local_source:` in the project manifest's artifact entry; when absent it defaults to
499    /// true for `runtime: skill` and false for every other role, which is exactly the role-based
500    /// gate this field replaced.
501    pub local_source: bool,
502    /// Whether this artifact's payload may be bound as the system prompt via
503    /// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
504    /// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
505    /// false for every other role.
506    pub prompt_payload: bool,
507    /// Per-artifact capability grant, declared via `capabilities:` on this entry in the
508    /// **capsule operator's own** manifest. Recognized on `runtime: hook`, `runtime: tool`,
509    /// and `runtime: driver` entries; a `runtime: skill` entry carrying the key is rejected
510    /// at parse time, because nothing would enforce it and a silently-ignored grant reads
511    /// like a scoped artifact.
512    ///
513    /// What `None` (the key absent) means depends on the role, deliberately:
514    /// - `hook`: full default-deny — no network and no preopened directory.
515    /// - `tool`/`driver`: the unchanged capsule-wide ceiling. A declared block *narrows*
516    ///   from that ceiling and can never widen past it.
517    ///
518    /// Deliberately never sourced from the artifact's own bundled `murmur.yaml` (see
519    /// [`parse_hook_config_from_yaml`], which parses that file and knows nothing about
520    /// capabilities) — an artifact pulled from a registry cannot self-grant.
521    ///
522    /// Reuses the whole [`Capabilities`] type for vocabulary consistency with the
523    /// capsule-wide block, but only `network` and `filesystem` are consumed by the runtime
524    /// per-artifact; the other sub-blocks are inert here.
525    pub capabilities: Option<Capabilities>,
526    /// What the runtime does when this hook's async job queue is full, declared via
527    /// `on_overflow:` on this entry. Defaults to [`HookOverflowPolicy::Drop`] when absent.
528    /// Accepted only on `runtime: hook` entries — every other role is rejected at parse
529    /// time, since nothing would ever consult it there.
530    pub on_overflow: HookOverflowPolicy,
531}
532
533#[derive(Debug, Clone, PartialEq, Eq)]
534pub struct InferenceDriver {
535    pub artifact: String,
536    pub config: Option<String>,
537}
538
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct InferenceConfig {
541    pub transport: String,
542    /// HTTP endpoint for the WASM driver. Present for `transport: http`, absent for `transport: process`.
543    pub endpoint: Option<String>,
544    pub model: String,
545    pub api_key: Option<String>,
546    /// WASM driver artifact. Present for `transport: http`, absent for `transport: process`.
547    pub driver: Option<InferenceDriver>,
548    /// CLI binary to spawn. Present for `transport: process`, absent for `transport: http`.
549    pub command: Option<String>,
550    pub compaction: Option<CompactionConfig>,
551    pub system_prompt: Option<String>,
552    pub system_prompt_file: Option<String>,
553    /// Name of a skill artifact declared in `artifacts:` whose `skill.md` is used as the system
554    /// prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
555    /// excluded from the callable tool inventory (it's already in the system prompt).
556    pub system_prompt_artifact: Option<String>,
557    /// Maximum LLM turns per capsule task. Defaults to 10 when absent in the manifest.
558    /// Enforced as a hard ceiling by the runtime.
559    pub max_turns: u32,
560    /// Maximum times an `on-task-end` hook may reopen a single task (re-run its agent
561    /// loop with injected feedback). Defaults to 1 when absent. `0` disables reopening
562    /// entirely. Unlike `max_turns`, `0` is a valid explicit value. Reopening never
563    /// grants turns past `max_turns` — the two budgets share one cumulative turn count.
564    pub max_task_reopens: u32,
565    /// Maximum output tokens the model may generate per turn (`max_tokens` in the driver wire
566    /// payload). None means the manifest didn't set it; the runtime applies its own default.
567    /// `transport: http` only — rejected at parse time under `transport: process`.
568    ///
569    /// Unrelated to [`ContextConfig::max_tokens`], which is the session-wide token budget that
570    /// drives compaction. This one is a per-turn *output* cap.
571    pub max_tokens: Option<u32>,
572}
573
574#[derive(Debug, Clone, PartialEq)]
575pub struct CompactionConfig {
576    /// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
577    pub threshold: Option<f32>,
578    /// Model for compaction. None means use the primary inference model.
579    pub model: Option<String>,
580    /// System prompt for compaction. None means the compaction hook picks its own default.
581    /// Mutually exclusive with `system_prompt_file`.
582    pub system_prompt: Option<String>,
583    /// Path to a local file whose contents are used as the compaction system prompt,
584    /// resolved relative to the manifest directory when the session launches. Mutually
585    /// exclusive with `system_prompt`.
586    pub system_prompt_file: Option<String>,
587    /// When true, every committed compaction appends one JSON line recording the
588    /// replacement summary to `out/compaction-summaries.jsonl` in the session workdir.
589    /// None (the key absent) is equivalent to false: nothing is written.
590    pub dump_summaries: Option<bool>,
591}
592
593// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
594impl Eq for CompactionConfig {}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct ContextConfig {
598    /// Token budget for this session; None disables compaction.
599    pub max_tokens: Option<u32>,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
603pub struct ObservabilityConfig {
604    pub otel_endpoint: Option<String>,
605    pub eval: Option<EvalConfig>,
606}
607
608#[derive(Debug, Clone, PartialEq, Eq)]
609pub struct TraceConfig {
610    /// When true, the raw tool output is captured in each tool_call trace event.
611    /// Defaults to false because tool output can be large (file diffs, shell dumps).
612    pub include_tool_output: bool,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
616pub struct EvalConfig {
617    #[serde(skip_serializing_if = "Option::is_none")]
618    pub dataset_id: Option<String>,
619    pub scorers: Vec<ScorerConfig>,
620}
621
622#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
623#[serde(tag = "type", rename_all = "snake_case")]
624pub enum ScorerConfig {
625    ExitOk { name: String },
626    MaxTurns { name: String, max: u32 },
627    MaxTokens { name: String, max: u64 },
628    ToolSequence { name: String, expected: Vec<String> },
629    LlmJudge { name: String },
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
633pub struct NetworkCapabilities {
634    pub allow: Vec<String>,
635    /// Whether the capsule's shell subprocess tree may create `AF_UNIX` sockets. `false` unless
636    /// the manifest says otherwise: a local daemon socket — `/var/run/docker.sock` above all —
637    /// is an unmediated path to host root, and the `allow` list above only governs IP
638    /// destinations, so nothing else in this block constrains it. Declaring `true` is the
639    /// deliberate, auditable widening for a capsule that genuinely needs a local daemon socket;
640    /// it is capsule-wide and coarse (a domain, not a per-path allowlist). `AF_NETLINK` and
641    /// `AF_PACKET` have no corresponding key and are always denied.
642    pub unix_sockets: bool,
643}
644
645#[derive(Debug, Clone, PartialEq, Eq)]
646pub struct FilesystemCapabilities {
647    pub scope: Option<String>,
648    /// Whether anything written *into* the session workdir may be executed. `false` unless the
649    /// manifest says otherwise, and that default is the whole point: with it, the workdir's own
650    /// Landlock `PathBeneath` rule withholds the `Execute` right, so the kernel — evaluating the
651    /// resolved path itself, with no userspace round trip — refuses to exec a binary the capsule
652    /// produced, whatever it is named. That is what makes `capabilities.shell.allow` a complete
653    /// and sound statement rather than a name-matching convention.
654    ///
655    /// `true` takes the `Execute` right back for compile-and-run workflows (a capsule that
656    /// `gcc`/`cargo build`s inside its workdir and then runs the result). The cost is not
657    /// negotiable and is not hidden: anything the capsule writes into the workdir can then run
658    /// regardless of `shell.allow`, so the allowlist stops being an enforceable property of that
659    /// capsule. A capsule declaring it can therefore never achieve
660    /// [`ContainmentClass::Scoped`] — see `capsule_runtime::containment` — and pairing it with
661    /// `capabilities.containment: scoped` is refused at launch.
662    pub workdir_exec: bool,
663}
664
665#[derive(Debug, Clone, PartialEq, Eq)]
666pub struct ShellCapabilities {
667    pub allow: Vec<String>,
668    pub strip_env: Option<Vec<String>>,
669    pub baseline_env: Option<Vec<String>>,
670    /// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
671    /// scope to the exact host directories its import machinery needs *outside* the workdir
672    /// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
673    /// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
674    /// This can only ever name specific directories with an explicit per-directory
675    /// enumerability flag — it has no field that expands a whole install prefix.
676    pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
677    /// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
678    /// into a `sealed` capsule's composed root. Empty unless the manifest declares
679    /// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
680    /// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
681    /// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
682    pub staged_runtime: Vec<StagedRuntimeGrant>,
683}
684
685/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
686/// exact host directories outside the workdir its import machinery must reach. Declaring one
687/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
688/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
689#[derive(Debug, Clone, PartialEq, Eq)]
690pub struct InterpreterRuntimeGrant {
691    /// A binary that MUST already appear in this same block's `allow`. This mechanism can only
692    /// narrow filesystem access alongside an exec grant that already exists — it never itself
693    /// grants exec.
694    pub binary: String,
695    /// The host directories to grant. Never empty (a grant with no directories is rejected at
696    /// parse time).
697    pub dirs: Vec<InterpreterRuntimeDir>,
698}
699
700/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
701#[derive(Debug, Clone, PartialEq, Eq)]
702pub struct InterpreterRuntimeDir {
703    /// An absolute host path (must start with `/`) outside the workdir.
704    pub path: String,
705    /// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
706    /// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
707    /// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
708    /// directory itself cannot be listed. Never inferred: the author must write it explicitly.
709    pub list_dir: bool,
710}
711
712/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
713/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
714/// identifying which build that tree is.
715///
716/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
717/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
718/// happens to keep it — which couples the capsule to one host's directory layout and is why it
719/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
720/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
721/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
722/// is no composed root to stage into below that — and it is refused alongside an
723/// `interpreter_runtime` grant for the same binary.
724///
725/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
726/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
727/// does not resolve, discover or version-sniff it.
728#[derive(Debug, Clone, PartialEq, Eq)]
729pub struct StagedRuntimeGrant {
730    /// A binary that MUST already appear in this same block's `allow`. Like
731    /// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
732    /// where the runtime behind an existing exec grant comes from.
733    pub binary: String,
734    /// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
735    /// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
736    /// turn recorded keep resolving.
737    pub source_path: String,
738    /// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
739    /// inferred from the tree's contents: it exists so a human can compare the declared pin across
740    /// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
741    /// an opaque string and does not parse it.
742    pub pin: String,
743}
744
745#[derive(Debug, Clone, PartialEq, Eq)]
746pub struct SpawnCapabilities {
747    pub allow: Vec<String>,
748}
749
750/// Host environment variables a WASM guest may observe, from `capabilities.env`.
751///
752/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
753/// `capabilities.shell.allow` this is not rejected at parse time.
754#[derive(Debug, Clone, PartialEq, Eq, Default)]
755pub struct EnvCapabilities {
756    pub allow: Vec<String>,
757}
758
759/// Per-guest execution limits from `capabilities.limits`.
760///
761/// Every field is `Option` because this type carries only what the manifest actually
762/// declared — an omitted field stays `None` here and the runtime substitutes its own
763/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
764/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
765/// instead of duplicating them into the manifest parser.
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
767pub struct ResourceLimits {
768    /// Cap on a guest's linear-memory growth, in bytes.
769    pub memory_bytes: Option<usize>,
770    /// Cap on a guest's table growth, in elements.
771    pub table_elements: Option<usize>,
772    /// Cap on the number of instances a single store may create.
773    pub instances: Option<usize>,
774    /// Wall-clock budget for a single guest invocation, in seconds.
775    pub deadline_seconds: Option<u64>,
776}
777
778/// Host-process (OS-level) resource bounds from `capabilities.resources`.
779///
780/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
781/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every *native
782/// subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
783/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
784/// that cannot escape containment can still wedge its host by forking, allocating, or writing
785/// without bound; this is the block that stops that.
786///
787/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
788/// what the manifest actually declared, and the runtime substitutes its own default for
789/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
790/// or an omitted block — means defaults, never "unlimited".
791#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
792pub struct ResourceCapabilities {
793    /// `RLIMIT_NPROC` hard ceiling on each spawned subprocess.
794    pub max_processes: Option<u64>,
795    /// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
796    pub max_open_files: Option<u64>,
797    /// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
798    pub max_file_size_bytes: Option<u64>,
799    /// `RLIMIT_CPU` hard ceiling, in CPU-seconds, on each spawned subprocess.
800    pub cpu_seconds: Option<u64>,
801    /// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
802    /// subprocess.
803    pub memory_bytes: Option<u64>,
804    /// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
805    pub cgroup_memory_bytes: Option<u64>,
806    /// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
807    pub cgroup_pids_max: Option<u64>,
808    /// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
809    pub cgroup_cpu_percent: Option<u32>,
810    /// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
811    /// best-effort (the backing device cannot always be resolved).
812    pub cgroup_io_bytes_per_sec: Option<u64>,
813    /// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
814    pub workdir_max_bytes: Option<u64>,
815}
816
817// ── Containment class ─────────────────────────────────────────────────────────
818
819/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
820/// by the operator and satisfied (or not) by the host's kernel.
821///
822/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
823/// must stay listed weakest-first. Combining floors from several sources is `max`, never
824/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
825///
826/// The class an operator *declares* is a requirement. The class a host actually *achieves*
827/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
828/// this declaration.
829#[derive(
830    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
831)]
832#[serde(rename_all = "snake_case")]
833pub enum ContainmentClass {
834    /// No kernel mechanism required. Capability declarations are honored by the runtime's
835    /// own dispatch layer and by convention; anything the host cannot mediate is warned
836    /// about, not refused. Every host satisfies this, including macOS. The default.
837    #[default]
838    Advisory,
839    /// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
840    /// filesystem: every path outside the workdir must be an explicit manifest grant.
841    /// Requires Linux 5.13+ with a usable Landlock ABI.
842    Scoped,
843    /// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
844    /// root, so the host filesystem is not merely mediated but absent. Achievable on an
845    /// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
846    /// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
847    /// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
848    /// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
849    /// See `docs/content/reference/sealed-containment-manual-verification.md`.
850    Sealed,
851}
852
853impl ContainmentClass {
854    /// The lowercase wire name, identical in the manifest, the workspace config, the
855    /// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
856    pub fn as_str(self) -> &'static str {
857        match self {
858            Self::Advisory => "advisory",
859            Self::Scoped => "scoped",
860            Self::Sealed => "sealed",
861        }
862    }
863
864    /// Every value, weakest first — the single source for the "must be one of: …" text.
865    pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
866}
867
868impl std::fmt::Display for ContainmentClass {
869    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
870        f.write_str(self.as_str())
871    }
872}
873
874/// A string that is not one of the three containment class names.
875#[derive(Debug, Clone, PartialEq, Eq)]
876pub struct ParseContainmentClassError {
877    pub value: String,
878}
879
880impl std::fmt::Display for ParseContainmentClassError {
881    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
882        write!(
883            f,
884            "must be one of: advisory, scoped, sealed; got '{}'",
885            self.value
886        )
887    }
888}
889
890impl std::error::Error for ParseContainmentClassError {}
891
892impl std::str::FromStr for ContainmentClass {
893    type Err = ParseContainmentClassError;
894
895    fn from_str(value: &str) -> Result<Self, Self::Err> {
896        match value.trim() {
897            "advisory" => Ok(Self::Advisory),
898            "scoped" => Ok(Self::Scoped),
899            "sealed" => Ok(Self::Sealed),
900            other => Err(ParseContainmentClassError {
901                value: other.to_string(),
902            }),
903        }
904    }
905}
906
907/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
908/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
909///
910/// Monotonic by construction — an absent source does not participate, and no present source
911/// can lower what another raised. Argument order carries no precedence.
912pub fn effective_containment_floor(
913    workspace_default: Option<ContainmentClass>,
914    manifest_declared: Option<ContainmentClass>,
915    cli_flag: Option<ContainmentClass>,
916) -> ContainmentClass {
917    [workspace_default, manifest_declared, cli_flag]
918        .into_iter()
919        .flatten()
920        .max()
921        .unwrap_or_default()
922}
923
924#[derive(Debug, Clone, PartialEq, Eq)]
925pub struct Capabilities {
926    pub network: Option<NetworkCapabilities>,
927    pub filesystem: Option<FilesystemCapabilities>,
928    pub shell: Option<ShellCapabilities>,
929    pub spawn: Option<SpawnCapabilities>,
930    pub env: Option<EnvCapabilities>,
931    pub limits: Option<ResourceLimits>,
932    pub resources: Option<ResourceCapabilities>,
933    /// Minimum containment class this capsule declares. `None` (the overwhelmingly common
934    /// case) means the capsule states no requirement and inherits whatever the workspace
935    /// config or `--containment` asks for, defaulting to `advisory`.
936    pub containment: Option<ContainmentClass>,
937}
938
939#[derive(Debug, Error)]
940pub enum RuntimeManifestError {
941    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
942    NotFound(String),
943    #[error("{0}")]
944    YamlSyntax(String),
945    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
946    MissingField { field: String },
947    #[error(
948        "{}: invalid artifact declaration at index {index}: {message}",
949        MANIFEST_FILENAME
950    )]
951    InvalidArtifact { index: usize, message: String },
952    #[error(
953        "{}: invalid inference config for '{field}': {message}",
954        MANIFEST_FILENAME
955    )]
956    InvalidInferenceConfig { field: String, message: String },
957    #[error(
958        "{}: invalid capability config for '{field}': {message}",
959        MANIFEST_FILENAME
960    )]
961    InvalidCapabilities { field: String, message: String },
962    #[error(
963        "{}: inference.api_key references {reference} but the environment variable is not set",
964        MANIFEST_FILENAME
965    )]
966    MissingInferenceEnvVar {
967        field: String,
968        reference: String,
969        variable: String,
970    },
971    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
972    Io {
973        path: String,
974        #[source]
975        source: std::io::Error,
976    },
977}
978
979#[derive(Debug, Deserialize)]
980struct RawRuntimeManifest {
981    name: Option<String>,
982    version: Option<String>,
983    #[serde(default)]
984    artifacts: Vec<RawArtifact>,
985    #[serde(default)]
986    capabilities: Option<RawCapabilities>,
987    #[serde(default)]
988    inference: Option<RawInferenceConfig>,
989    #[serde(default)]
990    context: Option<RawContextConfig>,
991    #[serde(default)]
992    observability: Option<RawObservabilityConfig>,
993    #[serde(default)]
994    trace: Option<RawTraceConfig>,
995    #[serde(default)]
996    network: Option<RawNetworkConfig>,
997    #[serde(default)]
998    lifecycle: Option<RawLifecycleConfig>,
999    #[serde(default)]
1000    mur_version: Option<String>,
1001}
1002
1003#[derive(Debug, Deserialize)]
1004struct RawLifecycleConfig {
1005    #[serde(default)]
1006    task_acceptance: Option<TaskAcceptance>,
1007    #[serde(default)]
1008    after_task: Option<AfterTask>,
1009    #[serde(default)]
1010    queue_depth: Option<usize>,
1011    #[serde(default)]
1012    input_timeout_secs: Option<u64>,
1013    #[serde(default)]
1014    conversation: Option<ConversationMode>,
1015}
1016
1017#[derive(Debug, Deserialize)]
1018struct RawNetworkConfig {
1019    internal_port: Option<u16>,
1020}
1021
1022#[derive(Debug, Deserialize)]
1023struct RawContextConfig {
1024    max_tokens: Option<u32>,
1025}
1026
1027#[derive(Debug, Deserialize)]
1028struct RawObservabilityConfig {
1029    #[serde(default)]
1030    otel_endpoint: Option<String>,
1031    #[serde(default)]
1032    eval: Option<RawEvalConfig>,
1033}
1034
1035#[derive(Debug, Deserialize)]
1036struct RawTraceConfig {
1037    #[serde(default)]
1038    include_tool_output: Option<bool>,
1039}
1040
1041#[derive(Debug, Deserialize)]
1042struct RawEvalConfig {
1043    #[serde(default)]
1044    dataset_id: Option<String>,
1045    #[serde(default)]
1046    scorers: Vec<RawScorerConfig>,
1047}
1048
1049#[derive(Debug, Deserialize)]
1050struct RawScorerConfig {
1051    #[serde(rename = "type")]
1052    scorer_type: String,
1053    name: Option<String>,
1054    max: Option<u64>,
1055    expected: Option<Vec<String>>,
1056}
1057
1058#[derive(Debug, Deserialize)]
1059struct RawArtifact {
1060    name: Option<String>,
1061    version: Option<String>,
1062    runtime: Option<String>,
1063    #[serde(default)]
1064    source: Option<String>,
1065    #[serde(default)]
1066    local_source: Option<bool>,
1067    #[serde(default)]
1068    prompt_payload: Option<bool>,
1069    #[serde(default)]
1070    capabilities: Option<RawCapabilities>,
1071    #[serde(default)]
1072    on_overflow: Option<String>,
1073}
1074
1075#[derive(Debug, Deserialize)]
1076struct RawCapabilities {
1077    #[serde(default)]
1078    network: Option<RawNetworkCapabilities>,
1079    #[serde(default)]
1080    filesystem: Option<RawFilesystemCapabilities>,
1081    #[serde(default)]
1082    shell: Option<RawShellCapabilities>,
1083    #[serde(default)]
1084    spawn: Option<RawSpawnCapabilities>,
1085    #[serde(default)]
1086    env: Option<RawEnvCapabilities>,
1087    #[serde(default)]
1088    limits: Option<RawResourceLimits>,
1089    #[serde(default)]
1090    resources: Option<RawResourceCapabilities>,
1091    /// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
1092    /// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
1093    /// "unknown variant" error attributed to the whole `capabilities:` block.
1094    #[serde(default)]
1095    containment: Option<String>,
1096}
1097
1098#[derive(Debug, Deserialize)]
1099struct RawSpawnCapabilities {
1100    #[serde(default)]
1101    allow: Vec<String>,
1102}
1103
1104#[derive(Debug, Deserialize)]
1105struct RawEnvCapabilities {
1106    #[serde(default)]
1107    allow: Vec<String>,
1108}
1109
1110#[derive(Debug, Deserialize)]
1111struct RawResourceLimits {
1112    #[serde(default)]
1113    memory_bytes: Option<usize>,
1114    #[serde(default)]
1115    table_elements: Option<usize>,
1116    #[serde(default)]
1117    instances: Option<usize>,
1118    #[serde(default)]
1119    deadline_seconds: Option<u64>,
1120}
1121
1122#[derive(Debug, Deserialize)]
1123struct RawResourceCapabilities {
1124    #[serde(default)]
1125    max_processes: Option<u64>,
1126    #[serde(default)]
1127    max_open_files: Option<u64>,
1128    #[serde(default)]
1129    max_file_size_bytes: Option<u64>,
1130    #[serde(default)]
1131    cpu_seconds: Option<u64>,
1132    #[serde(default)]
1133    memory_bytes: Option<u64>,
1134    #[serde(default)]
1135    cgroup_memory_bytes: Option<u64>,
1136    #[serde(default)]
1137    cgroup_pids_max: Option<u64>,
1138    #[serde(default)]
1139    cgroup_cpu_percent: Option<u32>,
1140    #[serde(default)]
1141    cgroup_io_bytes_per_sec: Option<u64>,
1142    #[serde(default)]
1143    workdir_max_bytes: Option<u64>,
1144}
1145
1146#[derive(Debug, Deserialize)]
1147struct RawNetworkCapabilities {
1148    #[serde(default)]
1149    allow: Vec<String>,
1150    #[serde(default)]
1151    unix_sockets: bool,
1152}
1153
1154#[derive(Debug, Deserialize)]
1155struct RawFilesystemCapabilities {
1156    #[serde(default)]
1157    scope: Option<String>,
1158    /// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
1159    /// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
1160    /// "The key is absent" and "the key says `false`" are the same declaration.
1161    #[serde(default)]
1162    workdir_exec: bool,
1163}
1164
1165#[derive(Debug, Deserialize)]
1166struct RawShellCapabilities {
1167    #[serde(default)]
1168    allow: Vec<String>,
1169    #[serde(default)]
1170    strip_env: Option<Vec<String>>,
1171    #[serde(default)]
1172    baseline_env: Option<Vec<String>>,
1173    #[serde(default)]
1174    interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
1175    #[serde(default)]
1176    staged_runtime: Vec<RawStagedRuntimeGrant>,
1177}
1178
1179#[derive(Debug, Deserialize)]
1180struct RawInterpreterRuntimeGrant {
1181    #[serde(default)]
1182    binary: Option<String>,
1183    #[serde(default)]
1184    dirs: Vec<RawInterpreterRuntimeDir>,
1185}
1186
1187#[derive(Debug, Deserialize)]
1188struct RawInterpreterRuntimeDir {
1189    #[serde(default)]
1190    path: Option<String>,
1191    // `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
1192    // omission is rejected outright rather than defaulted, because enumerability is never
1193    // inferred.
1194    #[serde(default)]
1195    list_dir: Option<bool>,
1196}
1197
1198#[derive(Debug, Deserialize)]
1199struct RawStagedRuntimeGrant {
1200    #[serde(default)]
1201    binary: Option<String>,
1202    #[serde(default)]
1203    source_path: Option<String>,
1204    // `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
1205    // field-naming message, rather than defaulting to an empty string that would silently mean
1206    // "unpinned" — the one thing this field exists to make impossible.
1207    #[serde(default)]
1208    pin: Option<String>,
1209}
1210
1211#[derive(Debug, Deserialize)]
1212struct RawInferenceConfig {
1213    transport: Option<String>,
1214    endpoint: Option<String>,
1215    model: Option<String>,
1216    #[serde(default)]
1217    api_key: Option<String>,
1218    #[serde(default)]
1219    command: Option<String>,
1220    #[serde(default)]
1221    system_prompt: Option<String>,
1222    #[serde(default)]
1223    system_prompt_file: Option<String>,
1224    #[serde(default)]
1225    system_prompt_artifact: Option<String>,
1226    #[serde(default)]
1227    driver: Option<RawInferenceDriver>,
1228    #[serde(default)]
1229    provider: Option<RawInferenceDriver>,
1230    #[serde(default)]
1231    compaction: Option<RawCompactionConfig>,
1232    #[serde(default)]
1233    max_turns: Option<u32>,
1234    #[serde(default)]
1235    max_task_reopens: Option<u32>,
1236    #[serde(default)]
1237    max_tokens: Option<u32>,
1238}
1239
1240#[derive(Debug, Deserialize)]
1241struct RawCompactionConfig {
1242    threshold: Option<f32>,
1243    model: Option<String>,
1244    system_prompt: Option<String>,
1245    system_prompt_file: Option<String>,
1246    dump_summaries: Option<bool>,
1247}
1248
1249#[derive(Debug, Deserialize)]
1250struct RawInferenceDriver {
1251    artifact: Option<String>,
1252    #[serde(default)]
1253    config: Option<serde_yaml::Value>,
1254}
1255
1256#[must_use = "validated runtime manifest is required before run"]
1257pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
1258    let content = fs::read_to_string(path).map_err(|source| {
1259        if source.kind() == std::io::ErrorKind::NotFound {
1260            return RuntimeManifestError::NotFound(path.display().to_string());
1261        }
1262
1263        RuntimeManifestError::Io {
1264            path: path.display().to_string(),
1265            source,
1266        }
1267    })?;
1268
1269    RuntimeManifest::from_yaml_str(&content)
1270}
1271
1272impl RuntimeManifest {
1273    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
1274        let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
1275            if let Some(location) = err.location() {
1276                RuntimeManifestError::YamlSyntax(format!(
1277                    "{}: YAML syntax error at line {}, column {}: {}",
1278                    MANIFEST_FILENAME,
1279                    location.line(),
1280                    location.column(),
1281                    err
1282                ))
1283            } else {
1284                RuntimeManifestError::YamlSyntax(format!(
1285                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
1286                ))
1287            }
1288        })?;
1289
1290        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
1291            RuntimeManifestError::MissingField {
1292                field: "name".to_string(),
1293            }
1294        })?;
1295
1296        let version = raw
1297            .version
1298            .filter(|s| !s.trim().is_empty())
1299            .ok_or_else(|| RuntimeManifestError::MissingField {
1300                field: "version".to_string(),
1301            })?;
1302
1303        let artifacts = raw
1304            .artifacts
1305            .into_iter()
1306            .enumerate()
1307            .map(|(index, artifact)| {
1308                let name = artifact
1309                    .name
1310                    .filter(|s| !s.trim().is_empty())
1311                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
1312                        index,
1313                        message: "missing required field 'name'".to_string(),
1314                    })?;
1315
1316                let source = artifact.source.filter(|s| !s.trim().is_empty());
1317
1318                let runtime = match artifact.runtime.as_deref() {
1319                    None | Some("tool") => ArtifactRuntime::Tool,
1320                    Some("wasm") | Some("native") => {
1321                        return Err(RuntimeManifestError::InvalidArtifact {
1322                            index,
1323                            message: format!(
1324                                "use 'runtime: tool'; implementation is declared in the \
1325                                 artifact's own manifest (got '{}')",
1326                                artifact.runtime.as_deref().unwrap_or("")
1327                            ),
1328                        });
1329                    }
1330                    Some("driver") => ArtifactRuntime::Driver,
1331                    Some("hook") => ArtifactRuntime::Hook,
1332                    Some("skill") => ArtifactRuntime::Skill,
1333                    Some(other) => {
1334                        return Err(RuntimeManifestError::InvalidArtifact {
1335                            index,
1336                            message: format!(
1337                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
1338                            ),
1339                        });
1340                    }
1341                };
1342
1343                // Capabilities are keyed on declared properties, not on the role. When the
1344                // property is absent we derive it from the role, which reproduces the previous
1345                // skill-only gate exactly and leaves every existing manifest unchanged.
1346                let local_source = artifact
1347                    .local_source
1348                    .unwrap_or(runtime == ArtifactRuntime::Skill);
1349                let prompt_payload = artifact
1350                    .prompt_payload
1351                    .unwrap_or(runtime == ArtifactRuntime::Skill);
1352
1353                // `source:` requires the artifact to declare `local_source: true`.
1354                if source.is_some() && !local_source {
1355                    return Err(RuntimeManifestError::InvalidArtifact {
1356                        index,
1357                        message: format!(
1358                            "artifact '{name}' declares 'source:' but does not declare \
1359                             'local_source: true' (runtime: {})",
1360                            runtime.as_str()
1361                        ),
1362                    });
1363                }
1364
1365                // When `source:` is present, `version:` is optional — substitute "local".
1366                // If both are set, `version:` is ignored and a warning is printed to stderr.
1367                let version = if source.is_some() {
1368                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
1369                        eprintln!(
1370                            "warning: artifact '{name}' declares both 'source:' and \
1371                             'version: {explicit}'; version is ignored for local-source skills \
1372                             (using 'local')"
1373                        );
1374                    }
1375                    "local".to_string()
1376                } else {
1377                    artifact
1378                        .version
1379                        .filter(|s| !s.trim().is_empty())
1380                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
1381                            index,
1382                            message: "missing required field 'version'".to_string(),
1383                        })?
1384                };
1385
1386                // Per-artifact `capabilities:` is enforced on every role that actually
1387                // executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
1388                // below the capsule ceiling). A `skill` has no execution surface, so the
1389                // block would be silently ignored there; that reads like a scoped artifact
1390                // when it is not, hence a parse error rather than a no-op.
1391                let capabilities = match artifact.capabilities {
1392                    None => None,
1393                    Some(raw_caps) => {
1394                        if runtime == ArtifactRuntime::Skill {
1395                            return Err(RuntimeManifestError::InvalidArtifact {
1396                                index,
1397                                message: format!(
1398                                    "artifact '{name}' declares per-artifact 'capabilities:' but \
1399                                     has 'runtime: {}'; per-artifact capabilities are only \
1400                                     recognized on 'runtime: hook', 'runtime: tool', and \
1401                                     'runtime: driver' entries — use the capsule-wide top-level \
1402                                     'capabilities:' block instead",
1403                                    runtime.as_str()
1404                                ),
1405                            });
1406                        }
1407                        parse_capabilities(Some(raw_caps))?
1408                    }
1409                };
1410
1411                // `on_overflow:` governs an async hook's job queue, which only exists for
1412                // `runtime: hook`. On any other role it would be silently ignored, so it is
1413                // a parse error for the same reason per-artifact `capabilities:` is on a
1414                // skill. It stays legal on a *blocking* hook entry (simply inert): the
1415                // execution mode lives in the artifact's own manifest and is unknown here.
1416                let on_overflow = match artifact.on_overflow.as_deref() {
1417                    None => HookOverflowPolicy::default(),
1418                    Some(raw) => {
1419                        if runtime != ArtifactRuntime::Hook {
1420                            return Err(RuntimeManifestError::InvalidArtifact {
1421                                index,
1422                                message: format!(
1423                                    "artifact '{name}' declares 'on_overflow:' but has \
1424                                     'runtime: {}'; the key governs an async hook's job queue \
1425                                     and is only recognized on 'runtime: hook' entries",
1426                                    runtime.as_str()
1427                                ),
1428                            });
1429                        }
1430                        match raw {
1431                            "drop" => HookOverflowPolicy::Drop,
1432                            "block" => HookOverflowPolicy::Block,
1433                            other => {
1434                                return Err(RuntimeManifestError::InvalidArtifact {
1435                                    index,
1436                                    message: format!(
1437                                        "artifact '{name}' declares unknown on_overflow \
1438                                         '{other}'; expected: drop, block"
1439                                    ),
1440                                });
1441                            }
1442                        }
1443                    }
1444                };
1445
1446                Ok(RuntimeArtifact {
1447                    name,
1448                    version,
1449                    runtime,
1450                    source,
1451                    local_source,
1452                    prompt_payload,
1453                    capabilities,
1454                    on_overflow,
1455                })
1456            })
1457            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
1458
1459        let capabilities = parse_capabilities(raw.capabilities)?;
1460        let inference = parse_inference(raw.inference)?;
1461
1462        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
1463        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
1464        if let Some(ref sp_art) = inference
1465            .as_ref()
1466            .and_then(|i| i.system_prompt_artifact.clone())
1467        {
1468            let matching = artifacts.iter().find(|a| &a.name == sp_art);
1469            match matching {
1470                None => {
1471                    return Err(RuntimeManifestError::InvalidInferenceConfig {
1472                        field: "inference.system_prompt_artifact".to_string(),
1473                        message: format!("artifact '{sp_art}' is not declared in artifacts:"),
1474                    });
1475                }
1476                Some(a) if !a.prompt_payload => {
1477                    return Err(RuntimeManifestError::InvalidInferenceConfig {
1478                        field: "inference.system_prompt_artifact".to_string(),
1479                        message: format!(
1480                            "artifact '{sp_art}' (runtime: {}) does not declare \
1481                             'prompt_payload: true', which system_prompt_artifact requires",
1482                            a.runtime.as_str()
1483                        ),
1484                    });
1485                }
1486                Some(_) => {} // valid
1487            }
1488        }
1489        let context = parse_context(raw.context)?;
1490        let observability = parse_observability(raw.observability);
1491        let trace = raw.trace.map(|raw_trace| TraceConfig {
1492            include_tool_output: raw_trace.include_tool_output.unwrap_or(false),
1493        });
1494        let network = raw.network.map(|n| NetworkConfig {
1495            internal_port: n.internal_port,
1496        });
1497        let lifecycle = raw.lifecycle.map(|raw_lc| {
1498            let defaults = LifecycleConfig::default();
1499            LifecycleConfig {
1500                task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
1501                after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
1502                queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
1503                input_timeout_secs: raw_lc.input_timeout_secs,
1504                conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
1505            }
1506        });
1507
1508        Ok(Self {
1509            name,
1510            version,
1511            artifacts,
1512            capabilities,
1513            inference,
1514            context,
1515            observability,
1516            trace,
1517            network,
1518            lifecycle,
1519            mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
1520        })
1521    }
1522}
1523
1524fn parse_capabilities(
1525    raw: Option<RawCapabilities>,
1526) -> Result<Option<Capabilities>, RuntimeManifestError> {
1527    let Some(raw_caps) = raw else {
1528        return Ok(None);
1529    };
1530
1531    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
1532        allow: raw_network.allow,
1533        unix_sockets: raw_network.unix_sockets,
1534    });
1535
1536    let filesystem = raw_caps
1537        .filesystem
1538        .map(|raw_filesystem| FilesystemCapabilities {
1539            scope: raw_filesystem.scope.and_then(|scope| {
1540                let trimmed = scope.trim();
1541                (!trimmed.is_empty()).then(|| trimmed.to_string())
1542            }),
1543            // Copied straight across: a bool has no empty/whitespace case to normalise, and there
1544            // is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
1545            // refusal when `capabilities.containment: scoped` is also declared) belongs to the
1546            // runtime's containment layer, not to manifest parsing.
1547            workdir_exec: raw_filesystem.workdir_exec,
1548        });
1549
1550    let shell = raw_caps
1551        .shell
1552        .map(|raw_shell| {
1553            if raw_shell.allow.is_empty() {
1554                return Err(RuntimeManifestError::InvalidCapabilities {
1555                    field: "capabilities.shell.allow".to_string(),
1556                    message: "must contain at least one binary".to_string(),
1557                });
1558            }
1559
1560            let interpreter_runtime =
1561                parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
1562            // Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
1563            // already-validated grant list to test against.
1564            let staged_runtime = parse_staged_runtime(
1565                &raw_shell.allow,
1566                &interpreter_runtime,
1567                raw_shell.staged_runtime,
1568            )?;
1569
1570            Ok(ShellCapabilities {
1571                allow: raw_shell.allow,
1572                strip_env: raw_shell.strip_env,
1573                baseline_env: raw_shell.baseline_env,
1574                interpreter_runtime,
1575                staged_runtime,
1576            })
1577        })
1578        .transpose()?;
1579
1580    let spawn = raw_caps.spawn.map(|raw_spawn| SpawnCapabilities {
1581        allow: raw_spawn.allow,
1582    });
1583
1584    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
1585        allow: raw_env.allow,
1586    });
1587
1588    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
1589
1590    let resources = raw_caps
1591        .resources
1592        .map(parse_resource_capabilities)
1593        .transpose()?;
1594
1595    let containment = raw_caps
1596        .containment
1597        .as_deref()
1598        .map(str::trim)
1599        .filter(|value| !value.is_empty())
1600        .map(|value| {
1601            value.parse::<ContainmentClass>().map_err(|error| {
1602                RuntimeManifestError::InvalidCapabilities {
1603                    field: "capabilities.containment".to_string(),
1604                    message: error.to_string(),
1605                }
1606            })
1607        })
1608        .transpose()?;
1609
1610    Ok(Some(Capabilities {
1611        network,
1612        filesystem,
1613        shell,
1614        spawn,
1615        env,
1616        limits,
1617        resources,
1618        containment,
1619    }))
1620}
1621
1622/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
1623/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
1624/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
1625/// expand a whole install prefix: each grant names specific directories, and each directory
1626/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
1627fn parse_interpreter_runtime(
1628    allow: &[String],
1629    raw_grants: Vec<RawInterpreterRuntimeGrant>,
1630) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
1631    let mut grants = Vec::with_capacity(raw_grants.len());
1632    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
1633        let base = format!("capabilities.shell.interpreter_runtime[{index}]");
1634
1635        let binary = raw_grant
1636            .binary
1637            .filter(|b| !b.trim().is_empty())
1638            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1639                field: format!("{base}.binary"),
1640                message: "must name a binary".to_string(),
1641            })?;
1642
1643        // The whole point of this mechanism: it narrows filesystem access alongside an exec
1644        // grant that already exists. It can never itself grant exec, so the binary must already
1645        // be allowlisted in this same block's `allow`.
1646        if !allow.contains(&binary) {
1647            return Err(RuntimeManifestError::InvalidCapabilities {
1648                field: format!("{base}.binary"),
1649                message: format!(
1650                    "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
1651                     only narrow filesystem access for an already-allowlisted binary, never \
1652                     grant exec"
1653                ),
1654            });
1655        }
1656
1657        if raw_grant.dirs.is_empty() {
1658            return Err(RuntimeManifestError::InvalidCapabilities {
1659                field: format!("{base}.dirs"),
1660                message: "must name at least one directory".to_string(),
1661            });
1662        }
1663
1664        let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
1665        for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
1666            let dir_base = format!("{base}.dirs[{dir_index}]");
1667
1668            let path = raw_dir
1669                .path
1670                .filter(|p| !p.trim().is_empty())
1671                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1672                    field: format!("{dir_base}.path"),
1673                    message: "must name a host directory".to_string(),
1674                })?;
1675            if !path.starts_with('/') {
1676                return Err(RuntimeManifestError::InvalidCapabilities {
1677                    field: format!("{dir_base}.path"),
1678                    message: format!(
1679                        "'{path}' must be an absolute host path (start with '/') — these are \
1680                         filesystem paths outside the workdir"
1681                    ),
1682                });
1683            }
1684
1685            let list_dir =
1686                raw_dir
1687                    .list_dir
1688                    .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1689                        field: format!("{dir_base}.list_dir"),
1690                        message: format!(
1691                            "'{path}' must set list_dir explicitly to true or false — \
1692                         enumerability is never inferred"
1693                        ),
1694                    })?;
1695
1696            dirs.push(InterpreterRuntimeDir { path, list_dir });
1697        }
1698
1699        grants.push(InterpreterRuntimeGrant { binary, dirs });
1700    }
1701
1702    Ok(grants)
1703}
1704
1705/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
1706/// and its already-validated `interpreter_runtime` grants. Every rejection is a
1707/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
1708/// [`parse_interpreter_runtime`] and the rest of this file.
1709///
1710/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
1711/// loudly wrong here:
1712///
1713///   1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
1714///   2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
1715///      and declaring both means the author expects a host-widening grant that a composed root
1716///      makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
1717///   3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
1718///      launch host and not relative to anything the runtime knows.
1719///   4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
1720///      so an absent one defeats the field's only purpose.
1721///
1722/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
1723/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
1724/// will never run it (`mur build` on a laptop for a Linux fleet).
1725fn parse_staged_runtime(
1726    allow: &[String],
1727    interpreter_runtime: &[InterpreterRuntimeGrant],
1728    raw_grants: Vec<RawStagedRuntimeGrant>,
1729) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
1730    let mut grants = Vec::with_capacity(raw_grants.len());
1731    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
1732        let base = format!("capabilities.shell.staged_runtime[{index}]");
1733
1734        let binary = raw_grant
1735            .binary
1736            .filter(|b| !b.trim().is_empty())
1737            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1738                field: format!("{base}.binary"),
1739                message: "must name a binary".to_string(),
1740            })?;
1741
1742        if !allow.contains(&binary) {
1743            return Err(RuntimeManifestError::InvalidCapabilities {
1744                field: format!("{base}.binary"),
1745                message: format!(
1746                    "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
1747                     stage the runtime tree behind an already-allowlisted binary, never grant exec"
1748                ),
1749            });
1750        }
1751
1752        if interpreter_runtime
1753            .iter()
1754            .any(|grant| grant.binary == binary)
1755        {
1756            return Err(RuntimeManifestError::InvalidCapabilities {
1757                field: format!("{base}.binary"),
1758                message: format!(
1759                    "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
1760                     are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
1761                     tree into the capsule's own root, which is what makes widening the capsule's \
1762                     host filesystem scope unnecessary rather than something to add to it. Remove \
1763                     the interpreter_runtime grant for '{binary}'"
1764                ),
1765            });
1766        }
1767
1768        let source_path = raw_grant
1769            .source_path
1770            .filter(|p| !p.trim().is_empty())
1771            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1772                field: format!("{base}.source_path"),
1773                message: "must name the host directory holding the pinned runtime tree".to_string(),
1774            })?;
1775        if !source_path.starts_with('/') {
1776            return Err(RuntimeManifestError::InvalidCapabilities {
1777                field: format!("{base}.source_path"),
1778                message: format!(
1779                    "'{source_path}' must be an absolute host path (start with '/') — \
1780                     staged-runtime sources are host filesystem paths outside the workdir"
1781                ),
1782            });
1783        }
1784
1785        let pin = raw_grant
1786            .pin
1787            .map(|p| p.trim().to_string())
1788            .filter(|p| !p.is_empty())
1789            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1790                field: format!("{base}.pin"),
1791                message: format!(
1792                    "'{source_path}' must declare a non-empty pin identifying which build this \
1793                     tree is — it is never inferred, because it is the value a human compares \
1794                     across two hosts to confirm the same runtime shipped to both"
1795                ),
1796            })?;
1797
1798        grants.push(StagedRuntimeGrant {
1799            binary,
1800            source_path,
1801            pin,
1802        });
1803    }
1804
1805    Ok(grants)
1806}
1807
1808/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
1809/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
1810/// catching it here means `mur run` fails at parse time with a field name rather than
1811/// surfacing much later as an opaque wasm trap.
1812fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
1813    let reject_zero_usize =
1814        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
1815            match value {
1816                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
1817                    field: format!("capabilities.limits.{field}"),
1818                    message: "must be greater than zero".to_string(),
1819                }),
1820                _ => Ok(()),
1821            }
1822        };
1823
1824    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
1825    reject_zero_usize(raw.table_elements, "table_elements")?;
1826    reject_zero_usize(raw.instances, "instances")?;
1827    if raw.deadline_seconds == Some(0) {
1828        return Err(RuntimeManifestError::InvalidCapabilities {
1829            field: "capabilities.limits.deadline_seconds".to_string(),
1830            message: "must be greater than zero".to_string(),
1831        });
1832    }
1833
1834    Ok(ResourceLimits {
1835        memory_bytes: raw.memory_bytes,
1836        table_elements: raw.table_elements,
1837        instances: raw.instances,
1838        deadline_seconds: raw.deadline_seconds,
1839    })
1840}
1841
1842/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
1843/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
1844/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
1845/// never what an author means, and letting it through would turn a typo into a subprocess that
1846/// cannot fork, open a file, or run for a single CPU-second.
1847fn parse_resource_capabilities(
1848    raw: RawResourceCapabilities,
1849) -> Result<ResourceCapabilities, RuntimeManifestError> {
1850    let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
1851        match value {
1852            Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
1853                field: format!("capabilities.resources.{field}"),
1854                message: "must be greater than zero".to_string(),
1855            }),
1856            _ => Ok(()),
1857        }
1858    };
1859
1860    reject_zero(raw.max_processes, "max_processes")?;
1861    reject_zero(raw.max_open_files, "max_open_files")?;
1862    reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
1863    reject_zero(raw.cpu_seconds, "cpu_seconds")?;
1864    reject_zero(raw.memory_bytes, "memory_bytes")?;
1865    reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
1866    reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
1867    reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
1868    reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
1869    reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
1870
1871    Ok(ResourceCapabilities {
1872        max_processes: raw.max_processes,
1873        max_open_files: raw.max_open_files,
1874        max_file_size_bytes: raw.max_file_size_bytes,
1875        cpu_seconds: raw.cpu_seconds,
1876        memory_bytes: raw.memory_bytes,
1877        cgroup_memory_bytes: raw.cgroup_memory_bytes,
1878        cgroup_pids_max: raw.cgroup_pids_max,
1879        cgroup_cpu_percent: raw.cgroup_cpu_percent,
1880        cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
1881        workdir_max_bytes: raw.workdir_max_bytes,
1882    })
1883}
1884
1885fn parse_inference(
1886    raw: Option<RawInferenceConfig>,
1887) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
1888    let Some(raw) = raw else {
1889        return Ok(None);
1890    };
1891
1892    let transport = raw
1893        .transport
1894        .map(|s| s.trim().to_string())
1895        .filter(|s| !s.is_empty())
1896        .unwrap_or_else(|| "http".to_string());
1897
1898    let compaction = parse_compaction(raw.compaction)?;
1899    let system_prompt = optional_trimmed_string(raw.system_prompt);
1900    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
1901    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
1902
1903    let prompt_sources_set = [
1904        system_prompt.is_some(),
1905        system_prompt_file.is_some(),
1906        system_prompt_artifact.is_some(),
1907    ]
1908    .iter()
1909    .filter(|&&b| b)
1910    .count();
1911    if prompt_sources_set > 1 {
1912        return Err(RuntimeManifestError::InvalidInferenceConfig {
1913            field: "inference.system_prompt".to_string(),
1914            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
1915                      may be set"
1916                .to_string(),
1917        });
1918    }
1919
1920    let max_turns = match raw.max_turns {
1921        None => 10,
1922        Some(0) => {
1923            return Err(RuntimeManifestError::InvalidInferenceConfig {
1924                field: "inference.max_turns".to_string(),
1925                message: "must be greater than 0".to_string(),
1926            });
1927        }
1928        Some(n) => n,
1929    };
1930
1931    // Unlike `max_turns`, `0` is a valid explicit value here: it disables reopening.
1932    // Absent defaults to 1 (one reopen permitted).
1933    let max_task_reopens = raw.max_task_reopens.unwrap_or(1);
1934
1935    match transport.as_str() {
1936        "http" => {
1937            let endpoint = required_inference_field(raw.endpoint, "endpoint")?;
1938            validate_inference_endpoint(&endpoint)?;
1939            let model = required_inference_field(raw.model, "model")?;
1940
1941            let api_key = match raw.api_key {
1942                None => None,
1943                Some(value) => {
1944                    let trimmed = value.trim();
1945                    if trimmed.is_empty() {
1946                        None
1947                    } else {
1948                        Some(resolve_inference_api_key(trimmed)?)
1949                    }
1950                }
1951            };
1952
1953            let driver_raw = raw.driver.or(raw.provider).ok_or_else(|| {
1954                RuntimeManifestError::InvalidInferenceConfig {
1955                    field: "inference.driver".to_string(),
1956                    message: "missing required field".to_string(),
1957                }
1958            })?;
1959            let driver = parse_inference_driver(driver_raw)?;
1960
1961            // Advisory only: catch an authoring typo at parse time rather than as a confusing
1962            // provider 400. Large values are deliberately NOT clamped — a ceiling here would
1963            // block a model whose real limit is higher than anything we could hard-code.
1964            if raw.max_tokens == Some(0) {
1965                return Err(RuntimeManifestError::InvalidInferenceConfig {
1966                    field: "inference.max_tokens".to_string(),
1967                    message: "must be greater than 0".to_string(),
1968                });
1969            }
1970
1971            Ok(Some(InferenceConfig {
1972                transport,
1973                endpoint: Some(endpoint),
1974                model,
1975                api_key,
1976                driver: Some(driver),
1977                command: None,
1978                compaction,
1979                system_prompt,
1980                system_prompt_file,
1981                system_prompt_artifact,
1982                max_turns,
1983                max_task_reopens,
1984                max_tokens: raw.max_tokens,
1985            }))
1986        }
1987        "process" => {
1988            // Fields that are invalid with transport: process
1989            if raw.driver.is_some() || raw.provider.is_some() {
1990                return Err(RuntimeManifestError::InvalidInferenceConfig {
1991                    field: "inference.driver.artifact".to_string(),
1992                    message: "is not valid with transport: process".to_string(),
1993                });
1994            }
1995            if raw
1996                .endpoint
1997                .as_ref()
1998                .map(|s| !s.trim().is_empty())
1999                .unwrap_or(false)
2000            {
2001                return Err(RuntimeManifestError::InvalidInferenceConfig {
2002                    field: "inference.endpoint".to_string(),
2003                    message: "is not valid with transport: process".to_string(),
2004                });
2005            }
2006            if raw
2007                .api_key
2008                .as_ref()
2009                .map(|s| !s.trim().is_empty())
2010                .unwrap_or(false)
2011            {
2012                return Err(RuntimeManifestError::InvalidInferenceConfig {
2013                    field: "inference.api_key".to_string(),
2014                    message: "is not valid with transport: process".to_string(),
2015                });
2016            }
2017            // The CLI subprocess path never builds a driver payload, so this value would be
2018            // silently inert here — reject it rather than let it look effective.
2019            if raw.max_tokens.is_some() {
2020                return Err(RuntimeManifestError::InvalidInferenceConfig {
2021                    field: "inference.max_tokens".to_string(),
2022                    message: "is not valid with transport: process".to_string(),
2023                });
2024            }
2025
2026            let command = required_inference_field(raw.command, "command")?;
2027            // model is OPTIONAL for transport: process — an empty string means "use the CLI's
2028            // configured/account-default model" (e.g. a codex subscription's default; passing an
2029            // unsupported model there is a hard 400). The Claude dialect still needs a real model
2030            // and will surface the CLI's own error if given none.
2031            let model = optional_trimmed_string(raw.model).unwrap_or_default();
2032
2033            Ok(Some(InferenceConfig {
2034                transport,
2035                endpoint: None,
2036                model,
2037                api_key: None,
2038                driver: None,
2039                command: Some(command),
2040                compaction,
2041                system_prompt,
2042                system_prompt_file,
2043                system_prompt_artifact,
2044                max_turns,
2045                max_task_reopens,
2046                max_tokens: None,
2047            }))
2048        }
2049        other => Err(RuntimeManifestError::InvalidInferenceConfig {
2050            field: "inference.transport".to_string(),
2051            message: format!("unknown value '{other}'"),
2052        }),
2053    }
2054}
2055
2056fn parse_context(
2057    raw: Option<RawContextConfig>,
2058) -> Result<Option<ContextConfig>, RuntimeManifestError> {
2059    let Some(raw) = raw else {
2060        return Ok(None);
2061    };
2062
2063    if let Some(max_tokens) = raw.max_tokens {
2064        if max_tokens == 0 {
2065            return Err(RuntimeManifestError::InvalidInferenceConfig {
2066                field: "context.max_tokens".to_string(),
2067                message: "must be greater than 0".to_string(),
2068            });
2069        }
2070    }
2071
2072    Ok(Some(ContextConfig {
2073        max_tokens: raw.max_tokens,
2074    }))
2075}
2076
2077fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
2078    raw.map(|raw| ObservabilityConfig {
2079        otel_endpoint: raw
2080            .otel_endpoint
2081            .map(|value| value.trim().to_string())
2082            .filter(|value| !value.is_empty()),
2083        eval: raw.eval.map(parse_eval_config),
2084    })
2085}
2086
2087fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
2088    let scorers = raw
2089        .scorers
2090        .into_iter()
2091        .filter_map(|raw| {
2092            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
2093            match raw.scorer_type.as_str() {
2094                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
2095                "max_turns" => Some(ScorerConfig::MaxTurns {
2096                    name,
2097                    max: raw.max.unwrap_or(10) as u32,
2098                }),
2099                "max_tokens" => Some(ScorerConfig::MaxTokens {
2100                    name,
2101                    max: raw.max.unwrap_or(100_000),
2102                }),
2103                "tool_sequence" => Some(ScorerConfig::ToolSequence {
2104                    name,
2105                    expected: raw.expected.unwrap_or_default(),
2106                }),
2107                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
2108                other => {
2109                    eprintln!("[murmur-artifact] unknown scorer type '{other}' — skipping");
2110                    None
2111                }
2112            }
2113        })
2114        .collect();
2115
2116    EvalConfig {
2117        dataset_id: raw.dataset_id,
2118        scorers,
2119    }
2120}
2121
2122fn parse_compaction(
2123    raw: Option<RawCompactionConfig>,
2124) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
2125    let Some(raw) = raw else {
2126        return Ok(None);
2127    };
2128
2129    if let Some(threshold) = raw.threshold {
2130        if threshold <= 0.0 || threshold > 1.0 {
2131            return Err(RuntimeManifestError::InvalidInferenceConfig {
2132                field: "inference.compaction.threshold".to_string(),
2133                message: format!("must be in (0.0, 1.0] but got {threshold}"),
2134            });
2135        }
2136    }
2137
2138    if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
2139        return Err(RuntimeManifestError::InvalidInferenceConfig {
2140            field: "inference.compaction.system_prompt".to_string(),
2141            message: "at most one of inference.compaction.system_prompt, \
2142                      inference.compaction.system_prompt_file may be set"
2143                .to_string(),
2144        });
2145    }
2146
2147    Ok(Some(CompactionConfig {
2148        threshold: raw.threshold,
2149        model: raw.model,
2150        system_prompt: raw.system_prompt,
2151        system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
2152        // A plain optional boolean: nothing to validate, and deliberately independent of the
2153        // threshold-range and prompt-source checks above.
2154        dump_summaries: raw.dump_summaries,
2155    }))
2156}
2157
2158fn optional_trimmed_string(value: Option<String>) -> Option<String> {
2159    value
2160        .map(|s| s.trim().to_string())
2161        .filter(|s| !s.is_empty())
2162}
2163
2164fn required_inference_field(
2165    value: Option<String>,
2166    key: &str,
2167) -> Result<String, RuntimeManifestError> {
2168    value
2169        .map(|value| value.trim().to_string())
2170        .filter(|value| !value.is_empty())
2171        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
2172            field: format!("inference.{key}"),
2173            message: "missing required field".to_string(),
2174        })
2175}
2176
2177/// Rejects `inference.endpoint` values that would let a manifest redirect the
2178/// inference trust root to an unencrypted, non-loopback host (murmur-security-assessment.md C-3).
2179///
2180/// Accepted: any `https://` URL, or `http://` URLs whose host is `localhost` or an
2181/// IP literal for which [`IpAddr::is_loopback`] returns true (e.g. `127.0.0.1`, `::1`).
2182/// Rejected: malformed URLs, unsupported schemes (anything but `http`/`https`), and
2183/// `http://` URLs whose host is not loopback.
2184fn validate_inference_endpoint(endpoint: &str) -> Result<(), RuntimeManifestError> {
2185    let url =
2186        Url::parse(endpoint).map_err(|source| RuntimeManifestError::InvalidInferenceConfig {
2187            field: "inference.endpoint".to_string(),
2188            message: format!("failed to parse '{endpoint}' as a URL: {source}"),
2189        })?;
2190
2191    match url.scheme() {
2192        "https" => Ok(()),
2193        "http" => {
2194            let host = url.host_str().unwrap_or("");
2195            let is_loopback = host == "localhost"
2196                || host
2197                    .parse::<IpAddr>()
2198                    .map(|ip| ip.is_loopback())
2199                    .unwrap_or(false);
2200
2201            if is_loopback {
2202                Ok(())
2203            } else {
2204                Err(RuntimeManifestError::InvalidInferenceConfig {
2205                    field: "inference.endpoint".to_string(),
2206                    message: format!(
2207                        "'{endpoint}' uses plain http:// with non-loopback host '{host}' — \
2208                         use https:// for remote endpoints, or http://localhost or \
2209                         a loopback IP literal for local inference"
2210                    ),
2211                })
2212            }
2213        }
2214        other => Err(RuntimeManifestError::InvalidInferenceConfig {
2215            field: "inference.endpoint".to_string(),
2216            message: format!(
2217                "unsupported scheme '{other}' in '{endpoint}' — expected http or https"
2218            ),
2219        }),
2220    }
2221}
2222
2223fn parse_inference_driver(
2224    raw: RawInferenceDriver,
2225) -> Result<InferenceDriver, RuntimeManifestError> {
2226    let artifact = raw
2227        .artifact
2228        .map(|value| value.trim().to_string())
2229        .filter(|value| !value.is_empty())
2230        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
2231            field: "inference.driver.artifact".to_string(),
2232            message: "missing required field".to_string(),
2233        })?;
2234
2235    let config = parse_inference_driver_config(raw.config)?;
2236
2237    Ok(InferenceDriver { artifact, config })
2238}
2239
2240fn parse_inference_driver_config(
2241    raw: Option<serde_yaml::Value>,
2242) -> Result<Option<String>, RuntimeManifestError> {
2243    let Some(raw) = raw else {
2244        return Ok(None);
2245    };
2246
2247    if matches!(raw, serde_yaml::Value::Null) {
2248        return Ok(None);
2249    }
2250
2251    if !matches!(raw, serde_yaml::Value::Mapping(_)) {
2252        return Err(RuntimeManifestError::InvalidInferenceConfig {
2253            field: "inference.driver.config".to_string(),
2254            message: "expected mapping/object".to_string(),
2255        });
2256    }
2257
2258    let value =
2259        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
2260            field: "inference.driver.config".to_string(),
2261            message: format!("failed to convert to JSON: {err}"),
2262        })?;
2263
2264    serde_json::to_string(&value).map(Some).map_err(|err| {
2265        RuntimeManifestError::InvalidInferenceConfig {
2266            field: "inference.driver.config".to_string(),
2267            message: format!("failed to encode JSON: {err}"),
2268        }
2269    })
2270}
2271
2272fn resolve_inference_api_key(value: &str) -> Result<String, RuntimeManifestError> {
2273    if let Some(variable) = parse_env_reference(value) {
2274        return std::env::var(variable).map_err(|_| RuntimeManifestError::MissingInferenceEnvVar {
2275            field: "inference.api_key".to_string(),
2276            reference: value.to_string(),
2277            variable: variable.to_string(),
2278        });
2279    }
2280
2281    Ok(value.to_string())
2282}
2283
2284fn parse_env_reference(value: &str) -> Option<&str> {
2285    if !value.starts_with("${") || !value.ends_with('}') {
2286        return None;
2287    }
2288
2289    let variable = &value[2..value.len() - 1];
2290    is_valid_env_variable(variable).then_some(variable)
2291}
2292
2293fn is_valid_env_variable(value: &str) -> bool {
2294    let mut chars = value.chars();
2295    let Some(first) = chars.next() else {
2296        return false;
2297    };
2298
2299    if !(first == '_' || first.is_ascii_uppercase()) {
2300        return false;
2301    }
2302
2303    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
2304}
2305
2306#[cfg(test)]
2307mod tests {
2308    use super::*;
2309
2310    #[test]
2311    fn resources_block_is_optional_and_absent_stays_absent() {
2312        let manifest = RuntimeManifest::from_yaml_str(
2313            r#"
2314name: cap
2315version: 0.0.1
2316capabilities:
2317  shell:
2318    allow: [bash]
2319"#,
2320        )
2321        .unwrap();
2322
2323        // `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
2324        // its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
2325        // keeping the two states distinct is what lets it own them in one place.
2326        let caps = manifest.capabilities.expect("shell block must parse");
2327        assert_eq!(caps.resources, None);
2328    }
2329
2330    #[test]
2331    fn resources_fields_parse_and_each_stays_independently_optional() {
2332        let manifest = RuntimeManifest::from_yaml_str(
2333            r#"
2334name: cap
2335version: 0.0.1
2336capabilities:
2337  resources:
2338    max_processes: 32
2339    max_open_files: 16
2340    max_file_size_bytes: 1048576
2341    cpu_seconds: 30
2342    memory_bytes: 268435456
2343    cgroup_memory_bytes: 536870912
2344    cgroup_pids_max: 64
2345    cgroup_cpu_percent: 50
2346    cgroup_io_bytes_per_sec: 1048576
2347    workdir_max_bytes: 2097152
2348"#,
2349        )
2350        .unwrap();
2351
2352        let resources = manifest
2353            .capabilities
2354            .and_then(|caps| caps.resources)
2355            .expect("resources block must parse");
2356        assert_eq!(resources.max_processes, Some(32));
2357        assert_eq!(resources.max_open_files, Some(16));
2358        assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
2359        assert_eq!(resources.cpu_seconds, Some(30));
2360        assert_eq!(resources.memory_bytes, Some(268_435_456));
2361        assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
2362        assert_eq!(resources.cgroup_pids_max, Some(64));
2363        assert_eq!(resources.cgroup_cpu_percent, Some(50));
2364        assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
2365        assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
2366    }
2367
2368    #[test]
2369    fn resources_partial_block_leaves_undeclared_fields_none() {
2370        let manifest = RuntimeManifest::from_yaml_str(
2371            r#"
2372name: cap
2373version: 0.0.1
2374capabilities:
2375  resources:
2376    max_open_files: 16
2377"#,
2378        )
2379        .unwrap();
2380
2381        let resources = manifest
2382            .capabilities
2383            .and_then(|caps| caps.resources)
2384            .expect("resources block must parse");
2385        assert_eq!(resources.max_open_files, Some(16));
2386        assert_eq!(resources.max_processes, None);
2387        assert_eq!(resources.workdir_max_bytes, None);
2388    }
2389
2390    /// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
2391    /// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
2392    /// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
2393    #[test]
2394    fn resources_zero_is_rejected_on_every_field() {
2395        for field in [
2396            "max_processes",
2397            "max_open_files",
2398            "max_file_size_bytes",
2399            "cpu_seconds",
2400            "memory_bytes",
2401            "cgroup_memory_bytes",
2402            "cgroup_pids_max",
2403            "cgroup_cpu_percent",
2404            "cgroup_io_bytes_per_sec",
2405            "workdir_max_bytes",
2406        ] {
2407            let yaml =
2408                format!("name: cap\nversion: 0.0.1\ncapabilities:\n  resources:\n    {field}: 0\n");
2409            let error =
2410                RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
2411
2412            match error {
2413                RuntimeManifestError::InvalidCapabilities {
2414                    field: reported,
2415                    message,
2416                } => {
2417                    assert_eq!(reported, format!("capabilities.resources.{field}"));
2418                    assert_eq!(message, "must be greater than zero");
2419                }
2420                other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
2421            }
2422        }
2423    }
2424
2425    #[test]
2426    fn artifact_runtime_defaults_to_tool() {
2427        let manifest = RuntimeManifest::from_yaml_str(
2428            r#"
2429name: cap
2430version: 0.0.1
2431artifacts:
2432  - name: echo
2433    version: 1.2.3
2434"#,
2435        )
2436        .unwrap();
2437
2438        assert_eq!(manifest.artifacts.len(), 1);
2439        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
2440    }
2441
2442    #[test]
2443    fn artifact_runtime_tool_explicit() {
2444        let manifest = RuntimeManifest::from_yaml_str(
2445            r#"
2446name: cap
2447version: 0.0.1
2448artifacts:
2449  - name: echo
2450    version: 1.2.3
2451    runtime: tool
2452"#,
2453        )
2454        .unwrap();
2455
2456        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
2457    }
2458
2459    #[test]
2460    fn artifact_runtime_wasm_is_error() {
2461        let err = RuntimeManifest::from_yaml_str(
2462            r#"
2463name: cap
2464version: 0.0.1
2465artifacts:
2466  - name: echo
2467    version: 1.2.3
2468    runtime: wasm
2469"#,
2470        )
2471        .unwrap_err();
2472
2473        let msg = err.to_string();
2474        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
2475        assert!(msg.contains("wasm"), "error was: {msg}");
2476    }
2477
2478    #[test]
2479    fn artifact_runtime_native_is_error() {
2480        let err = RuntimeManifest::from_yaml_str(
2481            r#"
2482name: cap
2483version: 0.0.1
2484artifacts:
2485  - name: echo
2486    version: 1.2.3
2487    runtime: native
2488"#,
2489        )
2490        .unwrap_err();
2491
2492        let msg = err.to_string();
2493        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
2494        assert!(msg.contains("native"), "error was: {msg}");
2495    }
2496
2497    #[test]
2498    fn invalid_artifact_reports_index() {
2499        let err = RuntimeManifest::from_yaml_str(
2500            r#"
2501name: cap
2502version: 0.0.1
2503artifacts:
2504  - name: ""
2505    version: 1.2.3
2506"#,
2507        )
2508        .unwrap_err();
2509
2510        let msg = err.to_string();
2511        assert!(msg.contains("index 0"));
2512        assert!(msg.contains("missing required field 'name'"));
2513    }
2514
2515    #[test]
2516    fn capabilities_parse_when_all_fields_present() {
2517        let manifest = RuntimeManifest::from_yaml_str(
2518            r#"
2519name: cap
2520version: 0.0.1
2521artifacts:
2522  - name: echo
2523    version: 1.2.3
2524capabilities:
2525  network:
2526    allow:
2527      - https://api.anthropic.com
2528      - http://127.0.0.1:8080
2529  filesystem:
2530    scope: ./sandbox
2531"#,
2532        )
2533        .unwrap();
2534
2535        let capabilities = manifest.capabilities.expect("capabilities should exist");
2536        assert_eq!(
2537            capabilities.network.unwrap().allow,
2538            vec![
2539                "https://api.anthropic.com".to_string(),
2540                "http://127.0.0.1:8080".to_string()
2541            ]
2542        );
2543        assert_eq!(
2544            capabilities.filesystem.unwrap().scope,
2545            Some("./sandbox".to_string())
2546        );
2547    }
2548
2549    #[test]
2550    fn env_capabilities_parse_allowlist() {
2551        let manifest = RuntimeManifest::from_yaml_str(
2552            r#"
2553name: cap
2554version: 0.0.1
2555artifacts:
2556  - name: echo
2557    version: 1.2.3
2558capabilities:
2559  env:
2560    allow:
2561      - MY_APP_REGION
2562      - GITHUB_TOKEN
2563"#,
2564        )
2565        .unwrap();
2566
2567        let capabilities = manifest.capabilities.expect("capabilities should exist");
2568        assert_eq!(
2569            capabilities.env.unwrap().allow,
2570            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
2571        );
2572    }
2573
2574    #[test]
2575    fn env_capabilities_absent_when_block_omitted() {
2576        let manifest = RuntimeManifest::from_yaml_str(
2577            r#"
2578name: cap
2579version: 0.0.1
2580artifacts:
2581  - name: echo
2582    version: 1.2.3
2583capabilities:
2584  network:
2585    allow:
2586      - https://api.anthropic.com
2587"#,
2588        )
2589        .unwrap();
2590
2591        assert!(manifest.capabilities.unwrap().env.is_none());
2592    }
2593
2594    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
2595    /// than a manifest error.
2596    #[test]
2597    fn env_capabilities_allow_empty_list_is_accepted() {
2598        let manifest = RuntimeManifest::from_yaml_str(
2599            r#"
2600name: cap
2601version: 0.0.1
2602artifacts:
2603  - name: echo
2604    version: 1.2.3
2605capabilities:
2606  env: {}
2607"#,
2608        )
2609        .unwrap();
2610
2611        assert_eq!(
2612            manifest.capabilities.unwrap().env,
2613            Some(EnvCapabilities { allow: vec![] })
2614        );
2615    }
2616
2617    #[test]
2618    fn missing_capabilities_block_is_backward_compatible() {
2619        let manifest = RuntimeManifest::from_yaml_str(
2620            r#"
2621name: cap
2622version: 0.0.1
2623artifacts:
2624  - name: echo
2625    version: 1.2.3
2626"#,
2627        )
2628        .unwrap();
2629
2630        assert!(manifest.capabilities.is_none());
2631    }
2632
2633    #[test]
2634    fn network_only_capabilities_parse() {
2635        let manifest = RuntimeManifest::from_yaml_str(
2636            r#"
2637name: cap
2638version: 0.0.1
2639artifacts:
2640  - name: echo
2641    version: 1.2.3
2642capabilities:
2643  network:
2644    allow:
2645      - https://allowed.example.com
2646"#,
2647        )
2648        .unwrap();
2649
2650        let capabilities = manifest.capabilities.unwrap();
2651        assert_eq!(
2652            capabilities.network.unwrap().allow,
2653            vec!["https://allowed.example.com".to_string()]
2654        );
2655        assert!(capabilities.filesystem.is_none());
2656    }
2657
2658    #[test]
2659    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
2660        let manifest = RuntimeManifest::from_yaml_str(
2661            r#"
2662name: cap
2663version: 0.0.1
2664artifacts:
2665  - name: echo
2666    version: 1.2.3
2667capabilities:
2668  network: {}
2669"#,
2670        )
2671        .unwrap();
2672
2673        let capabilities = manifest.capabilities.unwrap();
2674        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
2675    }
2676
2677    /// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
2678    /// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
2679    #[test]
2680    fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
2681        let manifest = RuntimeManifest::from_yaml_str(
2682            r#"
2683name: cap
2684version: 0.0.1
2685artifacts:
2686  - name: echo
2687    version: 1.2.3
2688capabilities:
2689  network:
2690    allow:
2691      - https://api.anthropic.com
2692"#,
2693        )
2694        .unwrap();
2695
2696        let network = manifest.capabilities.unwrap().network.unwrap();
2697        assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
2698        assert!(!network.unix_sockets);
2699    }
2700
2701    #[test]
2702    fn network_unix_sockets_parses_explicit_false() {
2703        let manifest = RuntimeManifest::from_yaml_str(
2704            r#"
2705name: cap
2706version: 0.0.1
2707artifacts:
2708  - name: echo
2709    version: 1.2.3
2710capabilities:
2711  network:
2712    unix_sockets: false
2713"#,
2714        )
2715        .unwrap();
2716
2717        assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
2718    }
2719
2720    #[test]
2721    fn network_unix_sockets_parses_explicit_true() {
2722        let manifest = RuntimeManifest::from_yaml_str(
2723            r#"
2724name: cap
2725version: 0.0.1
2726artifacts:
2727  - name: echo
2728    version: 1.2.3
2729capabilities:
2730  network:
2731    unix_sockets: true
2732"#,
2733        )
2734        .unwrap();
2735
2736        let network = manifest.capabilities.unwrap().network.unwrap();
2737        assert!(network.unix_sockets);
2738        // The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
2739        assert_eq!(network.allow, Vec::<String>::new());
2740    }
2741
2742    /// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
2743    /// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
2744    /// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
2745    #[test]
2746    fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
2747        let manifest = RuntimeManifest::from_yaml_str(
2748            r#"
2749name: cap
2750version: 0.0.1
2751artifacts:
2752  - name: echo
2753    version: 1.2.3
2754capabilities:
2755  filesystem:
2756    scope: workdir
2757"#,
2758        )
2759        .unwrap();
2760
2761        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
2762        assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
2763        assert!(!filesystem.workdir_exec);
2764    }
2765
2766    /// The other absent case: no `filesystem:` block at all. There is nothing to default here —
2767    /// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
2768    /// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
2769    /// covers with its own test.
2770    #[test]
2771    fn filesystem_block_stays_absent_when_undeclared() {
2772        let manifest = RuntimeManifest::from_yaml_str(
2773            r#"
2774name: cap
2775version: 0.0.1
2776artifacts:
2777  - name: echo
2778    version: 1.2.3
2779capabilities:
2780  network:
2781    allow:
2782      - https://api.anthropic.com
2783"#,
2784        )
2785        .unwrap();
2786
2787        assert!(manifest.capabilities.unwrap().filesystem.is_none());
2788    }
2789
2790    #[test]
2791    fn filesystem_workdir_exec_parses_explicit_false() {
2792        let manifest = RuntimeManifest::from_yaml_str(
2793            r#"
2794name: cap
2795version: 0.0.1
2796artifacts:
2797  - name: echo
2798    version: 1.2.3
2799capabilities:
2800  filesystem:
2801    workdir_exec: false
2802"#,
2803        )
2804        .unwrap();
2805
2806        assert!(
2807            !manifest
2808                .capabilities
2809                .unwrap()
2810                .filesystem
2811                .unwrap()
2812                .workdir_exec
2813        );
2814    }
2815
2816    #[test]
2817    fn filesystem_workdir_exec_parses_explicit_true() {
2818        let manifest = RuntimeManifest::from_yaml_str(
2819            r#"
2820name: cap
2821version: 0.0.1
2822artifacts:
2823  - name: echo
2824    version: 1.2.3
2825capabilities:
2826  filesystem:
2827    workdir_exec: true
2828"#,
2829        )
2830        .unwrap();
2831
2832        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
2833        assert!(filesystem.workdir_exec);
2834        // Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
2835        // declaring one says nothing about the other.
2836        assert_eq!(filesystem.scope, None);
2837    }
2838
2839    #[test]
2840    fn network_internal_port_parses() {
2841        let manifest = RuntimeManifest::from_yaml_str(
2842            r#"
2843name: worker
2844version: 0.1.0
2845artifacts: []
2846
2847network:
2848  internal_port: 58172
2849"#,
2850        )
2851        .unwrap();
2852
2853        assert_eq!(
2854            manifest.network.unwrap().internal_port,
2855            Some(58172),
2856            "network.internal_port should parse to Some(58172)"
2857        );
2858    }
2859
2860    #[test]
2861    fn network_internal_port_absent_when_omitted() {
2862        let manifest = RuntimeManifest::from_yaml_str(
2863            r#"
2864name: worker
2865version: 0.1.0
2866artifacts: []
2867"#,
2868        )
2869        .unwrap();
2870
2871        assert!(manifest.network.is_none());
2872    }
2873
2874    #[test]
2875    fn shell_capabilities_parse_with_allow_and_env_fields() {
2876        let manifest = RuntimeManifest::from_yaml_str(
2877            r#"
2878name: cap
2879version: 0.0.1
2880artifacts: []
2881capabilities:
2882  shell:
2883    allow:
2884      - bash
2885      - git
2886    strip_env:
2887      - "*_TOKEN"
2888      - SECRET_KEY
2889    baseline_env:
2890      - SSH_AUTH_SOCK
2891"#,
2892        )
2893        .unwrap();
2894
2895        let shell = manifest
2896            .capabilities
2897            .unwrap()
2898            .shell
2899            .expect("shell capabilities should be present");
2900        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
2901        assert_eq!(
2902            shell.strip_env,
2903            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
2904        );
2905        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
2906    }
2907
2908    #[test]
2909    fn shell_capabilities_require_non_empty_allowlist() {
2910        let err = RuntimeManifest::from_yaml_str(
2911            r#"
2912name: cap
2913version: 0.0.1
2914artifacts: []
2915capabilities:
2916  shell:
2917    allow: []
2918"#,
2919        )
2920        .unwrap_err();
2921
2922        let msg = err.to_string();
2923        assert!(msg.contains("capabilities.shell.allow"));
2924        assert!(msg.contains("at least one"));
2925    }
2926
2927    #[test]
2928    fn interpreter_runtime_parses_accepted_shape() {
2929        let manifest = RuntimeManifest::from_yaml_str(
2930            r#"
2931name: cap
2932version: 0.0.1
2933artifacts: []
2934capabilities:
2935  shell:
2936    allow:
2937      - python3
2938    interpreter_runtime:
2939      - binary: python3
2940        dirs:
2941          - path: /usr/lib/python3.11
2942            list_dir: true
2943          - path: /usr/lib/python3.11/lib-dynload
2944            list_dir: false
2945"#,
2946        )
2947        .unwrap();
2948
2949        let shell = manifest.capabilities.unwrap().shell.unwrap();
2950        assert_eq!(
2951            shell.interpreter_runtime,
2952            vec![InterpreterRuntimeGrant {
2953                binary: "python3".to_string(),
2954                dirs: vec![
2955                    InterpreterRuntimeDir {
2956                        path: "/usr/lib/python3.11".to_string(),
2957                        list_dir: true,
2958                    },
2959                    InterpreterRuntimeDir {
2960                        path: "/usr/lib/python3.11/lib-dynload".to_string(),
2961                        list_dir: false,
2962                    },
2963                ],
2964            }]
2965        );
2966    }
2967
2968    #[test]
2969    fn interpreter_runtime_defaults_to_empty_when_absent() {
2970        let manifest = RuntimeManifest::from_yaml_str(
2971            r#"
2972name: cap
2973version: 0.0.1
2974artifacts: []
2975capabilities:
2976  shell:
2977    allow:
2978      - python3
2979"#,
2980        )
2981        .unwrap();
2982
2983        let shell = manifest.capabilities.unwrap().shell.unwrap();
2984        assert!(shell.interpreter_runtime.is_empty());
2985    }
2986
2987    /// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
2988    /// (indented to sit under `interpreter_runtime:`), returning the error message.
2989    fn interpreter_runtime_reject(grant_yaml: &str) -> String {
2990        let yaml = format!(
2991            r#"
2992name: cap
2993version: 0.0.1
2994artifacts: []
2995capabilities:
2996  shell:
2997    allow:
2998      - python3
2999    interpreter_runtime:
3000{grant_yaml}
3001"#,
3002        );
3003        RuntimeManifest::from_yaml_str(&yaml)
3004            .expect_err("grant should be rejected")
3005            .to_string()
3006    }
3007
3008    #[test]
3009    fn interpreter_runtime_rejects_binary_not_in_allow() {
3010        let msg = interpreter_runtime_reject(
3011            "      - binary: ruby\n        dirs:\n          - path: /usr/lib/ruby\n            list_dir: true\n",
3012        );
3013        assert!(
3014            msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
3015            "{msg}"
3016        );
3017        assert!(msg.contains("ruby"), "{msg}");
3018        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
3019    }
3020
3021    #[test]
3022    fn interpreter_runtime_rejects_relative_path() {
3023        let msg = interpreter_runtime_reject(
3024            "      - binary: python3\n        dirs:\n          - path: usr/lib/python3.11\n            list_dir: true\n",
3025        );
3026        assert!(
3027            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
3028            "{msg}"
3029        );
3030        assert!(msg.contains("absolute"), "{msg}");
3031    }
3032
3033    #[test]
3034    fn interpreter_runtime_rejects_missing_list_dir() {
3035        let msg = interpreter_runtime_reject(
3036            "      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.11\n",
3037        );
3038        assert!(
3039            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
3040            "{msg}"
3041        );
3042        assert!(msg.contains("explicitly"), "{msg}");
3043    }
3044
3045    #[test]
3046    fn interpreter_runtime_rejects_empty_dirs() {
3047        let msg = interpreter_runtime_reject("      - binary: python3\n        dirs: []\n");
3048        assert!(
3049            msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
3050            "{msg}"
3051        );
3052        assert!(msg.contains("at least one"), "{msg}");
3053    }
3054
3055    #[test]
3056    fn staged_runtime_parses_accepted_shape() {
3057        let manifest = RuntimeManifest::from_yaml_str(
3058            r#"
3059name: cap
3060version: 0.0.1
3061artifacts: []
3062capabilities:
3063  containment: sealed
3064  shell:
3065    allow:
3066      - python3
3067      - bash
3068    staged_runtime:
3069      - binary: python3
3070        source_path: /opt/testbed/conda/envs/django__django
3071        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
3072"#,
3073        )
3074        .unwrap();
3075
3076        let shell = manifest.capabilities.unwrap().shell.unwrap();
3077        assert_eq!(
3078            shell.staged_runtime,
3079            vec![StagedRuntimeGrant {
3080                binary: "python3".to_string(),
3081                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
3082                pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
3083            }]
3084        );
3085        // The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
3086        // interpreter_runtime list untouched rather than populating it as a side effect.
3087        assert!(shell.interpreter_runtime.is_empty());
3088    }
3089
3090    #[test]
3091    fn staged_runtime_defaults_to_empty_when_absent() {
3092        let manifest = RuntimeManifest::from_yaml_str(
3093            r#"
3094name: cap
3095version: 0.0.1
3096artifacts: []
3097capabilities:
3098  shell:
3099    allow:
3100      - python3
3101"#,
3102        )
3103        .unwrap();
3104
3105        let shell = manifest.capabilities.unwrap().shell.unwrap();
3106        assert!(shell.staged_runtime.is_empty());
3107    }
3108
3109    /// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
3110    /// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
3111    /// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
3112    fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
3113        let yaml = format!(
3114            r#"
3115name: cap
3116version: 0.0.1
3117artifacts: []
3118capabilities:
3119  shell:
3120    allow:
3121      - python3
3122{extra}    staged_runtime:
3123{grant_yaml}
3124"#,
3125        );
3126        RuntimeManifest::from_yaml_str(&yaml)
3127            .expect_err("grant should be rejected")
3128            .to_string()
3129    }
3130
3131    #[test]
3132    fn staged_runtime_rejects_binary_not_in_allow() {
3133        let msg = staged_runtime_reject(
3134            "      - binary: ruby\n        source_path: /opt/ruby\n        pin: ruby-3.2.2\n",
3135            "",
3136        );
3137        assert!(
3138            msg.contains("capabilities.shell.staged_runtime[0].binary"),
3139            "{msg}"
3140        );
3141        assert!(msg.contains("ruby"), "{msg}");
3142        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
3143    }
3144
3145    #[test]
3146    fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
3147        let msg = staged_runtime_reject(
3148            "      - binary: python3\n        source_path: /opt/py\n        pin: cpython-3.9.19\n",
3149            "    interpreter_runtime:\n      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.9\n            list_dir: true\n",
3150        );
3151        assert!(
3152            msg.contains("capabilities.shell.staged_runtime[0].binary"),
3153            "{msg}"
3154        );
3155        assert!(msg.contains("python3"), "{msg}");
3156        assert!(msg.contains("interpreter_runtime"), "{msg}");
3157        assert!(msg.contains("mutually exclusive"), "{msg}");
3158    }
3159
3160    #[test]
3161    fn staged_runtime_rejects_relative_source_path() {
3162        let msg = staged_runtime_reject(
3163            "      - binary: python3\n        source_path: opt/testbed/conda\n        pin: cpython-3.9.19\n",
3164            "",
3165        );
3166        assert!(
3167            msg.contains("capabilities.shell.staged_runtime[0].source_path"),
3168            "{msg}"
3169        );
3170        assert!(msg.contains("absolute"), "{msg}");
3171    }
3172
3173    #[test]
3174    fn staged_runtime_rejects_missing_or_empty_pin() {
3175        // Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
3176        // precisely so that state is unrepresentable.
3177        for grant in [
3178            "      - binary: python3\n        source_path: /opt/testbed/conda\n",
3179            "      - binary: python3\n        source_path: /opt/testbed/conda\n        pin: \"   \"\n",
3180        ] {
3181            let msg = staged_runtime_reject(grant, "");
3182            assert!(
3183                msg.contains("capabilities.shell.staged_runtime[0].pin"),
3184                "{msg}"
3185            );
3186            assert!(msg.contains("never inferred"), "{msg}");
3187        }
3188    }
3189
3190    #[test]
3191    fn parses_inference_block() {
3192        let manifest = RuntimeManifest::from_yaml_str(
3193            r#"
3194name: cap
3195version: 0.0.1
3196artifacts: []
3197inference:
3198  transport: http
3199  endpoint: http://127.0.0.1:8080
3200  model: test-model
3201  api_key: literal-key
3202  driver:
3203    artifact: murmur-driver-anthropic
3204    config:
3205      temperature: 0.2
3206      headers:
3207        x-test: true
3208"#,
3209        )
3210        .unwrap();
3211
3212        let inference = manifest.inference.expect("inference should exist");
3213        assert_eq!(inference.transport, "http");
3214        assert_eq!(
3215            inference.endpoint,
3216            Some("http://127.0.0.1:8080".to_string())
3217        );
3218        assert_eq!(inference.model, "test-model");
3219        assert_eq!(inference.api_key, Some("literal-key".to_string()));
3220        let driver = inference.driver.as_ref().expect("driver should be present");
3221        assert_eq!(driver.artifact, "murmur-driver-anthropic");
3222        assert_eq!(
3223            driver.config,
3224            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
3225        );
3226        assert!(inference.system_prompt.is_none());
3227        assert!(inference.system_prompt_file.is_none());
3228    }
3229
3230    #[test]
3231    fn https_endpoint_accepted() {
3232        let manifest = RuntimeManifest::from_yaml_str(
3233            r#"
3234name: cap
3235version: 0.0.1
3236artifacts: []
3237inference:
3238  transport: http
3239  endpoint: https://api.anthropic.com
3240  model: test-model
3241  driver:
3242    artifact: murmur-driver-anthropic
3243"#,
3244        )
3245        .unwrap();
3246
3247        assert_eq!(
3248            manifest.inference.unwrap().endpoint,
3249            Some("https://api.anthropic.com".to_string())
3250        );
3251    }
3252
3253    #[test]
3254    fn http_localhost_endpoint_accepted() {
3255        let manifest = RuntimeManifest::from_yaml_str(
3256            r#"
3257name: cap
3258version: 0.0.1
3259artifacts: []
3260inference:
3261  transport: http
3262  endpoint: http://localhost:11434
3263  model: test-model
3264  driver:
3265    artifact: murmur-driver-anthropic
3266"#,
3267        )
3268        .unwrap();
3269
3270        assert_eq!(
3271            manifest.inference.unwrap().endpoint,
3272            Some("http://localhost:11434".to_string())
3273        );
3274    }
3275
3276    #[test]
3277    fn http_loopback_ip_endpoint_accepted() {
3278        let manifest = RuntimeManifest::from_yaml_str(
3279            r#"
3280name: cap
3281version: 0.0.1
3282artifacts: []
3283inference:
3284  transport: http
3285  endpoint: http://127.0.0.1:11434
3286  model: test-model
3287  driver:
3288    artifact: murmur-driver-anthropic
3289"#,
3290        )
3291        .unwrap();
3292
3293        assert_eq!(
3294            manifest.inference.unwrap().endpoint,
3295            Some("http://127.0.0.1:11434".to_string())
3296        );
3297    }
3298
3299    #[test]
3300    fn http_non_localhost_endpoint_rejected() {
3301        let err = RuntimeManifest::from_yaml_str(
3302            r#"
3303name: cap
3304version: 0.0.1
3305artifacts: []
3306inference:
3307  transport: http
3308  endpoint: http://api.attacker.example.com
3309  model: test-model
3310  driver:
3311    artifact: murmur-driver-anthropic
3312"#,
3313        )
3314        .unwrap_err();
3315
3316        let msg = err.to_string();
3317        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
3318        assert!(msg.contains("api.attacker.example.com"), "error was: {msg}");
3319    }
3320
3321    #[test]
3322    fn schemeless_endpoint_rejected() {
3323        let err = RuntimeManifest::from_yaml_str(
3324            r#"
3325name: cap
3326version: 0.0.1
3327artifacts: []
3328inference:
3329  transport: http
3330  endpoint: api.anthropic.com
3331  model: test-model
3332  driver:
3333    artifact: murmur-driver-anthropic
3334"#,
3335        )
3336        .unwrap_err();
3337
3338        let msg = err.to_string();
3339        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
3340        assert!(msg.contains("failed to parse"), "error was: {msg}");
3341    }
3342
3343    #[test]
3344    fn malformed_endpoint_rejected() {
3345        let err = RuntimeManifest::from_yaml_str(
3346            r#"
3347name: cap
3348version: 0.0.1
3349artifacts: []
3350inference:
3351  transport: http
3352  endpoint: "not a url"
3353  model: test-model
3354  driver:
3355    artifact: murmur-driver-anthropic
3356"#,
3357        )
3358        .unwrap_err();
3359
3360        let msg = err.to_string();
3361        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
3362        assert!(msg.contains("failed to parse"), "error was: {msg}");
3363    }
3364
3365    #[test]
3366    fn unsupported_scheme_endpoint_rejected() {
3367        let err = RuntimeManifest::from_yaml_str(
3368            r#"
3369name: cap
3370version: 0.0.1
3371artifacts: []
3372inference:
3373  transport: http
3374  endpoint: ftp://example.com
3375  model: test-model
3376  driver:
3377    artifact: murmur-driver-anthropic
3378"#,
3379        )
3380        .unwrap_err();
3381
3382        let msg = err.to_string();
3383        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
3384        assert!(msg.contains("unsupported scheme 'ftp'"), "error was: {msg}");
3385    }
3386
3387    #[test]
3388    fn parses_inline_system_prompt() {
3389        let manifest = RuntimeManifest::from_yaml_str(
3390            r#"
3391name: cap
3392version: 0.0.1
3393artifacts: []
3394inference:
3395  endpoint: http://127.0.0.1:8080
3396  model: test-model
3397  system_prompt: "Always begin with CONFIRMED:"
3398  driver:
3399    artifact: murmur-driver-anthropic
3400"#,
3401        )
3402        .unwrap();
3403
3404        let inference = manifest.inference.unwrap();
3405        assert_eq!(
3406            inference.system_prompt,
3407            Some("Always begin with CONFIRMED:".to_string())
3408        );
3409        assert!(inference.system_prompt_file.is_none());
3410    }
3411
3412    #[test]
3413    fn parses_compaction_system_prompt() {
3414        let manifest = RuntimeManifest::from_yaml_str(
3415            r#"
3416name: cap
3417version: 0.0.1
3418artifacts: []
3419inference:
3420  endpoint: http://127.0.0.1:8080
3421  model: test-model
3422  compaction:
3423    model: compaction-model
3424    system_prompt: "task = X, currently editing Y, already tried Z."
3425  driver:
3426    artifact: murmur-driver-anthropic
3427"#,
3428        )
3429        .unwrap();
3430
3431        let compaction = manifest.inference.unwrap().compaction.unwrap();
3432        assert_eq!(
3433            compaction.system_prompt,
3434            Some("task = X, currently editing Y, already tried Z.".to_string())
3435        );
3436        assert_eq!(compaction.model, Some("compaction-model".to_string()));
3437    }
3438
3439    /// The two override fields resolve independently: `model` set, `system_prompt`
3440    /// absent leaves the latter `None` with no default string substituted.
3441    #[test]
3442    fn compaction_system_prompt_absent_stays_none() {
3443        let manifest = RuntimeManifest::from_yaml_str(
3444            r#"
3445name: cap
3446version: 0.0.1
3447artifacts: []
3448inference:
3449  endpoint: http://127.0.0.1:8080
3450  model: test-model
3451  compaction:
3452    model: compaction-model
3453  driver:
3454    artifact: murmur-driver-anthropic
3455"#,
3456        )
3457        .unwrap();
3458
3459        let compaction = manifest.inference.unwrap().compaction.unwrap();
3460        assert!(compaction.system_prompt.is_none());
3461        assert!(compaction.system_prompt_file.is_none());
3462        assert_eq!(compaction.model, Some("compaction-model".to_string()));
3463    }
3464
3465    /// `system_prompt_file` parses as the second, independent prompt source: the path is
3466    /// carried through verbatim (resolution against the manifest dir happens in the
3467    /// runtime), the inline field stays `None`, and `model` is unaffected.
3468    #[test]
3469    fn parses_compaction_system_prompt_file() {
3470        let manifest = RuntimeManifest::from_yaml_str(
3471            r#"
3472name: cap
3473version: 0.0.1
3474artifacts: []
3475inference:
3476  endpoint: http://127.0.0.1:8080
3477  model: test-model
3478  compaction:
3479    model: compaction-model
3480    system_prompt_file: "compaction-instructions.md"
3481  driver:
3482    artifact: murmur-driver-anthropic
3483"#,
3484        )
3485        .unwrap();
3486
3487        let compaction = manifest.inference.unwrap().compaction.unwrap();
3488        assert_eq!(
3489            compaction.system_prompt_file,
3490            Some("compaction-instructions.md".to_string())
3491        );
3492        assert!(compaction.system_prompt.is_none());
3493        assert_eq!(compaction.model, Some("compaction-model".to_string()));
3494    }
3495
3496    /// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
3497    /// depend on each other in either direction.
3498    #[test]
3499    fn compaction_system_prompt_file_independent_of_model() {
3500        let manifest = RuntimeManifest::from_yaml_str(
3501            r#"
3502name: cap
3503version: 0.0.1
3504artifacts: []
3505inference:
3506  endpoint: http://127.0.0.1:8080
3507  model: test-model
3508  compaction:
3509    threshold: 0.5
3510    system_prompt_file: "compaction-instructions.md"
3511  driver:
3512    artifact: murmur-driver-anthropic
3513"#,
3514        )
3515        .unwrap();
3516
3517        let compaction = manifest.inference.unwrap().compaction.unwrap();
3518        assert_eq!(
3519            compaction.system_prompt_file,
3520            Some("compaction-instructions.md".to_string())
3521        );
3522        assert!(compaction.model.is_none());
3523        assert_eq!(compaction.threshold, Some(0.5));
3524    }
3525
3526    /// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
3527    /// are three distinguishable states, so the runtime can tell "explicitly off" from
3528    /// "never configured" even though both resolve to the same behavior.
3529    #[test]
3530    fn parses_compaction_dump_summaries() {
3531        let parse = |line: &str| {
3532            RuntimeManifest::from_yaml_str(&format!(
3533                r#"
3534name: cap
3535version: 0.0.1
3536artifacts: []
3537inference:
3538  endpoint: http://127.0.0.1:8080
3539  model: test-model
3540  compaction:
3541    model: compaction-model
3542{line}  driver:
3543    artifact: murmur-driver-anthropic
3544"#
3545            ))
3546            .unwrap()
3547            .inference
3548            .unwrap()
3549            .compaction
3550            .unwrap()
3551        };
3552
3553        assert_eq!(
3554            parse("    dump_summaries: true\n").dump_summaries,
3555            Some(true)
3556        );
3557        assert_eq!(
3558            parse("    dump_summaries: false\n").dump_summaries,
3559            Some(false)
3560        );
3561        assert_eq!(parse("").dump_summaries, None);
3562    }
3563
3564    /// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
3565    /// prompt-source exclusivity check nor the threshold range check, and does not disturb
3566    /// the values those fields parse to.
3567    #[test]
3568    fn compaction_dump_summaries_does_not_interact_with_other_fields() {
3569        let compaction = RuntimeManifest::from_yaml_str(
3570            r#"
3571name: cap
3572version: 0.0.1
3573artifacts: []
3574inference:
3575  endpoint: http://127.0.0.1:8080
3576  model: test-model
3577  compaction:
3578    threshold: 0.5
3579    model: compaction-model
3580    system_prompt_file: "compaction-instructions.md"
3581    dump_summaries: true
3582  driver:
3583    artifact: murmur-driver-anthropic
3584"#,
3585        )
3586        .unwrap()
3587        .inference
3588        .unwrap()
3589        .compaction
3590        .unwrap();
3591
3592        assert_eq!(compaction.dump_summaries, Some(true));
3593        assert_eq!(compaction.threshold, Some(0.5));
3594        assert_eq!(compaction.model, Some("compaction-model".to_string()));
3595        assert_eq!(
3596            compaction.system_prompt_file,
3597            Some("compaction-instructions.md".to_string())
3598        );
3599        assert!(compaction.system_prompt.is_none());
3600
3601        // Still mutually exclusive with dump_summaries in the mix...
3602        let err = RuntimeManifest::from_yaml_str(
3603            r#"
3604name: cap
3605version: 0.0.1
3606artifacts: []
3607inference:
3608  endpoint: http://127.0.0.1:8080
3609  model: test-model
3610  compaction:
3611    dump_summaries: true
3612    system_prompt: "inline"
3613    system_prompt_file: "compaction-instructions.md"
3614  driver:
3615    artifact: murmur-driver-anthropic
3616"#,
3617        )
3618        .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
3619        assert!(matches!(
3620            err,
3621            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
3622                if field == "inference.compaction.system_prompt"
3623        ));
3624
3625        // ...and the threshold range is still enforced.
3626        let err = RuntimeManifest::from_yaml_str(
3627            r#"
3628name: cap
3629version: 0.0.1
3630artifacts: []
3631inference:
3632  endpoint: http://127.0.0.1:8080
3633  model: test-model
3634  compaction:
3635    dump_summaries: true
3636    threshold: 1.5
3637  driver:
3638    artifact: murmur-driver-anthropic
3639"#,
3640        )
3641        .expect_err("dump_summaries must not suppress the threshold range check");
3642        assert!(matches!(
3643            err,
3644            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
3645                if field == "inference.compaction.threshold"
3646        ));
3647    }
3648
3649    /// Setting both compaction prompt sources is a parse error naming the compaction
3650    /// fields — distinguishable from the top-level three-way `inference.system_prompt`
3651    /// exclusivity error, and with neither value silently preferred.
3652    #[test]
3653    fn compaction_prompt_sources_are_mutually_exclusive() {
3654        let err = RuntimeManifest::from_yaml_str(
3655            r#"
3656name: cap
3657version: 0.0.1
3658artifacts: []
3659inference:
3660  endpoint: http://127.0.0.1:8080
3661  model: test-model
3662  compaction:
3663    system_prompt: "inline"
3664    system_prompt_file: "compaction-instructions.md"
3665  driver:
3666    artifact: murmur-driver-anthropic
3667"#,
3668        )
3669        .expect_err("both compaction prompt sources set must fail");
3670
3671        match err {
3672            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
3673                assert_eq!(field, "inference.compaction.system_prompt");
3674                assert_eq!(
3675                    message,
3676                    "at most one of inference.compaction.system_prompt, \
3677                     inference.compaction.system_prompt_file may be set"
3678                );
3679            }
3680            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
3681        }
3682    }
3683
3684    /// The compaction-level exclusivity check is separate from the top-level one: an
3685    /// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
3686    /// perfectly legal manifest.
3687    #[test]
3688    fn top_level_and_compaction_prompt_sources_do_not_collide() {
3689        let manifest = RuntimeManifest::from_yaml_str(
3690            r#"
3691name: cap
3692version: 0.0.1
3693artifacts: []
3694inference:
3695  endpoint: http://127.0.0.1:8080
3696  model: test-model
3697  system_prompt: "be helpful"
3698  compaction:
3699    system_prompt_file: "compaction-instructions.md"
3700  driver:
3701    artifact: murmur-driver-anthropic
3702"#,
3703        )
3704        .unwrap();
3705
3706        let inference = manifest.inference.unwrap();
3707        assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
3708        assert_eq!(
3709            inference.compaction.unwrap().system_prompt_file,
3710            Some("compaction-instructions.md".to_string())
3711        );
3712    }
3713
3714    #[test]
3715    fn parses_system_prompt_file() {
3716        let manifest = RuntimeManifest::from_yaml_str(
3717            r#"
3718name: cap
3719version: 0.0.1
3720artifacts: []
3721inference:
3722  endpoint: http://127.0.0.1:8080
3723  model: test-model
3724  system_prompt_file: "conventions.md"
3725  driver:
3726    artifact: murmur-driver-anthropic
3727"#,
3728        )
3729        .unwrap();
3730
3731        let inference = manifest.inference.unwrap();
3732        assert_eq!(
3733            inference.system_prompt_file,
3734            Some("conventions.md".to_string())
3735        );
3736        assert!(inference.system_prompt.is_none());
3737    }
3738
3739    #[test]
3740    fn rejects_both_inline_and_file_system_prompt() {
3741        let err = RuntimeManifest::from_yaml_str(
3742            r#"
3743name: cap
3744version: 0.0.1
3745artifacts: []
3746inference:
3747  endpoint: http://127.0.0.1:8080
3748  model: test-model
3749  system_prompt: "inline"
3750  system_prompt_file: "conventions.md"
3751  driver:
3752    artifact: murmur-driver-anthropic
3753"#,
3754        )
3755        .unwrap_err();
3756
3757        let msg = err.to_string();
3758        assert!(msg.contains("inference.system_prompt"));
3759        assert!(msg.contains("system_prompt_file"));
3760    }
3761
3762    #[test]
3763    fn resolves_api_key_env_reference() {
3764        let key = "MURMUR_TEST_INFERENCE_KEY_RESOLVE";
3765        std::env::set_var(key, "resolved-secret");
3766
3767        let manifest = RuntimeManifest::from_yaml_str(&format!(
3768            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: http\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  api_key: ${{{key}}}\n  driver:\n    artifact: murmur-driver-anthropic\n"
3769        ))
3770        .unwrap();
3771
3772        assert_eq!(
3773            manifest.inference.unwrap().api_key,
3774            Some("resolved-secret".to_string())
3775        );
3776
3777        std::env::remove_var(key);
3778    }
3779
3780    #[test]
3781    fn missing_api_key_env_reference_reports_clear_error() {
3782        let key = "MURMUR_TEST_INFERENCE_KEY_MISSING";
3783        std::env::remove_var(key);
3784
3785        let err = RuntimeManifest::from_yaml_str(&format!(
3786            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: http\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  api_key: ${{{key}}}\n  driver:\n    artifact: murmur-driver-anthropic\n"
3787        ))
3788        .unwrap_err();
3789
3790        let msg = err.to_string();
3791        assert!(msg.contains("inference.api_key"));
3792        assert!(msg.contains(&format!("${{{key}}}")));
3793        assert!(msg.contains("environment variable is not set"));
3794    }
3795
3796    #[test]
3797    fn accepts_legacy_inference_provider_field() {
3798        let manifest = RuntimeManifest::from_yaml_str(
3799            r#"
3800name: cap
3801version: 0.0.1
3802artifacts: []
3803inference:
3804  transport: http
3805  endpoint: http://127.0.0.1:8080
3806  model: test-model
3807  provider:
3808    artifact: murmur-driver-openai
3809"#,
3810        )
3811        .unwrap();
3812
3813        assert_eq!(
3814            manifest
3815                .inference
3816                .unwrap()
3817                .driver
3818                .as_ref()
3819                .unwrap()
3820                .artifact,
3821            "murmur-driver-openai"
3822        );
3823    }
3824
3825    #[test]
3826    fn prefers_driver_field_over_legacy_provider_field() {
3827        let manifest = RuntimeManifest::from_yaml_str(
3828            r#"
3829name: cap
3830version: 0.0.1
3831artifacts: []
3832inference:
3833  transport: http
3834  endpoint: http://127.0.0.1:8080
3835  model: test-model
3836  driver:
3837    artifact: murmur-driver-anthropic
3838  provider:
3839    artifact: murmur-driver-openai
3840"#,
3841        )
3842        .unwrap();
3843
3844        assert_eq!(
3845            manifest
3846                .inference
3847                .unwrap()
3848                .driver
3849                .as_ref()
3850                .unwrap()
3851                .artifact,
3852            "murmur-driver-anthropic"
3853        );
3854    }
3855
3856    #[test]
3857    fn inference_requires_driver_artifact() {
3858        let err = RuntimeManifest::from_yaml_str(
3859            r#"
3860name: cap
3861version: 0.0.1
3862artifacts: []
3863inference:
3864  transport: http
3865  endpoint: http://127.0.0.1:8080
3866  model: test-model
3867"#,
3868        )
3869        .unwrap_err();
3870
3871        assert!(err.to_string().contains("inference.driver"));
3872    }
3873
3874    #[test]
3875    fn test_runtime_driver_deserializes() {
3876        let manifest = RuntimeManifest::from_yaml_str(
3877            r#"
3878name: cap
3879version: 0.0.1
3880artifacts:
3881  - name: my-provider
3882    version: 1.0.0
3883    runtime: driver
3884"#,
3885        )
3886        .unwrap();
3887
3888        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
3889    }
3890
3891    #[test]
3892    fn test_runtime_unknown_is_error() {
3893        let err = RuntimeManifest::from_yaml_str(
3894            r#"
3895name: cap
3896version: 0.0.1
3897artifacts:
3898  - name: foo
3899    version: 1.0.0
3900    runtime: unknown_type
3901"#,
3902        )
3903        .unwrap_err();
3904
3905        let msg = err.to_string();
3906        assert!(msg.contains("runtime"), "error should mention 'runtime'");
3907        assert!(
3908            msg.contains("unknown_type"),
3909            "error should identify the bad value"
3910        );
3911    }
3912
3913    #[test]
3914    fn test_runtime_all_variants_roundtrip() {
3915        for variant in [
3916            ArtifactRuntime::Tool,
3917            ArtifactRuntime::Driver,
3918            ArtifactRuntime::Hook,
3919            ArtifactRuntime::Skill,
3920        ] {
3921            let json = serde_json::to_string(&variant).unwrap();
3922            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
3923            assert_eq!(variant, back);
3924        }
3925    }
3926
3927    #[test]
3928    fn skill_source_without_version_defaults_to_local() {
3929        let manifest = RuntimeManifest::from_yaml_str(
3930            r#"
3931name: cap
3932version: 0.0.1
3933artifacts:
3934  - name: my-skill
3935    source: ./skills/my-skill/skill.md
3936    runtime: skill
3937"#,
3938        )
3939        .unwrap();
3940
3941        assert_eq!(manifest.artifacts.len(), 1);
3942        let artifact = &manifest.artifacts[0];
3943        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
3944        assert_eq!(
3945            artifact.source.as_deref(),
3946            Some("./skills/my-skill/skill.md")
3947        );
3948        assert_eq!(artifact.version, "local");
3949    }
3950
3951    #[test]
3952    fn skill_source_directory_path_is_accepted() {
3953        let manifest = RuntimeManifest::from_yaml_str(
3954            r#"
3955name: cap
3956version: 0.0.1
3957artifacts:
3958  - name: my-skill
3959    source: ./skills/my-skill/
3960    runtime: skill
3961"#,
3962        )
3963        .unwrap();
3964
3965        assert_eq!(
3966            manifest.artifacts[0].source.as_deref(),
3967            Some("./skills/my-skill/")
3968        );
3969        assert_eq!(manifest.artifacts[0].version, "local");
3970    }
3971
3972    #[test]
3973    fn skill_source_with_explicit_version_substitutes_local() {
3974        // Both set is allowed; version is ignored and "local" is substituted.
3975        let manifest = RuntimeManifest::from_yaml_str(
3976            r#"
3977name: cap
3978version: 0.0.1
3979artifacts:
3980  - name: my-skill
3981    source: ./skills/my-skill/skill.md
3982    version: 9.9.9
3983    runtime: skill
3984"#,
3985        )
3986        .unwrap();
3987
3988        assert_eq!(manifest.artifacts[0].version, "local");
3989    }
3990
3991    #[test]
3992    fn local_source_true_allows_source_on_non_skill_runtime() {
3993        for runtime in ["tool", "driver", "hook"] {
3994            let yaml = format!(
3995                r#"
3996name: cap
3997version: 0.0.1
3998artifacts:
3999  - name: my-thing
4000    source: ./local/path
4001    local_source: true
4002    runtime: {runtime}
4003"#
4004            );
4005            let manifest = RuntimeManifest::from_yaml_str(&yaml)
4006                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
4007            let artifact = &manifest.artifacts[0];
4008            assert!(artifact.local_source, "runtime {runtime}");
4009            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
4010            assert_eq!(artifact.version, "local", "runtime {runtime}");
4011        }
4012    }
4013
4014    #[test]
4015    fn local_source_false_on_skill_with_source_is_error() {
4016        // The gate reads the declared field, not the role: an explicit `local_source: false`
4017        // overrides the skill default and rejects `source:`.
4018        let err = RuntimeManifest::from_yaml_str(
4019            r#"
4020name: cap
4021version: 0.0.1
4022artifacts:
4023  - name: my-skill
4024    source: ./skills/my-skill
4025    local_source: false
4026    runtime: skill
4027"#,
4028        )
4029        .unwrap_err();
4030        let msg = err.to_string();
4031        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
4032        assert!(msg.contains("my-skill"), "error was: {msg}");
4033    }
4034
4035    #[test]
4036    fn local_source_defaults_from_role_when_absent() {
4037        let manifest = RuntimeManifest::from_yaml_str(
4038            r#"
4039name: cap
4040version: 0.0.1
4041artifacts:
4042  - name: my-skill
4043    source: ./skills/my-skill
4044    runtime: skill
4045  - name: my-tool
4046    version: 1.0.0
4047    runtime: tool
4048"#,
4049        )
4050        .unwrap();
4051        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
4052        assert!(
4053            !manifest.artifacts[1].local_source,
4054            "tool defaults to false"
4055        );
4056    }
4057
4058    #[test]
4059    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
4060        let manifest = RuntimeManifest::from_yaml_str(
4061            r#"
4062name: cap
4063version: 0.0.1
4064artifacts:
4065  - name: my-tool
4066    version: 1.0.0
4067    runtime: tool
4068    prompt_payload: true
4069inference:
4070  endpoint: http://127.0.0.1:8080
4071  model: test-model
4072  system_prompt_artifact: my-tool
4073  driver:
4074    artifact: murmur-driver-anthropic
4075"#,
4076        )
4077        .unwrap();
4078        assert!(manifest.artifacts[0].prompt_payload);
4079    }
4080
4081    #[test]
4082    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
4083        let err = RuntimeManifest::from_yaml_str(
4084            r#"
4085name: cap
4086version: 0.0.1
4087artifacts:
4088  - name: my-skill
4089    version: 1.0.0
4090    runtime: skill
4091    prompt_payload: false
4092inference:
4093  endpoint: http://127.0.0.1:8080
4094  model: test-model
4095  system_prompt_artifact: my-skill
4096  driver:
4097    artifact: murmur-driver-anthropic
4098"#,
4099        )
4100        .unwrap_err();
4101        let msg = err.to_string();
4102        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
4103    }
4104
4105    #[test]
4106    fn prompt_payload_defaults_from_role_when_absent() {
4107        let manifest = RuntimeManifest::from_yaml_str(
4108            r#"
4109name: cap
4110version: 0.0.1
4111artifacts:
4112  - name: my-skill
4113    version: 1.0.0
4114    runtime: skill
4115  - name: my-tool
4116    version: 1.0.0
4117    runtime: tool
4118"#,
4119        )
4120        .unwrap();
4121        assert!(
4122            manifest.artifacts[0].prompt_payload,
4123            "skill defaults to true"
4124        );
4125        assert!(
4126            !manifest.artifacts[1].prompt_payload,
4127            "tool defaults to false"
4128        );
4129    }
4130
4131    #[test]
4132    fn source_on_non_skill_runtime_is_error() {
4133        for runtime in ["tool", "driver", "hook"] {
4134            let yaml = format!(
4135                r#"
4136name: cap
4137version: 0.0.1
4138artifacts:
4139  - name: my-thing
4140    source: ./local/path
4141    runtime: {runtime}
4142"#
4143            );
4144            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
4145            let msg = err.to_string();
4146            assert!(
4147                msg.contains("'local_source: true'"),
4148                "runtime {runtime}: error was: {msg}"
4149            );
4150            assert!(
4151                msg.contains("my-thing"),
4152                "runtime {runtime}: error was: {msg}"
4153            );
4154        }
4155    }
4156
4157    #[test]
4158    fn registry_skill_still_requires_version() {
4159        let err = RuntimeManifest::from_yaml_str(
4160            r#"
4161name: cap
4162version: 0.0.1
4163artifacts:
4164  - name: my-skill
4165    runtime: skill
4166"#,
4167        )
4168        .unwrap_err();
4169
4170        let msg = err.to_string();
4171        assert!(
4172            msg.contains("missing required field 'version'"),
4173            "error was: {msg}"
4174        );
4175    }
4176
4177    #[test]
4178    fn rejects_unknown_inference_transport() {
4179        let err = RuntimeManifest::from_yaml_str(
4180            r#"
4181name: cap
4182version: 0.0.1
4183artifacts: []
4184inference:
4185  transport: grpc
4186  endpoint: ignored
4187  model: test-model
4188  driver:
4189    artifact: murmur-driver-anthropic
4190"#,
4191        )
4192        .unwrap_err();
4193
4194        let msg = err.to_string();
4195        assert!(msg.contains("inference.transport"), "error was: {msg}");
4196        assert!(msg.contains("unknown value"), "error was: {msg}");
4197        assert!(msg.contains("grpc"), "error was: {msg}");
4198    }
4199
4200    #[test]
4201    fn parses_process_transport() {
4202        let manifest = RuntimeManifest::from_yaml_str(
4203            r#"
4204name: cap
4205version: 0.0.1
4206artifacts: []
4207inference:
4208  transport: process
4209  command: claude
4210  model: claude-haiku-4-5-20251001
4211  max_turns: 20
4212"#,
4213        )
4214        .unwrap();
4215
4216        let inference = manifest.inference.expect("inference should exist");
4217        assert_eq!(inference.transport, "process");
4218        assert_eq!(inference.command, Some("claude".to_string()));
4219        assert_eq!(inference.model, "claude-haiku-4-5-20251001");
4220        assert_eq!(inference.max_turns, 20);
4221        assert!(inference.driver.is_none());
4222        assert!(inference.endpoint.is_none());
4223        assert!(inference.api_key.is_none());
4224    }
4225
4226    #[test]
4227    fn process_transport_requires_command() {
4228        let err = RuntimeManifest::from_yaml_str(
4229            r#"
4230name: cap
4231version: 0.0.1
4232artifacts: []
4233inference:
4234  transport: process
4235  model: claude-haiku-4-5-20251001
4236"#,
4237        )
4238        .unwrap_err();
4239
4240        let msg = err.to_string();
4241        assert!(msg.contains("inference.command"), "error was: {msg}");
4242    }
4243
4244    #[test]
4245    fn process_transport_allows_absent_model() {
4246        // model is optional for transport: process — an empty/absent model means "use the CLI's
4247        // account-default model" (e.g. a codex subscription default). The command is still required.
4248        let manifest = RuntimeManifest::from_yaml_str(
4249            r#"
4250name: cap
4251version: 0.0.1
4252artifacts: []
4253inference:
4254  transport: process
4255  command: codex
4256"#,
4257        )
4258        .expect("process transport should parse without a model");
4259        let inference = manifest.inference.expect("inference present");
4260        assert_eq!(inference.transport, "process");
4261        assert_eq!(inference.command.as_deref(), Some("codex"));
4262        assert_eq!(
4263            inference.model, "",
4264            "absent model resolves to empty (provider default)"
4265        );
4266    }
4267
4268    #[test]
4269    fn process_transport_rejects_driver_artifact() {
4270        let err = RuntimeManifest::from_yaml_str(
4271            r#"
4272name: cap
4273version: 0.0.1
4274artifacts: []
4275inference:
4276  transport: process
4277  command: claude
4278  model: claude-haiku-4-5-20251001
4279  driver:
4280    artifact: murmur-driver-anthropic
4281"#,
4282        )
4283        .unwrap_err();
4284
4285        let msg = err.to_string();
4286        assert!(
4287            msg.contains("inference.driver.artifact"),
4288            "error was: {msg}"
4289        );
4290        assert!(
4291            msg.contains("not valid with transport: process"),
4292            "error was: {msg}"
4293        );
4294    }
4295
4296    #[test]
4297    fn process_transport_rejects_endpoint() {
4298        let err = RuntimeManifest::from_yaml_str(
4299            r#"
4300name: cap
4301version: 0.0.1
4302artifacts: []
4303inference:
4304  transport: process
4305  command: claude
4306  model: claude-haiku-4-5-20251001
4307  endpoint: http://localhost:8080
4308"#,
4309        )
4310        .unwrap_err();
4311
4312        let msg = err.to_string();
4313        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
4314        assert!(
4315            msg.contains("not valid with transport: process"),
4316            "error was: {msg}"
4317        );
4318    }
4319
4320    #[test]
4321    fn process_transport_rejects_api_key() {
4322        // Key assembled at runtime so the source never contains a
4323        // credential-shaped literal that secret scanners could flag.
4324        let key = ["sk-", "ant-", "secret"].concat();
4325        let err = RuntimeManifest::from_yaml_str(&format!(
4326            r#"
4327name: cap
4328version: 0.0.1
4329artifacts: []
4330inference:
4331  transport: process
4332  command: claude
4333  model: claude-haiku-4-5-20251001
4334  api_key: {key}
4335"#
4336        ))
4337        .unwrap_err();
4338
4339        let msg = err.to_string();
4340        assert!(msg.contains("inference.api_key"), "error was: {msg}");
4341        assert!(
4342            msg.contains("not valid with transport: process"),
4343            "error was: {msg}"
4344        );
4345    }
4346
4347    #[test]
4348    fn process_transport_rejects_max_tokens() {
4349        // Rejected even at a perfectly valid value: the CLI subprocess path never builds a
4350        // driver payload, so accepting it would leave it silently inert.
4351        let err = RuntimeManifest::from_yaml_str(
4352            r#"
4353name: cap
4354version: 0.0.1
4355artifacts: []
4356inference:
4357  transport: process
4358  command: claude
4359  model: claude-haiku-4-5-20251001
4360  max_tokens: 4096
4361"#,
4362        )
4363        .unwrap_err();
4364
4365        let msg = err.to_string();
4366        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
4367        assert!(
4368            msg.contains("not valid with transport: process"),
4369            "error was: {msg}"
4370        );
4371    }
4372
4373    #[test]
4374    fn inference_max_tokens_round_trips() {
4375        let manifest = RuntimeManifest::from_yaml_str(
4376            r#"
4377name: cap
4378version: 0.0.1
4379artifacts: []
4380inference:
4381  transport: http
4382  endpoint: https://api.anthropic.com
4383  model: claude-opus-4-5
4384  max_tokens: 4096
4385  driver:
4386    artifact: murmur-driver-anthropic
4387"#,
4388        )
4389        .unwrap();
4390
4391        let inference = manifest.inference.unwrap();
4392        assert_eq!(inference.max_tokens, Some(4096));
4393    }
4394
4395    #[test]
4396    fn inference_max_tokens_absent_is_none() {
4397        // Absent means "the manifest didn't set it" all the way down; the 8192 default is
4398        // applied once, by the runtime, not smuggled in here.
4399        let manifest = RuntimeManifest::from_yaml_str(
4400            r#"
4401name: cap
4402version: 0.0.1
4403artifacts: []
4404inference:
4405  transport: http
4406  endpoint: https://api.anthropic.com
4407  model: claude-opus-4-5
4408  driver:
4409    artifact: murmur-driver-anthropic
4410"#,
4411        )
4412        .unwrap();
4413
4414        assert_eq!(manifest.inference.unwrap().max_tokens, None);
4415    }
4416
4417    #[test]
4418    fn inference_max_tokens_zero_is_rejected() {
4419        let err = RuntimeManifest::from_yaml_str(
4420            r#"
4421name: cap
4422version: 0.0.1
4423artifacts: []
4424inference:
4425  transport: http
4426  endpoint: https://api.anthropic.com
4427  model: claude-opus-4-5
4428  max_tokens: 0
4429  driver:
4430    artifact: murmur-driver-anthropic
4431"#,
4432        )
4433        .unwrap_err();
4434
4435        let msg = err.to_string();
4436        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
4437        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
4438    }
4439
4440    #[test]
4441    fn inference_max_tokens_large_value_is_accepted() {
4442        // Manifest validation stays advisory: an over-large cap is the provider's to reject
4443        // at request time, not ours to clamp or block at load time.
4444        let manifest = RuntimeManifest::from_yaml_str(
4445            r#"
4446name: cap
4447version: 0.0.1
4448artifacts: []
4449inference:
4450  transport: http
4451  endpoint: https://api.anthropic.com
4452  model: claude-opus-4-5
4453  max_tokens: 999999
4454  driver:
4455    artifact: murmur-driver-anthropic
4456"#,
4457        )
4458        .unwrap();
4459
4460        assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
4461    }
4462
4463    #[test]
4464    fn inference_and_context_max_tokens_are_independent() {
4465        // Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
4466        // session compaction budget. Neither may leak into the other.
4467        let manifest = RuntimeManifest::from_yaml_str(
4468            r#"
4469name: cap
4470version: 0.0.1
4471artifacts: []
4472inference:
4473  transport: http
4474  endpoint: https://api.anthropic.com
4475  model: claude-opus-4-5
4476  max_tokens: 4096
4477  driver:
4478    artifact: murmur-driver-anthropic
4479context:
4480  max_tokens: 200000
4481"#,
4482        )
4483        .unwrap();
4484
4485        assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
4486        assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
4487    }
4488
4489    #[test]
4490    fn hook_config_defaults_when_fields_absent() {
4491        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
4492        assert_eq!(config.binding, HookBinding::All);
4493        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
4494        assert_eq!(config.commit_policy, HookCommitPolicy::None);
4495    }
4496
4497    #[test]
4498    fn hook_config_parses_all_fields() {
4499        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
4500        let config = parse_hook_config_from_yaml(yaml).unwrap();
4501        assert_eq!(config.binding, HookBinding::OnCompaction);
4502        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
4503        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
4504    }
4505
4506    #[test]
4507    fn hook_config_parses_on_task_start() {
4508        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
4509        let config = parse_hook_config_from_yaml(yaml).unwrap();
4510        assert_eq!(config.binding, HookBinding::OnTaskStart);
4511    }
4512
4513    #[test]
4514    fn hook_config_parses_on_task_end() {
4515        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
4516        let config = parse_hook_config_from_yaml(yaml).unwrap();
4517        assert_eq!(config.binding, HookBinding::OnTaskEnd);
4518    }
4519
4520    #[test]
4521    fn hook_config_unknown_binding_lists_task_events() {
4522        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
4523        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4524        assert!(err.contains("on-task-start"), "error was: {err}");
4525        assert!(err.contains("on-task-end"), "error was: {err}");
4526    }
4527
4528    #[test]
4529    fn hook_config_async_with_commit_is_rejected() {
4530        let yaml =
4531            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
4532        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4533        assert!(err.contains("async-with-commit"), "error was: {err}");
4534    }
4535
4536    #[test]
4537    fn hook_config_on_stage_async_is_rejected() {
4538        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
4539        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4540        assert!(err.contains("on-stage"), "error was: {err}");
4541        assert!(err.contains("blocking"), "error was: {err}");
4542    }
4543
4544    #[test]
4545    fn hook_config_async_none_is_valid() {
4546        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
4547        let config = parse_hook_config_from_yaml(yaml).unwrap();
4548        assert_eq!(config.execution_mode, HookExecutionMode::Async);
4549        assert_eq!(config.commit_policy, HookCommitPolicy::None);
4550    }
4551
4552    #[test]
4553    fn hook_config_parses_reopen_task_commit_policy() {
4554        let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
4555        let config = parse_hook_config_from_yaml(yaml).unwrap();
4556        assert_eq!(config.binding, HookBinding::OnTaskEnd);
4557        assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
4558    }
4559
4560    #[test]
4561    fn hook_config_unknown_commit_policy_lists_reopen_task() {
4562        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
4563        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4564        assert!(err.contains("reopen-task"), "error was: {err}");
4565    }
4566
4567    /// The existing, unmodified async/commit validation must also reject
4568    /// `commit_policy: reopen-task` combined with `execution_mode: async`.
4569    #[test]
4570    fn hook_config_async_reopen_task_is_rejected() {
4571        let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
4572        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4573        assert!(err.contains("async-with-commit"), "error was: {err}");
4574        assert!(err.contains("reopen-task"), "error was: {err}");
4575    }
4576
4577    // ── binding is the single source of truth for what a hook commits ─────────────
4578
4579    /// The five bindings whose events honor no `hook-output` arm at all cannot declare
4580    /// any non-`none` `commit_policy`. The error names the binding, the declared policy,
4581    /// and `none` as what the binding honors.
4582    #[test]
4583    fn hook_config_non_committing_binding_rejects_any_commit_policy() {
4584        for binding in [
4585            "on-session-start",
4586            "on-task-start",
4587            "on-tool-call",
4588            "on-shell",
4589            "on-session-end",
4590        ] {
4591            let yaml = format!(
4592                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
4593            );
4594            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
4595            assert!(err.contains(binding), "error was: {err}");
4596            assert!(err.contains("replace-context"), "error was: {err}");
4597            assert!(
4598                err.contains("honors commit_policy 'none'"),
4599                "error was: {err}"
4600            );
4601        }
4602    }
4603
4604    /// A binding that honors one arm rejects every *other* policy, naming the one it does
4605    /// honor so the fix is obvious from the message alone.
4606    #[test]
4607    fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
4608        for (binding, declared, honored) in [
4609            ("on-stage", "reopen-task", "write-manifests"),
4610            ("on-compaction", "write-manifests", "replace-context"),
4611            ("on-task-end", "replace-context", "reopen-task"),
4612        ] {
4613            let yaml = format!(
4614                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
4615            );
4616            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
4617            assert!(err.contains(binding), "error was: {err}");
4618            assert!(
4619                err.contains(&format!("commit_policy '{declared}' is not valid")),
4620                "error was: {err}"
4621            );
4622            assert!(
4623                err.contains(&format!("honors commit_policy '{honored}'")),
4624                "error was: {err}"
4625            );
4626        }
4627    }
4628
4629    /// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
4630    /// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
4631    #[test]
4632    fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
4633        for declared in ["replace-context", "write-manifests", "reopen-task"] {
4634            let yaml = format!(
4635                "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
4636            );
4637            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
4638            assert!(err.contains("on-inference"), "error was: {err}");
4639            assert!(err.contains(declared), "error was: {err}");
4640            assert!(
4641                err.contains("honors commit_policy 'none'"),
4642                "error was: {err}"
4643            );
4644            assert!(err.contains("artifact"), "error was: {err}");
4645        }
4646    }
4647
4648    /// A binding declaring exactly the policy it honors is accepted, for all three
4649    /// representable pairs.
4650    #[test]
4651    fn hook_config_matching_binding_and_commit_policy_is_valid() {
4652        for (binding, policy, expected) in [
4653            (
4654                "on-stage",
4655                "write-manifests",
4656                HookCommitPolicy::WriteManifests,
4657            ),
4658            (
4659                "on-compaction",
4660                "replace-context",
4661                HookCommitPolicy::ReplaceContext,
4662            ),
4663            ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
4664        ] {
4665            let yaml =
4666                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
4667            let config = parse_hook_config_from_yaml(&yaml).unwrap();
4668            assert_eq!(config.commit_policy, expected);
4669        }
4670    }
4671
4672    /// Every binding may declare `commit_policy: none` — including the ones that honor
4673    /// an arm, which simply means the hook opts out of committing.
4674    #[test]
4675    fn hook_config_commit_policy_none_is_valid_for_every_binding() {
4676        for binding in [
4677            "on-stage",
4678            "on-session-start",
4679            "on-task-start",
4680            "on-inference",
4681            "on-tool-call",
4682            "on-shell",
4683            "on-compaction",
4684            "on-task-end",
4685            "on-session-end",
4686        ] {
4687            let yaml =
4688                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
4689            let config = parse_hook_config_from_yaml(&yaml).unwrap();
4690            assert_eq!(config.commit_policy, HookCommitPolicy::None);
4691        }
4692    }
4693
4694    /// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
4695    /// to every event — including all four that honor an arm — so every `commit_policy` is
4696    /// accepted for it. Deliberate: no narrowing for `All`.
4697    #[test]
4698    fn hook_config_omitted_binding_accepts_every_commit_policy() {
4699        for (policy, expected) in [
4700            ("replace-context", HookCommitPolicy::ReplaceContext),
4701            ("write-manifests", HookCommitPolicy::WriteManifests),
4702            ("reopen-task", HookCommitPolicy::ReopenTask),
4703            ("none", HookCommitPolicy::None),
4704        ] {
4705            let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
4706            let config = parse_hook_config_from_yaml(&yaml).unwrap();
4707            assert_eq!(config.binding, HookBinding::All);
4708            assert_eq!(config.commit_policy, expected);
4709        }
4710    }
4711
4712    /// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
4713    /// binding/commit_policy check exists — this change is non-breaking for all eight. The
4714    /// field combinations are reproduced literally rather than read from that repo, so this
4715    /// suite stays self-contained.
4716    #[test]
4717    fn hook_config_shipped_default_artifact_manifests_still_parse() {
4718        let shipped: [(&str, &str, HookConfig); 8] = [
4719            (
4720                "murmur-hook-compact",
4721                "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
4722                HookConfig {
4723                    binding: HookBinding::OnCompaction,
4724                    execution_mode: HookExecutionMode::Blocking,
4725                    commit_policy: HookCommitPolicy::ReplaceContext,
4726                },
4727            ),
4728            (
4729                "murmur-hook-shell-desc",
4730                "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
4731                HookConfig {
4732                    binding: HookBinding::OnStage,
4733                    execution_mode: HookExecutionMode::Blocking,
4734                    commit_policy: HookCommitPolicy::WriteManifests,
4735                },
4736            ),
4737            (
4738                // No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
4739                "murmur-hook-regression-verifier",
4740                "execution_mode: blocking\ncommit_policy: reopen-task\n",
4741                HookConfig {
4742                    binding: HookBinding::All,
4743                    execution_mode: HookExecutionMode::Blocking,
4744                    commit_policy: HookCommitPolicy::ReopenTask,
4745                },
4746            ),
4747            (
4748                "murmur-hook-memory-jsonl",
4749                "execution_mode: blocking\ncommit_policy: replace-context\n",
4750                HookConfig {
4751                    binding: HookBinding::All,
4752                    execution_mode: HookExecutionMode::Blocking,
4753                    commit_policy: HookCommitPolicy::ReplaceContext,
4754                },
4755            ),
4756            (
4757                "murmur-hook-debug",
4758                "execution_mode: async\ncommit_policy: none\n",
4759                HookConfig {
4760                    binding: HookBinding::All,
4761                    execution_mode: HookExecutionMode::Async,
4762                    commit_policy: HookCommitPolicy::None,
4763                },
4764            ),
4765            (
4766                "murmur-hook-diff-summary",
4767                "execution_mode: blocking\ncommit_policy: none\n",
4768                HookConfig {
4769                    binding: HookBinding::All,
4770                    execution_mode: HookExecutionMode::Blocking,
4771                    commit_policy: HookCommitPolicy::None,
4772                },
4773            ),
4774            ("murmur-hook-eval", "", HookConfig::default()),
4775            ("murmur-hook-grafana", "", HookConfig::default()),
4776        ];
4777
4778        for (name, fields, expected) in shipped {
4779            let yaml = format!("name: {name}\nruntime: hook\n{fields}");
4780            let config = parse_hook_config_from_yaml(&yaml)
4781                .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
4782            assert_eq!(config, expected, "shipped hook {name}");
4783        }
4784    }
4785
4786    /// The manifest-side honored-policy table agrees with the manifest spellings the
4787    /// parser accepts; `capsule-runtime` separately cross-checks it against its own
4788    /// `HONORED_OUTPUT_ARM` dispatch table.
4789    #[test]
4790    fn commit_policy_for_binding_matches_the_declarable_pairs() {
4791        assert_eq!(
4792            commit_policy_for_binding(&HookBinding::OnStage),
4793            Some(HookCommitPolicy::WriteManifests)
4794        );
4795        assert_eq!(
4796            commit_policy_for_binding(&HookBinding::OnCompaction),
4797            Some(HookCommitPolicy::ReplaceContext)
4798        );
4799        assert_eq!(
4800            commit_policy_for_binding(&HookBinding::OnTaskEnd),
4801            Some(HookCommitPolicy::ReopenTask)
4802        );
4803        // `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
4804        // other five honor nothing. `All` is unconstrained by design.
4805        for binding in [
4806            HookBinding::OnSessionStart,
4807            HookBinding::OnTaskStart,
4808            HookBinding::OnInference,
4809            HookBinding::OnToolCall,
4810            HookBinding::OnShell,
4811            HookBinding::OnSessionEnd,
4812            HookBinding::All,
4813        ] {
4814            assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
4815        }
4816    }
4817
4818    #[test]
4819    fn parse_tool_implementation_defaults_to_wasm() {
4820        let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
4821        assert_eq!(impl_, ArtifactImplementation::Wasm);
4822    }
4823
4824    #[test]
4825    fn parse_tool_implementation_native() {
4826        let impl_ = parse_tool_implementation_from_yaml(
4827            "name: my-tool\nruntime: tool\nimplementation: native\n",
4828        );
4829        assert_eq!(impl_, ArtifactImplementation::Native);
4830    }
4831
4832    #[test]
4833    fn mur_version_parses_correctly() {
4834        let manifest = RuntimeManifest::from_yaml_str(
4835            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
4836        )
4837        .unwrap();
4838        assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
4839    }
4840
4841    #[test]
4842    fn mur_version_absent_is_none() {
4843        let manifest =
4844            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
4845        assert_eq!(manifest.mur_version, None);
4846    }
4847
4848    #[test]
4849    fn mur_version_empty_string_is_none() {
4850        let manifest = RuntimeManifest::from_yaml_str(
4851            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
4852        )
4853        .unwrap();
4854        assert_eq!(manifest.mur_version, None);
4855    }
4856
4857    #[test]
4858    fn inference_max_turns_defaults_to_10() {
4859        let manifest = RuntimeManifest::from_yaml_str(
4860            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  driver:\n    artifact: murmur-driver-anthropic\n",
4861        ).unwrap();
4862        assert_eq!(manifest.inference.unwrap().max_turns, 10);
4863    }
4864
4865    #[test]
4866    fn inference_max_turns_explicit_value() {
4867        let manifest = RuntimeManifest::from_yaml_str(
4868            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  max_turns: 20\n  driver:\n    artifact: murmur-driver-anthropic\n",
4869        ).unwrap();
4870        assert_eq!(manifest.inference.unwrap().max_turns, 20);
4871    }
4872
4873    #[test]
4874    fn inference_max_turns_zero_is_rejected() {
4875        let err = RuntimeManifest::from_yaml_str(
4876            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  max_turns: 0\n  driver:\n    artifact: murmur-driver-anthropic\n",
4877        ).unwrap_err();
4878        let msg = err.to_string();
4879        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
4880        assert!(msg.contains("greater than 0"), "error was: {msg}");
4881    }
4882
4883    #[test]
4884    fn inference_max_task_reopens_defaults_to_1() {
4885        let manifest = RuntimeManifest::from_yaml_str(
4886            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  driver:\n    artifact: murmur-driver-anthropic\n",
4887        ).unwrap();
4888        assert_eq!(manifest.inference.unwrap().max_task_reopens, 1);
4889    }
4890
4891    #[test]
4892    fn inference_max_task_reopens_explicit_value() {
4893        let manifest = RuntimeManifest::from_yaml_str(
4894            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  max_task_reopens: 3\n  driver:\n    artifact: murmur-driver-anthropic\n",
4895        ).unwrap();
4896        assert_eq!(manifest.inference.unwrap().max_task_reopens, 3);
4897    }
4898
4899    /// Unlike `max_turns`, `0` is a valid explicit value — it disables reopening.
4900    #[test]
4901    fn inference_max_task_reopens_zero_is_accepted() {
4902        let manifest = RuntimeManifest::from_yaml_str(
4903            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  max_task_reopens: 0\n  driver:\n    artifact: murmur-driver-anthropic\n",
4904        ).unwrap();
4905        assert_eq!(manifest.inference.unwrap().max_task_reopens, 0);
4906    }
4907
4908    /// `max_task_reopens` threads through the `transport: process` construction site too.
4909    #[test]
4910    fn inference_max_task_reopens_process_transport() {
4911        let manifest = RuntimeManifest::from_yaml_str(
4912            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\n",
4913        ).unwrap();
4914        assert_eq!(manifest.inference.unwrap().max_task_reopens, 2);
4915    }
4916
4917    #[test]
4918    fn skill_is_llm_visible() {
4919        assert!(ArtifactRuntime::Skill.is_llm_visible());
4920    }
4921
4922    #[test]
4923    fn driver_and_hook_are_not_llm_visible() {
4924        assert!(!ArtifactRuntime::Driver.is_llm_visible());
4925        assert!(!ArtifactRuntime::Hook.is_llm_visible());
4926    }
4927
4928    #[test]
4929    fn parses_system_prompt_artifact() {
4930        let manifest = RuntimeManifest::from_yaml_str(
4931            r#"
4932name: cap
4933version: 0.0.1
4934artifacts:
4935  - name: my-skill
4936    version: 1.0.0
4937    runtime: skill
4938inference:
4939  endpoint: http://127.0.0.1:8080
4940  model: test-model
4941  system_prompt_artifact: my-skill
4942  driver:
4943    artifact: murmur-driver-anthropic
4944"#,
4945        )
4946        .unwrap();
4947
4948        let inference = manifest.inference.unwrap();
4949        assert_eq!(
4950            inference.system_prompt_artifact,
4951            Some("my-skill".to_string())
4952        );
4953        assert!(inference.system_prompt.is_none());
4954        assert!(inference.system_prompt_file.is_none());
4955    }
4956
4957    #[test]
4958    fn rejects_system_prompt_artifact_with_inline_prompt() {
4959        let err = RuntimeManifest::from_yaml_str(
4960            r#"
4961name: cap
4962version: 0.0.1
4963artifacts:
4964  - name: my-skill
4965    version: 1.0.0
4966    runtime: skill
4967inference:
4968  endpoint: http://127.0.0.1:8080
4969  model: test-model
4970  system_prompt: "inline"
4971  system_prompt_artifact: my-skill
4972  driver:
4973    artifact: murmur-driver-anthropic
4974"#,
4975        )
4976        .unwrap_err();
4977
4978        let msg = err.to_string();
4979        assert!(msg.contains("at most one"), "error was: {msg}");
4980    }
4981
4982    #[test]
4983    fn rejects_system_prompt_artifact_naming_non_skill() {
4984        let err = RuntimeManifest::from_yaml_str(
4985            r#"
4986name: cap
4987version: 0.0.1
4988artifacts:
4989  - name: my-tool
4990    version: 1.0.0
4991    runtime: tool
4992inference:
4993  endpoint: http://127.0.0.1:8080
4994  model: test-model
4995  system_prompt_artifact: my-tool
4996  driver:
4997    artifact: murmur-driver-anthropic
4998"#,
4999        )
5000        .unwrap_err();
5001
5002        let msg = err.to_string();
5003        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
5004        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
5005    }
5006
5007    #[test]
5008    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
5009        let err = RuntimeManifest::from_yaml_str(
5010            r#"
5011name: cap
5012version: 0.0.1
5013artifacts: []
5014inference:
5015  endpoint: http://127.0.0.1:8080
5016  model: test-model
5017  system_prompt_artifact: missing-skill
5018  driver:
5019    artifact: murmur-driver-anthropic
5020"#,
5021        )
5022        .unwrap_err();
5023
5024        let msg = err.to_string();
5025        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
5026        assert!(msg.contains("not declared"), "error was: {msg}");
5027    }
5028
5029    #[test]
5030    fn rejects_all_three_system_prompt_sources() {
5031        let err = RuntimeManifest::from_yaml_str(
5032            r#"
5033name: cap
5034version: 0.0.1
5035artifacts:
5036  - name: my-skill
5037    version: 1.0.0
5038    runtime: skill
5039inference:
5040  endpoint: http://127.0.0.1:8080
5041  model: test-model
5042  system_prompt: "inline"
5043  system_prompt_file: "conventions.md"
5044  system_prompt_artifact: my-skill
5045  driver:
5046    artifact: murmur-driver-anthropic
5047"#,
5048        )
5049        .unwrap_err();
5050
5051        let msg = err.to_string();
5052        assert!(msg.contains("at most one"), "error was: {msg}");
5053    }
5054
5055    // ── Per-artifact capability grants ───────────────────────────────────────
5056
5057    #[test]
5058    fn hook_artifact_capabilities_parse_network_and_filesystem() {
5059        let manifest = RuntimeManifest::from_yaml_str(
5060            r#"
5061name: cap
5062version: 0.0.1
5063artifacts:
5064  - name: telemetry-hook
5065    version: 1.0.0
5066    runtime: hook
5067    capabilities:
5068      network:
5069        allow:
5070          - https://telemetry.example.com
5071      filesystem:
5072        scope: hook-state
5073"#,
5074        )
5075        .unwrap();
5076
5077        let caps = manifest.artifacts[0]
5078            .capabilities
5079            .as_ref()
5080            .expect("hook entry carries a capability grant");
5081        assert_eq!(
5082            caps.network.as_ref().unwrap().allow,
5083            vec!["https://telemetry.example.com".to_string()]
5084        );
5085        assert_eq!(
5086            caps.filesystem.as_ref().unwrap().scope,
5087            Some("hook-state".to_string())
5088        );
5089    }
5090
5091    /// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
5092    /// runtime lowers to zero network and zero preopened directories.
5093    #[test]
5094    fn hook_artifact_without_capabilities_is_none() {
5095        let manifest = RuntimeManifest::from_yaml_str(
5096            r#"
5097name: cap
5098version: 0.0.1
5099artifacts:
5100  - name: quiet-hook
5101    version: 1.0.0
5102    runtime: hook
5103"#,
5104        )
5105        .unwrap();
5106
5107        assert!(manifest.artifacts[0].capabilities.is_none());
5108    }
5109
5110    /// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
5111    /// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
5112    #[test]
5113    fn tool_and_driver_artifact_capabilities_parse() {
5114        for role in ["tool", "driver"] {
5115            let yaml = format!(
5116                r#"
5117name: cap
5118version: 0.0.1
5119artifacts:
5120  - name: scoped
5121    version: 1.0.0
5122    runtime: {role}
5123    capabilities:
5124      network:
5125        allow:
5126          - https://api.example.com
5127      filesystem:
5128        scope: cache
5129"#
5130            );
5131            let manifest = RuntimeManifest::from_yaml_str(&yaml)
5132                .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
5133
5134            let caps = manifest.artifacts[0]
5135                .capabilities
5136                .as_ref()
5137                .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
5138            assert_eq!(
5139                caps.network.as_ref().unwrap().allow,
5140                vec!["https://api.example.com".to_string()]
5141            );
5142            assert_eq!(
5143                caps.filesystem.as_ref().unwrap().scope,
5144                Some("cache".to_string())
5145            );
5146        }
5147    }
5148
5149    /// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
5150    /// lowers to the unchanged capsule ceiling.
5151    #[test]
5152    fn tool_artifact_without_capabilities_is_none() {
5153        let manifest = RuntimeManifest::from_yaml_str(
5154            r#"
5155name: cap
5156version: 0.0.1
5157artifacts:
5158  - name: plain-tool
5159    version: 1.0.0
5160    runtime: tool
5161"#,
5162        )
5163        .unwrap();
5164
5165        assert!(manifest.artifacts[0].capabilities.is_none());
5166    }
5167
5168    /// A skill has no execution surface, so a grant on one would be silently unenforced —
5169    /// rejected at parse time, with a message naming the roles that do work.
5170    #[test]
5171    fn skill_artifact_capabilities_are_rejected() {
5172        let yaml = r#"
5173name: cap
5174version: 0.0.1
5175artifacts:
5176  - name: sneaky
5177    version: 1.0.0
5178    runtime: skill
5179    capabilities:
5180      network:
5181        allow:
5182          - https://evil.example.com
5183"#;
5184        let err = match RuntimeManifest::from_yaml_str(yaml) {
5185            Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
5186            Err(err) => err,
5187        };
5188        let msg = err.to_string();
5189        assert!(msg.contains("sneaky"), "error was: {msg}");
5190        assert!(
5191            msg.contains(
5192                "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
5193                 entries"
5194            ),
5195            "error was: {msg}"
5196        );
5197        assert!(msg.contains("runtime: skill"), "error was: {msg}");
5198    }
5199
5200    // ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
5201
5202    /// `on_overflow: block` on a hook entry parses to the blocking policy.
5203    #[test]
5204    fn hook_on_overflow_block_parses() {
5205        let manifest = RuntimeManifest::from_yaml_str(
5206            r#"
5207name: cap
5208version: 0.0.1
5209artifacts:
5210  - name: murmur-hook-grafana
5211    version: 1.0.0
5212    runtime: hook
5213    on_overflow: block
5214"#,
5215        )
5216        .unwrap();
5217
5218        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
5219    }
5220
5221    /// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
5222    /// not backpressure they never asked for.
5223    #[test]
5224    fn hook_on_overflow_defaults_to_drop() {
5225        let manifest = RuntimeManifest::from_yaml_str(
5226            r#"
5227name: cap
5228version: 0.0.1
5229artifacts:
5230  - name: murmur-hook-grafana
5231    version: 1.0.0
5232    runtime: hook
5233"#,
5234        )
5235        .unwrap();
5236
5237        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
5238        assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
5239    }
5240
5241    /// Only a hook has a job queue, so the key is rejected on every other role rather than
5242    /// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
5243    #[test]
5244    fn on_overflow_on_a_non_hook_artifact_is_rejected() {
5245        let yaml = r#"
5246name: cap
5247version: 0.0.1
5248artifacts:
5249  - name: git
5250    version: 1.0.0
5251    runtime: tool
5252    on_overflow: block
5253"#;
5254        let err = match RuntimeManifest::from_yaml_str(yaml) {
5255            Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
5256            Err(err) => err,
5257        };
5258        assert!(matches!(
5259            err,
5260            RuntimeManifestError::InvalidArtifact { index: 0, .. }
5261        ));
5262        let msg = err.to_string();
5263        assert!(msg.contains("git"), "error was: {msg}");
5264        assert!(
5265            msg.contains("only recognized on 'runtime: hook' entries"),
5266            "error was: {msg}"
5267        );
5268    }
5269
5270    /// An unknown value is a typo, not a policy — rejected with both spellings named.
5271    #[test]
5272    fn unknown_on_overflow_value_is_rejected() {
5273        let yaml = r#"
5274name: cap
5275version: 0.0.1
5276artifacts:
5277  - name: murmur-hook-grafana
5278    version: 1.0.0
5279    runtime: hook
5280    on_overflow: spill
5281"#;
5282        let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
5283        let msg = err.to_string();
5284        assert!(msg.contains("spill"), "error was: {msg}");
5285        assert!(msg.contains("expected: drop, block"), "error was: {msg}");
5286    }
5287
5288    /// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
5289    /// cannot know a hook's execution mode, which lives in the artifact's own manifest.
5290    #[test]
5291    fn on_overflow_is_accepted_on_any_hook_entry() {
5292        let manifest = RuntimeManifest::from_yaml_str(
5293            r#"
5294name: cap
5295version: 0.0.1
5296artifacts:
5297  - name: murmur-hook-eval
5298    version: 1.0.0
5299    runtime: hook
5300    on_overflow: drop
5301    capabilities:
5302      network:
5303        allow:
5304          - https://telemetry.example.com
5305"#,
5306        )
5307        .unwrap();
5308
5309        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
5310        assert!(manifest.artifacts[0].capabilities.is_some());
5311    }
5312
5313    /// The capsule-wide top-level block is untouched by the per-artifact field: both may be
5314    /// present in one manifest and neither shadows the other.
5315    #[test]
5316    fn capsule_wide_and_hook_capabilities_coexist() {
5317        let manifest = RuntimeManifest::from_yaml_str(
5318            r#"
5319name: cap
5320version: 0.0.1
5321artifacts:
5322  - name: telemetry-hook
5323    version: 1.0.0
5324    runtime: hook
5325    capabilities:
5326      network:
5327        allow:
5328          - https://telemetry.example.com
5329capabilities:
5330  network:
5331    allow:
5332      - https://api.anthropic.com
5333"#,
5334        )
5335        .unwrap();
5336
5337        assert_eq!(
5338            manifest.capabilities.unwrap().network.unwrap().allow,
5339            vec!["https://api.anthropic.com".to_string()]
5340        );
5341        assert_eq!(
5342            manifest.artifacts[0]
5343                .capabilities
5344                .as_ref()
5345                .unwrap()
5346                .network
5347                .as_ref()
5348                .unwrap()
5349                .allow,
5350            vec!["https://telemetry.example.com".to_string()]
5351        );
5352    }
5353
5354    /// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
5355    /// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
5356    /// reads one. A self-declared `capabilities:` block there is inert.
5357    #[test]
5358    fn hook_own_manifest_capabilities_are_not_read_into_config() {
5359        let config = parse_hook_config_from_yaml(
5360            r#"
5361name: telemetry-hook
5362version: 1.0.0
5363binding: on-inference
5364execution_mode: blocking
5365capabilities:
5366  network:
5367    allow:
5368      - https://evil.example.com
5369  filesystem:
5370    scope: /
5371"#,
5372        )
5373        .expect("a self-declared capabilities block is ignored, not an error");
5374
5375        assert_eq!(
5376            config,
5377            HookConfig {
5378                binding: HookBinding::OnInference,
5379                execution_mode: HookExecutionMode::Blocking,
5380                commit_policy: HookCommitPolicy::None,
5381            }
5382        );
5383    }
5384
5385    // ── Containment class ────────────────────────────────────────────────────
5386
5387    #[test]
5388    fn containment_classes_order_weakest_to_strongest() {
5389        assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
5390        assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
5391        assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
5392        assert_eq!(
5393            ContainmentClass::ALL.iter().copied().max(),
5394            Some(ContainmentClass::Sealed)
5395        );
5396    }
5397
5398    #[test]
5399    fn containment_class_defaults_to_advisory() {
5400        assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
5401    }
5402
5403    #[test]
5404    fn containment_class_round_trips_through_string() {
5405        for class in ContainmentClass::ALL {
5406            assert_eq!(
5407                class.to_string().parse::<ContainmentClass>(),
5408                Ok(class),
5409                "{class} must survive Display -> FromStr"
5410            );
5411        }
5412        assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
5413        assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
5414        assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
5415    }
5416
5417    #[test]
5418    fn containment_class_rejects_unknown_names() {
5419        let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
5420        assert_eq!(err.value, "paranoid");
5421        assert_eq!(
5422            err.to_string(),
5423            "must be one of: advisory, scoped, sealed; got 'paranoid'"
5424        );
5425        // Case-sensitive by design: the wire form is lowercase everywhere.
5426        assert!("Sealed".parse::<ContainmentClass>().is_err());
5427    }
5428
5429    #[test]
5430    fn containment_class_serializes_as_its_wire_name() {
5431        assert_eq!(
5432            serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
5433            "\"scoped\""
5434        );
5435        assert_eq!(
5436            serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
5437            ContainmentClass::Sealed
5438        );
5439    }
5440
5441    #[test]
5442    fn effective_floor_is_advisory_when_nothing_is_declared() {
5443        assert_eq!(
5444            effective_containment_floor(None, None, None),
5445            ContainmentClass::Advisory
5446        );
5447    }
5448
5449    #[test]
5450    fn effective_floor_takes_the_strongest_across_every_presence_combination() {
5451        use ContainmentClass::{Advisory, Scoped, Sealed};
5452
5453        // Exactly one source present: that source wins outright.
5454        assert_eq!(
5455            effective_containment_floor(Some(Scoped), None, None),
5456            Scoped
5457        );
5458        assert_eq!(
5459            effective_containment_floor(None, Some(Scoped), None),
5460            Scoped
5461        );
5462        assert_eq!(
5463            effective_containment_floor(None, None, Some(Scoped)),
5464            Scoped
5465        );
5466        assert_eq!(
5467            effective_containment_floor(Some(Sealed), None, None),
5468            Sealed
5469        );
5470        assert_eq!(
5471            effective_containment_floor(None, Some(Sealed), None),
5472            Sealed
5473        );
5474        assert_eq!(
5475            effective_containment_floor(None, None, Some(Sealed)),
5476            Sealed
5477        );
5478
5479        // Two sources present: the stronger wins regardless of which slot holds it, and a
5480        // weaker source can never pull the stronger one down.
5481        assert_eq!(
5482            effective_containment_floor(Some(Advisory), Some(Sealed), None),
5483            Sealed
5484        );
5485        assert_eq!(
5486            effective_containment_floor(Some(Sealed), Some(Advisory), None),
5487            Sealed
5488        );
5489        assert_eq!(
5490            effective_containment_floor(Some(Advisory), None, Some(Scoped)),
5491            Scoped
5492        );
5493        assert_eq!(
5494            effective_containment_floor(Some(Scoped), None, Some(Advisory)),
5495            Scoped
5496        );
5497        assert_eq!(
5498            effective_containment_floor(None, Some(Advisory), Some(Scoped)),
5499            Scoped
5500        );
5501        assert_eq!(
5502            effective_containment_floor(None, Some(Scoped), Some(Advisory)),
5503            Scoped
5504        );
5505
5506        // All three present: max wins from every slot, and all-equal is a fixed point.
5507        assert_eq!(
5508            effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
5509            Sealed
5510        );
5511        assert_eq!(
5512            effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
5513            Sealed
5514        );
5515        assert_eq!(
5516            effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
5517            Sealed
5518        );
5519        assert_eq!(
5520            effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
5521            Scoped
5522        );
5523        assert_eq!(
5524            effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
5525            Advisory
5526        );
5527    }
5528
5529    #[test]
5530    fn manifest_parses_each_declared_containment_class() {
5531        for class in ContainmentClass::ALL {
5532            let manifest = RuntimeManifest::from_yaml_str(&format!(
5533                r#"
5534name: cap
5535version: 0.0.1
5536capabilities:
5537  containment: {class}
5538"#
5539            ))
5540            .expect("a valid containment class parses");
5541
5542            assert_eq!(
5543                manifest.capabilities.unwrap().containment,
5544                Some(class),
5545                "capabilities.containment: {class}"
5546            );
5547        }
5548    }
5549
5550    #[test]
5551    fn manifest_without_containment_key_declares_nothing() {
5552        // Absent `capabilities:` block entirely.
5553        let manifest = RuntimeManifest::from_yaml_str(
5554            r#"
5555name: cap
5556version: 0.0.1
5557"#,
5558        )
5559        .unwrap();
5560        assert!(manifest.capabilities.is_none());
5561
5562        // Present `capabilities:` block that simply omits the key: still None, never a
5563        // silently-defaulted Advisory floor stored as if the operator had written it.
5564        let manifest = RuntimeManifest::from_yaml_str(
5565            r#"
5566name: cap
5567version: 0.0.1
5568capabilities:
5569  network:
5570    allow:
5571      - https://api.example.com
5572"#,
5573        )
5574        .unwrap();
5575        assert_eq!(manifest.capabilities.unwrap().containment, None);
5576    }
5577
5578    #[test]
5579    fn manifest_rejects_an_unknown_containment_class() {
5580        let err = RuntimeManifest::from_yaml_str(
5581            r#"
5582name: cap
5583version: 0.0.1
5584capabilities:
5585  containment: paranoid
5586"#,
5587        )
5588        .expect_err("an unknown containment class is rejected at parse time");
5589
5590        match err {
5591            RuntimeManifestError::InvalidCapabilities { field, message } => {
5592                assert_eq!(field, "capabilities.containment");
5593                assert_eq!(
5594                    message,
5595                    "must be one of: advisory, scoped, sealed; got 'paranoid'"
5596                );
5597            }
5598            other => panic!("expected InvalidCapabilities, got {other:?}"),
5599        }
5600    }
5601}