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::{
8    manifest_path::MANIFEST_FILENAME,
9    trace_capture::{resolve_trace_capture, TraceCapture},
10    unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
11};
12
13// ── Lifecycle types ───────────────────────────────────────────────────────────
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum TaskAcceptance {
18    None,
19    #[default]
20    Single,
21    Queue,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum AfterTask {
27    #[default]
28    Exit,
29    Sleep,
30}
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ConversationMode {
35    #[default]
36    Stateless,
37    Threaded,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LifecycleConfig {
42    pub task_acceptance: TaskAcceptance,
43    pub after_task: AfterTask,
44    pub queue_depth: usize,
45    /// If set, request-input calls that receive no follow-up within this many seconds
46    /// transition the task to "failed" with reason "input-timeout". Absent = wait indefinitely.
47    pub input_timeout_secs: Option<u64>,
48    /// Whether tasks sharing a contextId accumulate conversation history across turns.
49    /// Stateless (default): each task is fully independent; Threaded: history is loaded
50    /// and persisted per contextId within a session.
51    pub conversation_mode: ConversationMode,
52    /// Maximum times an `on-task-end` hook may reopen a single task (re-run its agent
53    /// loop with injected feedback). Defaults to 1 when absent. `0` disables reopening
54    /// entirely. Unlike `inference.max_turns`, `0` is a valid explicit value. Reopening
55    /// never grants turns past `inference.max_turns` — the two budgets share one
56    /// cumulative turn count.
57    pub max_task_reopens: u32,
58    /// How long a shell command runs in the foreground before it is demoted to the background.
59    /// Defaults to 10 seconds. A command that exits inside this window returns its output to the
60    /// turn as it always has; one that outruns it hands the turn a handle, keeps running, and
61    /// reports back later as a `completion`-origin task. `0` demotes at the first poll after the
62    /// spawn, so effectively every command detaches.
63    pub shell_grace_secs: u64,
64    /// The single bound on a delegation, covering both waits one can have: how long a sub-capsule
65    /// started by `delegate-task` is watched, and how long a plan `capsule` step waits for its
66    /// answer. Defaults to 600 seconds. Absent is a ceiling rather than an absence: a capsule that
67    /// never declares this one is still bounded, because an unbounded delegation leaves a wedged
68    /// sub-capsule running for as long as the capsule that started it. `0` gives up at the first
69    /// poll after the task is delivered, the same reading `shell_grace_secs: 0` has.
70    ///
71    /// Reaching the deadline ends the sub-capsule. A `delegate-task` caller is told in a
72    /// `completion`-origin task carrying `status: terminated` and a detail naming the bound; a plan
73    /// step's call returns `timed_out`. The clock starts when the sub-capsule reports itself ready,
74    /// not when its process is spawned, so a slow host does not spend this window on staging.
75    /// `MURMUR_DELEGATION_TIMEOUT_SECS` overrides this value when it names a positive number of
76    /// seconds.
77    pub delegation_deadline_secs: u64,
78}
79
80impl LifecycleConfig {
81    /// Whether a session under this lifecycle is still there to receive work that arrives after
82    /// the task which started it ended — a demoted shell command's completion, a delegation's
83    /// outcome.
84    ///
85    /// `queue` + `sleep` is the only combination that qualifies, and the delegation lane and the
86    /// shell lane must agree on it: a capsule warned about one and not the other would be told its
87    /// lifecycle is both adequate and inadequate for the same reason.
88    pub fn can_receive_background_tasks(&self) -> bool {
89        self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
90    }
91}
92
93impl Default for LifecycleConfig {
94    fn default() -> Self {
95        Self {
96            task_acceptance: TaskAcceptance::Single,
97            after_task: AfterTask::Exit,
98            queue_depth: 1,
99            input_timeout_secs: None,
100            conversation_mode: ConversationMode::Stateless,
101            max_task_reopens: 1,
102            shell_grace_secs: 10,
103            delegation_deadline_secs: 600,
104        }
105    }
106}
107
108/// Lifecycle override: allows a parent (mur-roost) or CLI flag to constrain
109/// the lifecycle down from what the manifest declares.
110#[derive(Debug, Clone, Default)]
111pub struct LifecycleOverride {
112    pub task_acceptance: Option<TaskAcceptance>,
113    pub after_task: Option<AfterTask>,
114}
115
116/// Artifact taxonomy for entries declared in a capsule manifest.
117///
118/// Variant meanings:
119///
120/// - `Tool`: agent-callable tool (WASM component or native binary). Visible in MURMUR.md and the
121///   LLM tool list. Implementation details (wasm vs native) are declared in the artifact's own
122///   murmur.yaml via `implementation:`.
123/// - `Driver`: inference driver implemented as a WASM component. The runtime calls it as a
124///   processor. Hidden from the LLM.
125/// - `Hook`: event-triggered artifact implemented as a WASM component. The runtime calls it at
126///   lifecycle points; behavioral contract (binding, execution_mode, commit_policy) is declared
127///   in the artifact's own murmur.yaml. Hidden from the LLM.
128/// - `Skill`: documentation artifact; no executable binary. Visible in the LLM tool inventory.
129///   Invoking a skill by name returns its `skill.md` content as the tool result (just-in-time
130///   injection). No WASM/native dispatch occurs. A skill bound via
131///   `inference.system_prompt_artifact` is excluded from the callable inventory because it is
132///   already part of the system prompt.
133#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
134#[serde(rename_all = "lowercase")]
135pub enum ArtifactRuntime {
136    Tool,
137    Driver,
138    Hook,
139    Skill,
140}
141
142impl ArtifactRuntime {
143    /// Whether artifacts of this role appear in the LLM's tool inventory.
144    ///
145    /// Deliberately an exhaustive match with one arm per variant and no wildcard: a new
146    /// `ArtifactRuntime` variant must state its own visibility here, so adding a role that
147    /// should be LLM-visible cannot silently default to hidden.
148    pub fn is_llm_visible(&self) -> bool {
149        match self {
150            ArtifactRuntime::Tool => true,
151            ArtifactRuntime::Skill => true,
152            ArtifactRuntime::Driver => false,
153            ArtifactRuntime::Hook => false,
154        }
155    }
156
157    pub fn as_str(&self) -> &'static str {
158        match self {
159            ArtifactRuntime::Tool => "tool",
160            ArtifactRuntime::Driver => "driver",
161            ArtifactRuntime::Hook => "hook",
162            ArtifactRuntime::Skill => "skill",
163        }
164    }
165}
166
167/// How a `runtime: tool` artifact is implemented.
168///
169/// Declared via `implementation:` in the artifact's own murmur.yaml.
170/// Defaults to `Wasm` when the field is absent.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum ArtifactImplementation {
173    Wasm,
174    Native,
175}
176
177/// Read the `implementation:` field from an artifact's murmur.yaml.
178/// Defaults to `Wasm` when the field is absent or unparseable.
179pub fn read_tool_implementation(
180    artifact_manifest_path: &Path,
181) -> Result<ArtifactImplementation, RuntimeManifestError> {
182    let content =
183        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
184            path: artifact_manifest_path.display().to_string(),
185            source,
186        })?;
187    Ok(parse_tool_implementation_from_yaml(&content))
188}
189
190/// Parse `implementation:` from an in-memory manifest YAML string.
191/// Defaults to `Wasm` when the field is absent or unparseable.
192pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
193    serde_yaml::from_str::<serde_yaml::Value>(yaml)
194        .map(|v| {
195            v.as_mapping()
196                .and_then(|m| m.get(serde_yaml::Value::String("implementation".to_string())))
197                .and_then(serde_yaml::Value::as_str)
198                .map(|s| match s {
199                    "native" => ArtifactImplementation::Native,
200                    _ => ArtifactImplementation::Wasm,
201                })
202                .unwrap_or(ArtifactImplementation::Wasm)
203        })
204        .unwrap_or(ArtifactImplementation::Wasm)
205}
206
207// ── Hook configuration types ──────────────────────────────────────────────────
208
209/// Which lifecycle event(s) a hook binds to.
210///
211/// Declared via `binding:` in the artifact's own murmur.yaml.
212/// Defaults to `All` when the field is absent (receives all session events).
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum HookBinding {
215    /// `"on-stage"` — fires during `stage_session`; always blocking.
216    OnStage,
217    /// `"on-session-start"` — fires once before the first inference turn.
218    OnSessionStart,
219    /// `"on-task-start"` — fires once per task, before that task's first inference turn.
220    OnTaskStart,
221    /// `"on-inference"` — fires after each inference response is parsed.
222    OnInference,
223    /// `"on-tool-call"` — fires after each model-requested tool invocation.
224    OnToolCall,
225    /// `"on-shell"` — fires after each shell command returns.
226    OnShell,
227    /// `"on-compaction"` — fires when the session token threshold is reached.
228    OnCompaction,
229    /// `"on-task-end"` — fires once per task, after that task's agent loop returns.
230    OnTaskEnd,
231    /// `"on-session-end"` — fires at session teardown.
232    OnSessionEnd,
233    /// No `binding:` field — receives all session events (not on-stage).
234    All,
235}
236
237impl HookBinding {
238    /// The manifest spelling of this binding, which is also the WIT lifecycle function
239    /// name the runtime dispatches to — the key `capsule-runtime`'s honored-arm table is
240    /// written against. [`HookBinding::All`] has no spelling of its own (it is what an
241    /// omitted `binding:` means), so it renders as `"all"` for diagnostics only.
242    #[must_use]
243    pub fn as_str(&self) -> &'static str {
244        match self {
245            Self::OnStage => "on-stage",
246            Self::OnSessionStart => "on-session-start",
247            Self::OnTaskStart => "on-task-start",
248            Self::OnInference => "on-inference",
249            Self::OnToolCall => "on-tool-call",
250            Self::OnShell => "on-shell",
251            Self::OnCompaction => "on-compaction",
252            Self::OnTaskEnd => "on-task-end",
253            Self::OnSessionEnd => "on-session-end",
254            Self::All => "all",
255        }
256    }
257}
258
259/// Whether the runtime waits for the hook result.
260///
261/// Declared via `execution_mode:` in the artifact's own murmur.yaml.
262/// Defaults to `Blocking` when the field is absent.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum HookExecutionMode {
265    /// `"blocking"` — runtime waits for result before proceeding.
266    Blocking,
267    /// `"async"` — runtime proceeds immediately; result not committed.
268    Async,
269}
270
271/// What the runtime does with a successful hook output.
272///
273/// Declared via `commit_policy:` in the artifact's own murmur.yaml.
274/// Defaults to `None` when the field is absent.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum HookCommitPolicy {
277    /// `"none"` — output discarded.
278    None,
279    /// `"replace-context"` — runtime replaces conversation history. Valid only with on-compaction.
280    ReplaceContext,
281    /// `"write-manifests"` — runtime writes tool manifests to workdir/tools/. Valid only with on-stage.
282    WriteManifests,
283    /// `"reopen-task"` — runtime re-runs the task's agent loop with the hook's feedback.
284    /// Valid only with on-task-end.
285    ReopenTask,
286    /// `"seed-context"` — runtime places the hook's messages at the head of the task's
287    /// first message list, under the `context.seed_budget` ceiling. Valid only with
288    /// on-task-start.
289    SeedContext,
290    /// `"deny"` — the hook decides, immediately before the call is dispatched, whether the
291    /// call happens at all. Valid only with on-shell and on-tool-call, and only with an
292    /// explicit `binding:` naming one of them.
293    ///
294    /// The only policy that *subtracts*: every other value commits something the hook
295    /// produced, this one refuses something the capsule asked for. It narrows the manifest's
296    /// grant and can never widen it.
297    Deny,
298}
299
300impl HookCommitPolicy {
301    /// The manifest spelling of this policy, for diagnostics.
302    #[must_use]
303    pub fn as_str(&self) -> &'static str {
304        match self {
305            Self::None => "none",
306            Self::ReplaceContext => "replace-context",
307            Self::WriteManifests => "write-manifests",
308            Self::ReopenTask => "reopen-task",
309            Self::SeedContext => "seed-context",
310            Self::Deny => "deny",
311        }
312    }
313}
314
315/// What the runtime does with a lifecycle event for an `execution_mode: async` hook
316/// whose job queue is already full.
317///
318/// Declared via `on_overflow:` on the hook's entry in the **capsule operator's own**
319/// manifest — not in the hook artifact's bundled murmur.yaml, because keeping up with
320/// the agent loop is the operator's trade-off, not the hook author's. Inert on a hook
321/// that turns out to be `execution_mode: blocking` (a blocking hook is never queued);
322/// the operator-manifest parser cannot tell the two apart, since the mode lives in the
323/// artifact's own manifest and is only known at staging time.
324#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
325#[serde(rename_all = "snake_case")]
326pub enum HookOverflowPolicy {
327    /// `"drop"` (the default) — discard the event and count it. The agent loop never
328    /// waits on a slow hook; telemetry is lossy under sustained overload, and the loss
329    /// is reported rather than silent.
330    #[default]
331    Drop,
332    /// `"block"` — the agent loop waits for the hook's worker to make room. No event is
333    /// lost, at the cost of putting a slow hook back on the critical path.
334    Block,
335}
336
337impl HookOverflowPolicy {
338    /// The manifest spelling of this policy, for diagnostics.
339    #[must_use]
340    pub fn as_str(&self) -> &'static str {
341        match self {
342            Self::Drop => "drop",
343            Self::Block => "block",
344        }
345    }
346}
347
348/// Behavioral contract for a hook artifact, read from its own murmur.yaml.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct HookConfig {
351    pub binding: HookBinding,
352    pub execution_mode: HookExecutionMode,
353    pub commit_policy: HookCommitPolicy,
354}
355
356impl Default for HookConfig {
357    fn default() -> Self {
358        Self {
359            binding: HookBinding::All,
360            execution_mode: HookExecutionMode::Blocking,
361            commit_policy: HookCommitPolicy::None,
362        }
363    }
364}
365
366/// The one `commit_policy` a hook bound to `binding` can actually have committed, or
367/// `None` when no `commit_policy` value other than `none` is ever honored for it.
368///
369/// This is the manifest-side mirror of `capsule-runtime`'s `HONORED_OUTPUT_ARM` table,
370/// which decides — keyed on the same lifecycle function names [`HookBinding::as_str`]
371/// produces — which `hook-output` arm the runtime commits for each event. Two bindings
372/// return `None` for different reasons, and the distinction matters:
373///
374/// - `on-session-start` and `on-session-end` honor no arm at all; every output from them
375///   is discarded.
376/// - `on-tool-call` and `on-shell` honor `deny`, and only at their decision-point dispatch
377///   — the one that runs before the call. Their post-call observation dispatch honors
378///   nothing, which is why the runtime's table is keyed on the dispatch phase as well as
379///   the event name.
380/// - `on-inference` *does* honor an arm (`artifact`), but that arm has no
381///   `commit_policy` spelling — [`HookCommitPolicy`] has no `Artifact` variant — so no
382///   non-`none` policy is declarable for it either.
383///
384/// [`HookBinding::All`] (an omitted `binding:`) also returns `None`, and that is a third
385/// reason again: an `All`-bound hook is dispatched to *every* event, so there is no single
386/// honored policy to name. It is deliberately **not** validated against this function —
387/// [`parse_hook_config_from_yaml`] accepts every `commit_policy` for it, with the single
388/// exception of [`HookCommitPolicy::Deny`], which that function rejects outright for `All`.
389#[must_use]
390pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
391    match binding {
392        HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
393        HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
394        HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
395        HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
396        HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
397        HookBinding::OnSessionStart
398        | HookBinding::OnInference
399        | HookBinding::OnSessionEnd
400        | HookBinding::All => None,
401    }
402}
403
404/// Read a hook's behavioral contract from its murmur.yaml at the given path.
405pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
406    let content =
407        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
408            path: artifact_manifest_path.display().to_string(),
409            source,
410        })?;
411    parse_hook_config_from_yaml(&content).map_err(|msg| {
412        RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
413    })
414}
415
416/// Parse hook behavioral contract from an in-memory manifest YAML string.
417///
418/// `commit_policy: deny` is the one policy an omitted `binding:` cannot carry. Every other
419/// policy is accepted for [`HookBinding::All`] because an `All`-bound hook reaches every
420/// event and the runtime simply discards what a given event does not honor. `deny` is not
421/// discardable in that way: it is answered at a *decision point* standing in front of a call,
422/// and an `All`-bound hook would be asked to decide on shell and tool calls it was never
423/// written to judge — with every failure to answer, including a trap or a timeout, refusing
424/// the call. The binding is therefore required to name which of the two events the hook gates.
425pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
426    let v: serde_yaml::Value =
427        serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
428
429    let binding = v
430        .as_mapping()
431        .and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
432        .and_then(serde_yaml::Value::as_str)
433        .map(|s| match s {
434            "on-stage" => Ok(HookBinding::OnStage),
435            "on-session-start" => Ok(HookBinding::OnSessionStart),
436            "on-task-start" => Ok(HookBinding::OnTaskStart),
437            "on-inference" => Ok(HookBinding::OnInference),
438            "on-tool-call" => Ok(HookBinding::OnToolCall),
439            "on-shell" => Ok(HookBinding::OnShell),
440            "on-compaction" => Ok(HookBinding::OnCompaction),
441            "on-task-end" => Ok(HookBinding::OnTaskEnd),
442            "on-session-end" => Ok(HookBinding::OnSessionEnd),
443            other => Err(format!(
444                "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
445                 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
446            )),
447        })
448        .transpose()?
449        .unwrap_or(HookBinding::All);
450
451    let execution_mode = v
452        .as_mapping()
453        .and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
454        .and_then(serde_yaml::Value::as_str)
455        .map(|s| match s {
456            "blocking" => Ok(HookExecutionMode::Blocking),
457            "async" => Ok(HookExecutionMode::Async),
458            other => Err(format!(
459                "unknown execution_mode '{other}'; expected: blocking, async"
460            )),
461        })
462        .transpose()?
463        .unwrap_or(HookExecutionMode::Blocking);
464
465    let commit_policy = v
466        .as_mapping()
467        .and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
468        .and_then(serde_yaml::Value::as_str)
469        .map(|s| match s {
470            "none" => Ok(HookCommitPolicy::None),
471            "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
472            "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
473            "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
474            "seed-context" => Ok(HookCommitPolicy::SeedContext),
475            "deny" => Ok(HookCommitPolicy::Deny),
476            other => Err(format!(
477                "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
478                 reopen-task, seed-context, deny"
479            )),
480        })
481        .transpose()?
482        .unwrap_or(HookCommitPolicy::None);
483
484    // Validation
485    if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
486        return Err(format!(
487            "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
488             (got '{}')",
489            commit_policy.as_str()
490        ));
491    }
492    if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
493        return Err(
494            "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
495             'on-stage'"
496                .to_string(),
497        );
498    }
499    // The one policy `All` cannot carry: a hook that does not name which of the two gated
500    // events it decides on would be dispatched at decision points it was never written for,
501    // and every non-`none` answer there refuses a call.
502    if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
503        return Err(
504            "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
505             a hook with no binding: is dispatched at every event, including decision points \
506             it was not written to decide"
507                .to_string(),
508        );
509    }
510    // `binding` is the single source of truth for what the runtime commits; a declared
511    // `commit_policy` the binding can never honor is a mistake that is fully knowable here,
512    // at staging time, rather than mid-session as a `hook_dispatch_error`. An omitted
513    // `binding:` (`All`) reaches every event, so every other policy stays valid for it.
514    if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
515        let honored = commit_policy_for_binding(&binding);
516        if honored.as_ref() != Some(&commit_policy) {
517            let note = if binding == HookBinding::OnInference {
518                " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
519            } else {
520                ""
521            };
522            return Err(format!(
523                "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
524                 commit_policy '{}'{note}",
525                commit_policy.as_str(),
526                binding.as_str(),
527                binding.as_str(),
528                honored.as_ref().map_or("none", HookCommitPolicy::as_str)
529            ));
530        }
531    }
532
533    Ok(HookConfig {
534        binding,
535        execution_mode,
536        commit_policy,
537    })
538}
539
540#[derive(Debug, Clone, PartialEq, Eq)]
541pub struct RuntimeManifest {
542    pub name: String,
543    pub version: String,
544    pub artifacts: Vec<RuntimeArtifact>,
545    pub capabilities: Option<Capabilities>,
546    pub inference: Option<InferenceConfig>,
547    pub context: Option<ContextConfig>,
548    pub observability: Option<ObservabilityConfig>,
549    pub trace: Option<TraceConfig>,
550    pub network: Option<NetworkConfig>,
551    pub lifecycle: Option<LifecycleConfig>,
552    /// Read-only views onto the workdir that the operator opens to processes outside the capsule.
553    /// `None` means nothing is exported and every request to the resource plane is denied.
554    pub exports: Option<Exports>,
555    /// Pins the mur runtime version required by this capsule.
556    /// Used by `mur deploy` to select the binary version to install on the VM,
557    /// and by `mur run` to warn on version mismatch.
558    /// If absent, the running mur binary's version is used.
559    pub mur_version: Option<String>,
560    /// Every key the manifest declared that this build does not recognize, captured by each
561    /// `Raw*` block's `#[serde(flatten)]` overflow map instead of dropped.
562    ///
563    /// Ordered by the walk rather than by the manifest's own line order: a containing block's own
564    /// keys precede the blocks nested inside it, and one block's keys are alphabetical.
565    ///
566    /// Empty for a manifest written entirely in keys this build knows. Never a reason to refuse a
567    /// manifest — it is reported as `W-SEC-019` by
568    /// [`crate::unknown_manifest_keys::warn_on_unknown_manifest_keys`] and nothing else reads it.
569    pub unknown_keys: Vec<UnknownManifestKey>,
570}
571
572impl RuntimeManifest {
573    pub fn effective_lifecycle(&self) -> LifecycleConfig {
574        self.lifecycle.clone().unwrap_or_default()
575    }
576}
577
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub struct NetworkConfig {
580    /// Internal port the capsule expects to listen on. Default 14159 when absent.
581    pub internal_port: Option<u16>,
582}
583
584// ── Exports ───────────────────────────────────────────────────────────────────
585
586/// Per-file read ceiling applied when `exports.files.max_bytes` is absent: 10Mi.
587pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
588
589/// What the operator discloses to processes *outside* the capsule, declared under the top-level
590/// `exports:` key.
591///
592/// A sibling of `capabilities:` rather than a member of it: a capability is something the guest
593/// holds and an export is a disclosure the operator makes. Declaring one
594/// gives the agent nothing it did not already have — it widens only the operator's reach inward,
595/// which is why it never enters the achieved-containment computation.
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct Exports {
598    /// The declared read-only file surface. `None` — an `exports:` block with no `files:` —
599    /// means the resource plane is not declared and every request to it is denied.
600    pub files: Option<FileExport>,
601    /// The declared peer-handoff surface. A separate authoriser from [`Self::files`], over a
602    /// separate subtree: declaring one grants nothing about the other, and a capsule may declare
603    /// either, both or neither. `None` means the capsule mints no handles and its peer plane
604    /// answers `no_peer_plane`.
605    pub peer_files: Option<PeerFilesExport>,
606}
607
608/// The `exports.files` block: one subtree of the workdir, readable and nothing else.
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct FileExport {
611    /// Subtree of the capsule's accessible workdir this export opens, verbatim as declared
612    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
613    /// directory belongs to the runtime, which is the only component that may decide a path is
614    /// inside the root.
615    ///
616    /// Not required to exist when the capsule launches — the agent may create it during a task.
617    pub root: String,
618    /// Required, with exactly one legal value. A required field with one value is what makes the
619    /// read-only posture an explicit operator statement rather than an unstated default, and
620    /// leaves a future write mode somewhere to be declared rather than somewhere to leak in.
621    pub mode: ExportMode,
622    /// Per-file read ceiling in bytes, defaulting to [`DEFAULT_EXPORT_MAX_BYTES`]. Never an
623    /// aggregate budget: each file is judged on its own size, and a subtree whose total exceeds
624    /// this is still listed in full.
625    pub max_bytes: u64,
626}
627
628/// Handle lifetime applied when `exports.peer_files.max_ttl` is absent and the capsule is
629/// ephemeral (`lifecycle.after_task: exit`): 1h.
630pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
631
632/// The largest `exports.peer_files.max_ttl` a *persistent* capsule
633/// (`lifecycle.after_task: sleep`) may declare: 15m.
634///
635/// An ephemeral capsule needs no ceiling because teardown destroys the minting key and with it
636/// every outstanding handle. A persistent one has withdrawn that bound, so the declared lifetime
637/// becomes the only one — and a handle is not a durability mechanism. Enforced by the runtime,
638/// not here, because the rule reads `lifecycle.after_task` and this parser sees one block at a
639/// time.
640pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
641
642/// Per-file read ceiling applied when `exports.peer_files.max_bytes` is absent: 10Mi.
643pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
644
645/// The `exports.peer_files` block: the one subtree a `share-file` handle may name.
646///
647/// Deliberately not a mode of [`FileExport`]. The operator plane addresses files by path and
648/// enumerates them; this plane has no `list` verb and no path addressing at all, and its audience
649/// is another capsule rather than the operator. Two authorisers over two subtrees, so opening one
650/// never widens the other.
651#[derive(Debug, Clone, PartialEq, Eq)]
652pub struct PeerFilesExport {
653    /// Subtree of the capsule's accessible workdir a handle may name, verbatim as declared
654    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
655    /// directory belongs to the runtime. Not required to exist when the capsule launches.
656    pub root: String,
657    /// The declared `max_ttl`, in seconds, or `None` when the manifest declared none.
658    ///
659    /// Kept undefaulted because the two ephemerality cases answer an absent value differently:
660    /// an ephemeral capsule falls back to [`DEFAULT_PEER_HANDLE_TTL_SECS`], and a persistent one
661    /// refuses to launch. Substituting the default here would erase the difference before
662    /// anything could act on it. See [`Self::effective_max_ttl_secs`].
663    pub max_ttl_secs: Option<u64>,
664    /// Per-file ceiling on a redeemed read, defaulting to [`DEFAULT_PEER_FILES_MAX_BYTES`].
665    /// Per file, never an aggregate budget — the same terms as [`FileExport::max_bytes`], and a
666    /// separate value from it.
667    pub max_bytes: u64,
668}
669
670impl PeerFilesExport {
671    /// The handle lifetime ceiling this export actually applies: the declared `max_ttl`, or the
672    /// ephemeral default when none was declared.
673    ///
674    /// Correct for a persistent capsule too, because one that declared no `max_ttl` never
675    /// launches — the runtime refuses it before a plane is built.
676    pub fn effective_max_ttl_secs(&self) -> u64 {
677        self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
678    }
679}
680
681/// The `capabilities.peer_fetch` block: which peers this capsule may redeem a handle against.
682///
683/// A sibling of [`NetworkCapabilities`] rather than a member of it. Fetching a peer's bytes lands
684/// a file in this capsule's own workdir, so it is an ingestion path and a prompt-injection
685/// surface, and it gets its own operator control. Declaring a destination here does not widen
686/// `capabilities.network.allow`, and a destination allowed there is not redeemable unless it is
687/// also named here.
688#[derive(Debug, Clone, PartialEq, Eq)]
689pub struct PeerFetchCapabilities {
690    /// Network destinations a handle may be redeemed against. Required and non-empty when the
691    /// block is present: an empty list is a parse error, never a silent deny. Same syntax as
692    /// `capabilities.network.allow`, and matched by the same runtime rule matcher.
693    pub allow: Vec<String>,
694}
695
696/// The accepted spelling of every duration in the manifest, stated once so the parser and the
697/// error it produces cannot drift apart.
698pub const DURATION_ACCEPTED_FORM: &str =
699    "must be a duration: an integer, optionally suffixed s/m/h/d";
700
701/// Parses `90`, `30s`, `15m`, `1h`, `14d` into a whole number of seconds.
702///
703/// A bare integer is seconds. Suffixes are lowercase and single-character; `5 minutes` and `30S`
704/// are rejected rather than guessed at, on the same terms as [`parse_byte_size`]. Returns the
705/// accepted-form sentence as its error so every call site reports the same rule.
706pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
707    let trimmed = input.trim();
708    let (digits, multiplier) = match trimmed.strip_suffix('s') {
709        Some(digits) => (digits, 1u64),
710        None => match trimmed.strip_suffix('m') {
711            Some(digits) => (digits, 60),
712            None => match trimmed.strip_suffix('h') {
713                Some(digits) => (digits, 3600),
714                None => match trimmed.strip_suffix('d') {
715                    Some(digits) => (digits, 86_400),
716                    None => (trimmed, 1),
717                },
718            },
719        },
720    };
721    // Not trimmed again: the whole input was trimmed above, so a space surviving here is *inside*
722    // the value (`5 minutes`, `30 m`), which is not a spelling this accepts.
723    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
724        return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
725    }
726    digits
727        .parse::<u64>()
728        .ok()
729        .and_then(|value| value.checked_mul(multiplier))
730        .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
731}
732
733#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
734pub enum ExportMode {
735    #[serde(rename = "read-only")]
736    ReadOnly,
737}
738
739impl ExportMode {
740    pub fn as_str(self) -> &'static str {
741        match self {
742            ExportMode::ReadOnly => "read-only",
743        }
744    }
745}
746
747impl std::fmt::Display for ExportMode {
748    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749        f.write_str(self.as_str())
750    }
751}
752
753/// The accepted spelling of every byte count in the manifest, stated once so the parser and the
754/// error it produces cannot drift apart.
755pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
756
757/// Parses `4096`, `1Ki`, `10Mi`, `2Gi` into a byte count.
758///
759/// Binary suffixes only, and case-sensitively: `10MB` is rejected rather than guessed at, because
760/// a manifest that means 10 000 000 and a manifest that means 10 485 760 must not both parse.
761/// Returns the accepted-form sentence as its error so every call site reports the same rule.
762pub fn parse_byte_size(input: &str) -> Result<u64, String> {
763    let trimmed = input.trim();
764    let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
765        Some(digits) => (digits, 1024u64),
766        None => match trimmed.strip_suffix("Mi") {
767            Some(digits) => (digits, 1024 * 1024),
768            None => match trimmed.strip_suffix("Gi") {
769                Some(digits) => (digits, 1024 * 1024 * 1024),
770                None => (trimmed, 1),
771            },
772        },
773    };
774    // Deliberately not trimmed again: the whole input was trimmed above, so any space left here
775    // is *inside* the value (`10 Mi`), which is not a spelling this accepts.
776    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
777        return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
778    }
779    digits
780        .parse::<u64>()
781        .ok()
782        .and_then(|value| value.checked_mul(multiplier))
783        .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
784}
785
786#[derive(Debug, Clone, PartialEq, Eq)]
787pub struct RuntimeArtifact {
788    pub name: String,
789    pub version: String,
790    pub runtime: ArtifactRuntime,
791    /// Optional local source path. When set, the runtime resolves the artifact directly from
792    /// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
793    /// points at a `skill.md` file or at a directory containing one (case-insensitive).
794    /// Relative paths resolve against the directory containing `murmur.yaml`.
795    /// Only permitted when `local_source` is true.
796    pub source: Option<String>,
797    /// Whether this artifact may be resolved from a local `source:` path. Declared via
798    /// `local_source:` in the project manifest's artifact entry; when absent it defaults to
799    /// true for `runtime: skill` and false for every other role, which is exactly the role-based
800    /// gate this field replaced.
801    pub local_source: bool,
802    /// Whether this artifact's payload may be bound as the system prompt via
803    /// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
804    /// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
805    /// false for every other role.
806    pub prompt_payload: bool,
807    /// Per-artifact capability grant, declared via `capabilities:` on this entry in the
808    /// **capsule operator's own** manifest. Recognized on `runtime: hook`, `runtime: tool`,
809    /// and `runtime: driver` entries; a `runtime: skill` entry carrying the key is rejected
810    /// at parse time, because nothing would enforce it and a silently-ignored grant reads
811    /// like a scoped artifact.
812    ///
813    /// What `None` (the key absent) means depends on the role, deliberately:
814    /// - `hook`: full default-deny — no network and no preopened directory.
815    /// - `tool`/`driver`: the unchanged capsule-wide ceiling. A declared block *narrows*
816    ///   from that ceiling and can never widen past it.
817    ///
818    /// Deliberately never sourced from the artifact's own bundled `murmur.yaml` (see
819    /// [`parse_hook_config_from_yaml`], which parses that file and knows nothing about
820    /// capabilities) — an artifact pulled from a registry cannot self-grant.
821    ///
822    /// Reuses the whole [`Capabilities`] type for vocabulary consistency with the
823    /// capsule-wide block, but only `network` and `filesystem` are consumed by the runtime
824    /// per-artifact; the other sub-blocks are inert here.
825    pub capabilities: Option<Capabilities>,
826    /// What the runtime does when this hook's async job queue is full, declared via
827    /// `on_overflow:` on this entry. Defaults to [`HookOverflowPolicy::Drop`] when absent.
828    /// Accepted only on `runtime: hook` entries — every other role is rejected at parse
829    /// time, since nothing would ever consult it there.
830    pub on_overflow: HookOverflowPolicy,
831    /// Operator-authored configuration for this artifact alone, declared via `config:` on this
832    /// entry. Held as the raw YAML node: this crate accepts any mapping, and the runtime is what
833    /// lowers it to the JSON delivered as `MURMUR_ARTIFACT_CONFIG` and refuses a shape that
834    /// cannot travel that way.
835    ///
836    /// `None` is the key being absent, which means the variable is absent from the guest
837    /// environment. `Some(Value::Null)` is `config:` written with nothing under it — a
838    /// declaration that carries nothing, refused by the runtime rather than treated as absent.
839    ///
840    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
841    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
842    /// [`Self::capabilities`]. Operator-sourced only, and never read from the artifact's own
843    /// bundled `murmur.yaml` — an artifact pulled from a registry cannot configure itself.
844    ///
845    /// Plaintext in a file that is also an audit record: secrets belong in `${VAR}` references
846    /// and the credential-stripping path, not here.
847    pub config: Option<serde_yaml::Value>,
848}
849
850#[derive(Debug, Clone, PartialEq, Eq)]
851pub struct InferenceDriver {
852    pub artifact: String,
853    pub config: Option<String>,
854}
855
856/// What `inference.api_key` names, as the manifest wrote it. Parsing never resolves it: the
857/// runtime looks the value up at staging, where it knows where credentials are kept.
858#[derive(Clone, PartialEq, Eq)]
859pub enum ApiKeyReference {
860    /// `${NAME}`: a credential name, looked up in the global config's `credentials:` map and
861    /// then in the launching environment.
862    Environment(String),
863    /// Any other non-empty value, used verbatim as the key.
864    Literal(String),
865}
866
867impl std::fmt::Debug for ApiKeyReference {
868    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
869        match self {
870            Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
871            Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
872        }
873    }
874}
875
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct InferenceConfig {
878    pub transport: String,
879    /// HTTP endpoint for the WASM driver. Present for `transport: http`, absent for `transport: process`.
880    pub endpoint: Option<String>,
881    pub model: String,
882    pub api_key: Option<ApiKeyReference>,
883    /// WASM driver artifact. Present for `transport: http`, absent for `transport: process`.
884    pub driver: Option<InferenceDriver>,
885    /// CLI binary to spawn. Present for `transport: process`, absent for `transport: http`.
886    pub command: Option<String>,
887    pub compaction: Option<CompactionConfig>,
888    pub system_prompt: Option<String>,
889    pub system_prompt_file: Option<String>,
890    /// Name of a skill artifact declared in `artifacts:` whose `skill.md` is used as the system
891    /// prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
892    /// excluded from the callable tool inventory (it's already in the system prompt).
893    pub system_prompt_artifact: Option<String>,
894    /// Maximum LLM turns per capsule task. Defaults to 10 when absent in the manifest.
895    /// Enforced as a hard ceiling by the runtime.
896    pub max_turns: u32,
897    /// Maximum output tokens the model may generate per turn (`max_tokens` in the driver wire
898    /// payload). None means the manifest didn't set it; the runtime applies its own default.
899    /// `transport: http` only — rejected at parse time under `transport: process`.
900    ///
901    /// Unrelated to [`ContextConfig::max_tokens`], which is the session-wide token budget that
902    /// drives compaction. This one is a per-turn *output* cap.
903    pub max_tokens: Option<u32>,
904    /// `inference.max_session_tokens`: the most tokens — the runtime's own measured input plus
905    /// output, over every driver call the session makes — this session may spend. `None` sets no
906    /// session ceiling. `transport: http` only — rejected at parse time under `transport: process`,
907    /// where the CLI reaches its provider with its own credentials and murmur sees no spend.
908    pub max_session_tokens: Option<u64>,
909}
910
911#[derive(Debug, Clone, PartialEq)]
912pub struct CompactionConfig {
913    /// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
914    pub threshold: Option<f32>,
915    /// Model for compaction. None means use the primary inference model.
916    pub model: Option<String>,
917    /// System prompt for compaction. None means the compaction hook picks its own default.
918    /// Mutually exclusive with `system_prompt_file`.
919    pub system_prompt: Option<String>,
920    /// Path to a local file whose contents are used as the compaction system prompt,
921    /// resolved relative to the manifest directory when the session launches. Mutually
922    /// exclusive with `system_prompt`.
923    pub system_prompt_file: Option<String>,
924    /// When true, every committed compaction appends one JSON line recording the
925    /// replacement summary to `out/compaction-summaries.jsonl` in the session workdir.
926    /// None (the key absent) is equivalent to false: nothing is written.
927    pub dump_summaries: Option<bool>,
928}
929
930// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
931impl Eq for CompactionConfig {}
932
933/// `context.seed_budget` when the manifest leaves it out: a tenth of the window.
934/// Small enough that a seed cannot crowd out the task it precedes, large enough that a
935/// memory hook has room to say something.
936pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
937
938/// `context.seed_overflow_margin` when the manifest leaves it out. Slack above
939/// `seed_budget` within which an over-budget seed is trimmed rather than summarized —
940/// a few tokens over must not buy an inference call.
941pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
942
943#[derive(Debug, Clone, PartialEq)]
944pub struct ContextConfig {
945    /// Token budget for this session; None disables compaction.
946    pub max_tokens: Option<u32>,
947    /// Whether the runtime keeps a durable conversation record for this capsule. `true` unless
948    /// the manifest says `record: off`; `false` turns the mechanism off entirely, creating
949    /// nothing under `~/.murmur/conversations/`.
950    pub record: bool,
951    /// Directory segment under `~/.murmur/conversations/` this capsule's records live in.
952    /// `None` means the capsule name. Validated as one path segment by the runtime, on the same
953    /// terms as `capabilities.state.store`. Inert when [`Self::record`] is `false`.
954    pub record_store: Option<String>,
955    /// Fraction of [`Self::max_tokens`] an `on-task-start` hook's `seed-context` may
956    /// occupy, in `0.0..=1.0`. Always populated; [`DEFAULT_SEED_BUDGET`] when the key is
957    /// absent. Inert without `max_tokens`: a fraction of no ceiling is no ceiling, and a
958    /// seed with no ceiling is rejected rather than committed unbounded.
959    pub seed_budget: f32,
960    /// Slack above the seed budget, as a fraction of it, within which an over-budget seed
961    /// is trimmed from its front rather than handed to the compaction hook, in
962    /// `0.0..=1.0`. Always populated; [`DEFAULT_SEED_OVERFLOW_MARGIN`] when the key is
963    /// absent.
964    pub seed_overflow_margin: f32,
965    /// What bounds this capsule's conversation records. `None` — the `retain:` block was absent
966    /// — keeps every record whole and forever. Inert when [`Self::record`] is `false`.
967    pub retain: Option<ContextRetainConfig>,
968}
969
970// Both fractions are validated to 0.0..=1.0 at parse time, so NaN is impossible.
971impl Eq for ContextConfig {}
972
973#[derive(Debug, Clone, PartialEq, Eq)]
974pub struct ObservabilityConfig {
975    pub otel_endpoint: Option<String>,
976    pub eval: Option<EvalConfig>,
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
980pub struct TraceConfig {
981    /// How much of each turn's driver request this session's trace keeps — resolved from
982    /// `trace.capture`, or from the retired `trace.include_tool_output` alias, by
983    /// [`crate::trace_capture::resolve_trace_capture`].
984    pub capture: TraceCapture,
985    /// What bounds the session directories under the workdir. `None` — the `retain:` block was
986    /// absent — keeps every session forever; there is no default policy, because a mechanism
987    /// that deletes an operator's traces unless they opt out is the one default-allow in a
988    /// runtime that is default-deny everywhere else.
989    pub retain: Option<TraceRetainConfig>,
990}
991
992/// The `trace.retain` block: what bounds the set of session directories beside the running one.
993///
994/// Both keys are optional and ANDed — a session survives only if it is inside every limit
995/// declared. An empty block is a parse error, not a no-op: omitting `retain:` is how a capsule
996/// says "keep everything".
997#[derive(Debug, Clone, Copy, PartialEq, Eq)]
998pub struct TraceRetainConfig {
999    /// Newest session directories to keep, counting the running session itself. `None` means
1000    /// the count is unbounded. Never `Some(0)`: truncating the set to nothing would delete the
1001    /// running session's own trace, so it is refused at parse time.
1002    pub max_sessions: Option<u32>,
1003    /// Age beyond which a session directory is removed, in seconds, measured from the
1004    /// millisecond timestamp inside its own uuid-v7 `ses_` id. `None` means no age limit.
1005    pub max_age_secs: Option<u64>,
1006}
1007
1008/// The `context.retain` block: what bounds this capsule's conversation records.
1009///
1010/// Both keys are optional and ANDed. `max_messages` truncates the front of the record the
1011/// launch opens; `max_age_secs` removes a context directory this capsule owns and has not
1012/// written to inside the window.
1013#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1014pub struct ContextRetainConfig {
1015    /// Messages to keep at the tail of a record. `None` means the record grows without bound.
1016    /// Never `Some(0)`: truncating a record to nothing is `mur conversation rm`, not retention.
1017    pub max_messages: Option<u32>,
1018    /// Age beyond which a record this capsule owns is removed, in seconds, measured from the
1019    /// last write to its `conversation.jsonl`. `None` means no age limit.
1020    pub max_age_secs: Option<u64>,
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1024pub struct EvalConfig {
1025    #[serde(skip_serializing_if = "Option::is_none")]
1026    pub dataset_id: Option<String>,
1027    pub scorers: Vec<ScorerConfig>,
1028}
1029
1030#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1031#[serde(tag = "type", rename_all = "snake_case")]
1032pub enum ScorerConfig {
1033    ExitOk { name: String },
1034    MaxTurns { name: String, max: u32 },
1035    MaxTokens { name: String, max: u64 },
1036    ToolSequence { name: String, expected: Vec<String> },
1037    LlmJudge { name: String },
1038}
1039
1040#[derive(Debug, Clone, PartialEq, Eq)]
1041pub struct NetworkCapabilities {
1042    pub allow: Vec<String>,
1043    /// Whether the capsule's shell subprocess tree may create `AF_UNIX` sockets. `false` unless
1044    /// the manifest says otherwise: a local daemon socket — `/var/run/docker.sock` above all —
1045    /// is an unmediated path to host root, and the `allow` list above only governs IP
1046    /// destinations, so nothing else in this block constrains it. Declaring `true` is the
1047    /// deliberate, auditable widening for a capsule that genuinely needs a local daemon socket;
1048    /// it is capsule-wide and coarse (a domain, not a per-path allowlist). `AF_NETLINK` and
1049    /// `AF_PACKET` have no corresponding key and are always denied.
1050    pub unix_sockets: bool,
1051}
1052
1053#[derive(Debug, Clone, PartialEq, Eq)]
1054pub struct FilesystemCapabilities {
1055    pub scope: Option<String>,
1056    /// Whether anything written *into* the session workdir may be executed. `false` unless the
1057    /// manifest says otherwise, and that default is the whole point: with it, the workdir's own
1058    /// Landlock `PathBeneath` rule withholds the `Execute` right, so the kernel — evaluating the
1059    /// resolved path itself, with no userspace round trip — refuses to exec a binary the capsule
1060    /// produced, whatever it is named. That is what makes `capabilities.shell.allow` a complete
1061    /// and sound statement rather than a name-matching convention.
1062    ///
1063    /// `true` takes the `Execute` right back for compile-and-run workflows (a capsule that
1064    /// `gcc`/`cargo build`s inside its workdir and then runs the result). The cost is not
1065    /// negotiable and is not hidden: anything the capsule writes into the workdir can then run
1066    /// regardless of `shell.allow`, so the allowlist stops being an enforceable property of that
1067    /// capsule. A capsule declaring it can therefore never achieve
1068    /// [`ContainmentClass::Scoped`] — see `capsule_runtime::containment` — and pairing it with
1069    /// `capabilities.containment: scoped` is refused at launch.
1070    pub workdir_exec: bool,
1071    /// Workdir-relative subtrees the capsule may read but must not write, in the same vocabulary
1072    /// [`Self::scope`] uses. Empty when the key is absent, which is the whole workdir writable —
1073    /// the behaviour of every capsule that declares nothing here.
1074    ///
1075    /// Entries are trimmed and empty ones dropped at parse; path *shape* is judged by the
1076    /// runtime (`capsule_runtime::protected_paths`), exactly as `scope`'s shape is.
1077    pub read_only: Vec<String>,
1078}
1079
1080/// The `capabilities.task_io` block on a `runtime: hook` artifact entry — the operator's
1081/// grant of the `murmur:task-io/read` host import to that one hook. Recognized nowhere else:
1082/// on any other role, and in the capsule-wide block, it is rejected at parse time rather than
1083/// accepted and silently inert.
1084#[derive(Debug, Clone, PartialEq, Eq)]
1085pub struct TaskIoCapabilities {
1086    /// Whether this hook may read the in-scope task's input and result text. Never inferred:
1087    /// a `task_io:` block that omits `read:` is rejected, on the same terms as
1088    /// `capabilities.shell.interpreter_runtime[].dirs[].list_dir`.
1089    pub read: bool,
1090}
1091
1092/// The `capabilities.conversation` block on a `runtime: hook` artifact entry — the operator's
1093/// grant of the `murmur:conversation/read` host import to that one hook. Rejected at parse time
1094/// on any other artifact role; accepted but inert in the capsule-wide block, which warns
1095/// `W-SEC-016` at staging.
1096#[derive(Debug, Clone, PartialEq, Eq)]
1097pub struct ConversationCapabilities {
1098    /// Whether this hook may read the capsule's durable conversation record. Never inferred: a
1099    /// `conversation:` block that omits `read:` is rejected, exactly as `task_io:` is.
1100    pub read: bool,
1101}
1102
1103/// The `capabilities.plan` block — the operator's grant of the runtime-provided `submit-plan`
1104/// tool to the capsule's agent loop. Read from the capsule-wide block only: an artifact entry
1105/// declaring it grants nothing, because a plan is submitted by the agent and not by an artifact.
1106#[derive(Debug, Clone, PartialEq, Eq)]
1107pub struct PlanCapabilities {
1108    /// Whether this capsule's model may submit a plan for the runtime to schedule. Never
1109    /// inferred: a `plan:` block that omits `submit:` is rejected, exactly as `task_io:` is.
1110    pub submit: bool,
1111}
1112
1113#[derive(Debug, Clone, PartialEq, Eq)]
1114pub struct ShellCapabilities {
1115    pub allow: Vec<String>,
1116    pub strip_env: Option<Vec<String>>,
1117    pub baseline_env: Option<Vec<String>>,
1118    /// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
1119    /// scope to the exact host directories its import machinery needs *outside* the workdir
1120    /// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
1121    /// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
1122    /// This can only ever name specific directories with an explicit per-directory
1123    /// enumerability flag — it has no field that expands a whole install prefix.
1124    pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1125    /// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
1126    /// into a `sealed` capsule's composed root. Empty unless the manifest declares
1127    /// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
1128    /// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
1129    /// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
1130    pub staged_runtime: Vec<StagedRuntimeGrant>,
1131}
1132
1133/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
1134/// exact host directories outside the workdir its import machinery must reach. Declaring one
1135/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
1136/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
1137#[derive(Debug, Clone, PartialEq, Eq)]
1138pub struct InterpreterRuntimeGrant {
1139    /// A binary that MUST already appear in this same block's `allow`. This mechanism can only
1140    /// narrow filesystem access alongside an exec grant that already exists — it never itself
1141    /// grants exec.
1142    pub binary: String,
1143    /// The host directories to grant. Never empty (a grant with no directories is rejected at
1144    /// parse time).
1145    pub dirs: Vec<InterpreterRuntimeDir>,
1146}
1147
1148/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
1149#[derive(Debug, Clone, PartialEq, Eq)]
1150pub struct InterpreterRuntimeDir {
1151    /// An absolute host path (must start with `/`) outside the workdir.
1152    pub path: String,
1153    /// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
1154    /// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
1155    /// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
1156    /// directory itself cannot be listed. Never inferred: the author must write it explicitly.
1157    pub list_dir: bool,
1158}
1159
1160/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
1161/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
1162/// identifying which build that tree is.
1163///
1164/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
1165/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
1166/// happens to keep it — which couples the capsule to one host's directory layout and is why it
1167/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
1168/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
1169/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
1170/// is no composed root to stage into below that — and it is refused alongside an
1171/// `interpreter_runtime` grant for the same binary.
1172///
1173/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
1174/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
1175/// does not resolve, discover or version-sniff it.
1176#[derive(Debug, Clone, PartialEq, Eq)]
1177pub struct StagedRuntimeGrant {
1178    /// A binary that MUST already appear in this same block's `allow`. Like
1179    /// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
1180    /// where the runtime behind an existing exec grant comes from.
1181    pub binary: String,
1182    /// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
1183    /// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
1184    /// turn recorded keep resolving.
1185    pub source_path: String,
1186    /// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
1187    /// inferred from the tree's contents: it exists so a human can compare the declared pin across
1188    /// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
1189    /// an opaque string and does not parse it.
1190    pub pin: String,
1191}
1192
1193#[derive(Debug, Clone, PartialEq, Eq)]
1194pub struct SpawnCapabilities {
1195    pub allow: Vec<String>,
1196}
1197
1198/// Host environment variables a WASM guest may observe, from `capabilities.env`.
1199///
1200/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
1201/// `capabilities.shell.allow` this is not rejected at parse time.
1202#[derive(Debug, Clone, PartialEq, Eq, Default)]
1203pub struct EnvCapabilities {
1204    pub allow: Vec<String>,
1205}
1206
1207/// Per-guest execution limits from `capabilities.limits`.
1208///
1209/// Every field is `Option` because this type carries only what the manifest actually
1210/// declared — an omitted field stays `None` here and the runtime substitutes its own
1211/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
1212/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
1213/// instead of duplicating them into the manifest parser.
1214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1215pub struct ResourceLimits {
1216    /// Cap on a guest's linear-memory growth, in bytes.
1217    pub memory_bytes: Option<usize>,
1218    /// Cap on a guest's table growth, in elements.
1219    pub table_elements: Option<usize>,
1220    /// Cap on the number of instances a single store may create.
1221    pub instances: Option<usize>,
1222    /// Wall-clock budget for a single guest invocation, in seconds.
1223    pub deadline_seconds: Option<u64>,
1224}
1225
1226/// Host-process (OS-level) resource bounds from `capabilities.resources`.
1227///
1228/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
1229/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every *native
1230/// subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
1231/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
1232/// that cannot escape containment can still wedge its host by forking, allocating, or writing
1233/// without bound; this is the block that stops that.
1234///
1235/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
1236/// what the manifest actually declared, and the runtime substitutes its own default for
1237/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
1238/// or an omitted block — means defaults, never "unlimited".
1239#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1240pub struct ResourceCapabilities {
1241    /// `RLIMIT_NPROC` hard ceiling on each spawned subprocess.
1242    pub max_processes: Option<u64>,
1243    /// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
1244    pub max_open_files: Option<u64>,
1245    /// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
1246    pub max_file_size_bytes: Option<u64>,
1247    /// `RLIMIT_CPU` hard ceiling, in CPU-seconds, on each spawned subprocess.
1248    pub cpu_seconds: Option<u64>,
1249    /// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
1250    /// subprocess.
1251    pub memory_bytes: Option<u64>,
1252    /// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
1253    pub cgroup_memory_bytes: Option<u64>,
1254    /// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
1255    pub cgroup_pids_max: Option<u64>,
1256    /// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
1257    pub cgroup_cpu_percent: Option<u32>,
1258    /// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
1259    /// best-effort (the backing device cannot always be resolved).
1260    pub cgroup_io_bytes_per_sec: Option<u64>,
1261    /// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
1262    pub workdir_max_bytes: Option<u64>,
1263}
1264
1265/// Durable, capsule-scoped state for one tool, driver or hook: a host directory outside every
1266/// session workdir, preopened into the guest as `state/`.
1267///
1268/// Absent is deny — no declaration means no second preopen and no directory anywhere. The store
1269/// is keyed by capsule rather than by workdir precisely so it survives a launch that gets a fresh
1270/// session workdir, and so two capsules launched in the same directory never share one.
1271#[derive(Debug, Clone, PartialEq, Eq, Default)]
1272pub struct StateCapabilities {
1273    /// Directory name under `~/.murmur/state/`. `None` means "use the capsule name", which is
1274    /// what the overwhelming majority of declarations want.
1275    ///
1276    /// Read from the capsule operator's own manifest entry and never from the artifact's bundled
1277    /// manifest, so a registry-pulled tool cannot claim a store that already exists. The *shape*
1278    /// of the name is not checked here — a store name is a runtime concern, refused as
1279    /// `E-CAP-009`, exactly as `capabilities.filesystem.scope`'s shape is refused as `E-CAP-002`.
1280    pub store: Option<String>,
1281}
1282
1283// ── Containment class ─────────────────────────────────────────────────────────
1284
1285/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
1286/// by the operator and satisfied (or not) by the host's kernel.
1287///
1288/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
1289/// must stay listed weakest-first. Combining floors from several sources is `max`, never
1290/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
1291///
1292/// The class an operator *declares* is a requirement. The class a host actually *achieves*
1293/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
1294/// this declaration.
1295#[derive(
1296    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1297)]
1298#[serde(rename_all = "snake_case")]
1299pub enum ContainmentClass {
1300    /// No kernel mechanism required. Capability declarations are honored by the runtime's
1301    /// own dispatch layer and by convention; anything the host cannot mediate is warned
1302    /// about, not refused. Every host satisfies this, including macOS. The default.
1303    #[default]
1304    Advisory,
1305    /// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
1306    /// filesystem: every path outside the workdir must be an explicit manifest grant.
1307    /// Requires Linux 5.13+ with a usable Landlock ABI.
1308    Scoped,
1309    /// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
1310    /// root, so the host filesystem is not merely mediated but absent. Achievable on an
1311    /// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
1312    /// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
1313    /// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
1314    /// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
1315    /// See `docs/content/reference/sealed-containment-manual-verification.md`.
1316    Sealed,
1317}
1318
1319impl ContainmentClass {
1320    /// The lowercase wire name, identical in the manifest, the workspace config, the
1321    /// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
1322    pub fn as_str(self) -> &'static str {
1323        match self {
1324            Self::Advisory => "advisory",
1325            Self::Scoped => "scoped",
1326            Self::Sealed => "sealed",
1327        }
1328    }
1329
1330    /// Every value, weakest first — the single source for the "must be one of: …" text.
1331    pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1332}
1333
1334impl std::fmt::Display for ContainmentClass {
1335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1336        f.write_str(self.as_str())
1337    }
1338}
1339
1340/// A string that is not one of the three containment class names.
1341#[derive(Debug, Clone, PartialEq, Eq)]
1342pub struct ParseContainmentClassError {
1343    pub value: String,
1344}
1345
1346impl std::fmt::Display for ParseContainmentClassError {
1347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1348        write!(
1349            f,
1350            "must be one of: advisory, scoped, sealed; got '{}'",
1351            self.value
1352        )
1353    }
1354}
1355
1356impl std::error::Error for ParseContainmentClassError {}
1357
1358impl std::str::FromStr for ContainmentClass {
1359    type Err = ParseContainmentClassError;
1360
1361    fn from_str(value: &str) -> Result<Self, Self::Err> {
1362        match value.trim() {
1363            "advisory" => Ok(Self::Advisory),
1364            "scoped" => Ok(Self::Scoped),
1365            "sealed" => Ok(Self::Sealed),
1366            other => Err(ParseContainmentClassError {
1367                value: other.to_string(),
1368            }),
1369        }
1370    }
1371}
1372
1373/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
1374/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
1375///
1376/// Monotonic by construction — an absent source does not participate, and no present source
1377/// can lower what another raised. Argument order carries no precedence.
1378pub fn effective_containment_floor(
1379    workspace_default: Option<ContainmentClass>,
1380    manifest_declared: Option<ContainmentClass>,
1381    cli_flag: Option<ContainmentClass>,
1382) -> ContainmentClass {
1383    [workspace_default, manifest_declared, cli_flag]
1384        .into_iter()
1385        .flatten()
1386        .max()
1387        .unwrap_or_default()
1388}
1389
1390#[derive(Debug, Clone, PartialEq, Eq)]
1391pub struct Capabilities {
1392    pub network: Option<NetworkCapabilities>,
1393    /// Which peers this capsule may redeem a peer-file handle against. `None` means no
1394    /// `fetch-peer-file` tool exists and nothing can be fetched — absent is deny.
1395    pub peer_fetch: Option<PeerFetchCapabilities>,
1396    pub filesystem: Option<FilesystemCapabilities>,
1397    pub shell: Option<ShellCapabilities>,
1398    pub spawn: Option<SpawnCapabilities>,
1399    pub env: Option<EnvCapabilities>,
1400    pub limits: Option<ResourceLimits>,
1401    pub resources: Option<ResourceCapabilities>,
1402    /// Durable state store for this artifact. `None` — the overwhelmingly common case — is deny:
1403    /// no `state/` preopen, and no directory created anywhere. See [`StateCapabilities`].
1404    pub state: Option<StateCapabilities>,
1405    /// Per-hook grant of the `murmur:task-io/read` host import. Only ever `Some` on a
1406    /// `runtime: hook` artifact entry — see [`TaskIoCapabilities`].
1407    pub task_io: Option<TaskIoCapabilities>,
1408    /// Per-hook grant of the `murmur:conversation/read` host import — see
1409    /// [`ConversationCapabilities`].
1410    pub conversation: Option<ConversationCapabilities>,
1411    /// Capsule-wide grant of the runtime-provided `submit-plan` tool. `None` is deny: the tool's
1412    /// synthetic manifest is not written, so it is absent from the model's inventory entirely.
1413    pub plan: Option<PlanCapabilities>,
1414    /// Minimum containment class this capsule declares. `None` (the overwhelmingly common
1415    /// case) means the capsule states no requirement and inherits whatever the workspace
1416    /// config or `--containment` asks for, defaulting to `advisory`.
1417    pub containment: Option<ContainmentClass>,
1418}
1419
1420#[derive(Debug, Error)]
1421pub enum RuntimeManifestError {
1422    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1423    NotFound(String),
1424    #[error("{0}")]
1425    YamlSyntax(String),
1426    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1427    MissingField { field: String },
1428    #[error(
1429        "{}: invalid artifact declaration at index {index}: {message}",
1430        MANIFEST_FILENAME
1431    )]
1432    InvalidArtifact { index: usize, message: String },
1433    #[error(
1434        "{}: invalid inference config for '{field}': {message}",
1435        MANIFEST_FILENAME
1436    )]
1437    InvalidInferenceConfig { field: String, message: String },
1438    #[error(
1439        "{}: invalid capability config for '{field}': {message}",
1440        MANIFEST_FILENAME
1441    )]
1442    InvalidCapabilities { field: String, message: String },
1443    #[error(
1444        "{}: invalid exports config for '{field}': {message}",
1445        MANIFEST_FILENAME
1446    )]
1447    InvalidExports { field: String, message: String },
1448    #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
1449    InvalidTraceConfig { field: String, message: String },
1450    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
1451    Io {
1452        path: String,
1453        #[source]
1454        source: std::io::Error,
1455    },
1456}
1457
1458#[derive(Debug, Deserialize)]
1459struct RawRuntimeManifest {
1460    name: Option<String>,
1461    version: Option<String>,
1462    #[serde(default)]
1463    artifacts: Vec<RawArtifact>,
1464    #[serde(default)]
1465    capabilities: Option<RawCapabilities>,
1466    #[serde(default)]
1467    inference: Option<RawInferenceConfig>,
1468    #[serde(default)]
1469    context: Option<RawContextConfig>,
1470    #[serde(default)]
1471    observability: Option<RawObservabilityConfig>,
1472    #[serde(default)]
1473    trace: Option<RawTraceConfig>,
1474    #[serde(default)]
1475    network: Option<RawNetworkConfig>,
1476    #[serde(default)]
1477    lifecycle: Option<RawLifecycleConfig>,
1478    #[serde(default)]
1479    exports: Option<RawExports>,
1480    #[serde(default)]
1481    mur_version: Option<String>,
1482    /// Captured only to refuse it. `config:` is delivered to one artifact through that artifact's
1483    /// own grant, so a capsule-wide block reaches nothing; without this field it would be one of
1484    /// the unrecognized top-level keys this manifest silently ignores.
1485    #[serde(default, deserialize_with = "deserialize_present")]
1486    config: Option<serde_yaml::Value>,
1487    /// Read by `mur build` and `mur publish` off this same file to select the packaging type.
1488    /// Declared here so a manifest that is both runnable and publishable does not report its own
1489    /// build-side keys as unrecognized; the run path takes no meaning from it and never inspects
1490    /// the value, so any shape parses.
1491    #[serde(default)]
1492    #[allow(dead_code)]
1493    runtime: Option<serde_yaml::Value>,
1494    /// Read by `mur build` and `mur publish`: it separates a native tool from a wasm one, both in
1495    /// the derived packaging type and in `mur publish`'s platform tagging. Ignored on the run
1496    /// path, and untyped for the same reason as `runtime:` above.
1497    #[serde(default)]
1498    #[allow(dead_code)]
1499    implementation: Option<serde_yaml::Value>,
1500    /// Read by `mur build` and `mur publish` as the declared packaging type, which overrides the
1501    /// one derived from `runtime:` and `implementation:`. Ignored on the run path, and untyped for
1502    /// the same reason as `runtime:` above.
1503    #[serde(default)]
1504    #[allow(dead_code)]
1505    execution: Option<serde_yaml::Value>,
1506    /// Read by `mur build` as the list of companion files to pack beside the manifest. Ignored on
1507    /// the run path, and untyped for the same reason as `runtime:` above.
1508    #[serde(default)]
1509    #[allow(dead_code)]
1510    requires_files: Option<serde_yaml::Value>,
1511    #[serde(flatten)]
1512    unknown: UnknownKeys,
1513}
1514
1515#[derive(Debug, Deserialize)]
1516struct RawExports {
1517    #[serde(default)]
1518    files: Option<RawFileExport>,
1519    #[serde(default)]
1520    peer_files: Option<RawPeerFilesExport>,
1521    #[serde(flatten)]
1522    unknown: UnknownKeys,
1523}
1524
1525#[derive(Debug, Deserialize)]
1526struct RawPeerFilesExport {
1527    #[serde(default)]
1528    root: Option<String>,
1529    /// Untyped for the same reason as [`RawFileExport::max_bytes`]: `30m` is a YAML string and a
1530    /// bare `1800` is a YAML integer, and a wrong-shaped value has to reach the parser to be
1531    /// reported as an [`RuntimeManifestError::InvalidExports`] naming the field.
1532    #[serde(default)]
1533    max_ttl: Option<serde_yaml::Value>,
1534    #[serde(default)]
1535    max_bytes: Option<serde_yaml::Value>,
1536    #[serde(flatten)]
1537    unknown: UnknownKeys,
1538}
1539
1540#[derive(Debug, Deserialize)]
1541struct RawFileExport {
1542    #[serde(default)]
1543    root: Option<String>,
1544    #[serde(default)]
1545    mode: Option<String>,
1546    /// Deliberately untyped: `max_bytes` accepts both a bare integer and a suffixed string, and
1547    /// a wrong-shaped value has to reach [`parse_exports`] to be reported as an
1548    /// [`RuntimeManifestError::InvalidExports`] naming the field rather than as a serde type
1549    /// error naming a line number.
1550    #[serde(default)]
1551    max_bytes: Option<serde_yaml::Value>,
1552    #[serde(flatten)]
1553    unknown: UnknownKeys,
1554}
1555
1556#[derive(Debug, Deserialize)]
1557struct RawLifecycleConfig {
1558    #[serde(default)]
1559    task_acceptance: Option<TaskAcceptance>,
1560    #[serde(default)]
1561    after_task: Option<AfterTask>,
1562    #[serde(default)]
1563    queue_depth: Option<usize>,
1564    #[serde(default)]
1565    input_timeout_secs: Option<u64>,
1566    #[serde(default)]
1567    conversation: Option<ConversationMode>,
1568    #[serde(default)]
1569    max_task_reopens: Option<u32>,
1570    #[serde(default)]
1571    shell_grace_secs: Option<u64>,
1572    #[serde(default)]
1573    delegation_deadline_secs: Option<u64>,
1574    #[serde(flatten)]
1575    unknown: UnknownKeys,
1576}
1577
1578#[derive(Debug, Deserialize)]
1579struct RawNetworkConfig {
1580    internal_port: Option<u16>,
1581    #[serde(flatten)]
1582    unknown: UnknownKeys,
1583}
1584
1585#[derive(Debug, Deserialize)]
1586struct RawContextConfig {
1587    max_tokens: Option<u32>,
1588    /// `on` | `off`. Kept as a raw `String` so an unrecognized value reports as an invalid
1589    /// value for this key rather than as a serde variant error against the whole block.
1590    #[serde(default)]
1591    record: Option<String>,
1592    #[serde(default)]
1593    record_store: Option<String>,
1594    #[serde(default)]
1595    seed_budget: Option<f32>,
1596    #[serde(default)]
1597    seed_overflow_margin: Option<f32>,
1598    /// Untyped so an empty block, a null block and an unknown key inside it are each reported
1599    /// as an invalid `context.retain` rather than as a serde message about a struct field.
1600    #[serde(default, deserialize_with = "present_yaml_value")]
1601    retain: Option<serde_yaml::Value>,
1602    #[serde(flatten)]
1603    unknown: UnknownKeys,
1604}
1605
1606#[derive(Debug, Deserialize)]
1607struct RawObservabilityConfig {
1608    #[serde(default)]
1609    otel_endpoint: Option<String>,
1610    #[serde(default)]
1611    eval: Option<RawEvalConfig>,
1612    #[serde(flatten)]
1613    unknown: UnknownKeys,
1614}
1615
1616#[derive(Debug, Deserialize)]
1617struct RawTraceConfig {
1618    /// Untyped so an unparseable mode reaches [`resolve_trace_capture`] and is reported as a
1619    /// [`RuntimeManifestError::InvalidTraceConfig`] naming the field and the accepted values,
1620    /// rather than as a serde message about an unknown variant.
1621    #[serde(default)]
1622    capture: Option<String>,
1623    /// The retired alias. `None` means the key was absent, which is what separates a manifest
1624    /// that opted out from one that never mentioned it — see [`resolve_trace_capture`].
1625    #[serde(default)]
1626    include_tool_output: Option<bool>,
1627    /// Untyped for the same reason as `context.retain` — see [`RawContextConfig::retain`].
1628    #[serde(default, deserialize_with = "present_yaml_value")]
1629    retain: Option<serde_yaml::Value>,
1630    #[serde(flatten)]
1631    unknown: UnknownKeys,
1632}
1633
1634#[derive(Debug, Deserialize)]
1635struct RawEvalConfig {
1636    #[serde(default)]
1637    dataset_id: Option<String>,
1638    #[serde(default)]
1639    scorers: Vec<RawScorerConfig>,
1640    #[serde(flatten)]
1641    unknown: UnknownKeys,
1642}
1643
1644#[derive(Debug, Deserialize)]
1645struct RawScorerConfig {
1646    #[serde(rename = "type")]
1647    scorer_type: String,
1648    name: Option<String>,
1649    max: Option<u64>,
1650    expected: Option<Vec<String>>,
1651    #[serde(flatten)]
1652    unknown: UnknownKeys,
1653}
1654
1655#[derive(Debug, Deserialize)]
1656struct RawArtifact {
1657    name: Option<String>,
1658    version: Option<String>,
1659    runtime: Option<String>,
1660    #[serde(default)]
1661    source: Option<String>,
1662    #[serde(default)]
1663    local_source: Option<bool>,
1664    #[serde(default)]
1665    prompt_payload: Option<bool>,
1666    #[serde(default)]
1667    capabilities: Option<RawCapabilities>,
1668    #[serde(default)]
1669    on_overflow: Option<String>,
1670    /// Untyped, and deserialized through [`deserialize_present`] rather than plain `Option`, so
1671    /// `config:` written with nothing under it arrives as `Some(Value::Null)` instead of
1672    /// collapsing into the absent case. The two mean different things: absent grants no variable,
1673    /// while an empty declaration is a written statement that carries nothing and is refused.
1674    #[serde(default, deserialize_with = "deserialize_present")]
1675    config: Option<serde_yaml::Value>,
1676    #[serde(flatten)]
1677    unknown: UnknownKeys,
1678}
1679
1680/// Deserialize a present key into `Some(value)` even when its value is YAML null, leaving `None`
1681/// to mean the key was absent and `#[serde(default)]` supplied it.
1682///
1683/// serde's own `Option` impl maps a null value to `None`, which erases exactly the distinction
1684/// `config:` rests on.
1685fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
1686where
1687    T: Deserialize<'de>,
1688    D: serde::Deserializer<'de>,
1689{
1690    T::deserialize(deserializer).map(Some)
1691}
1692
1693#[derive(Debug, Deserialize)]
1694struct RawCapabilities {
1695    #[serde(default)]
1696    network: Option<RawNetworkCapabilities>,
1697    #[serde(default)]
1698    peer_fetch: Option<RawPeerFetchCapabilities>,
1699    #[serde(default)]
1700    filesystem: Option<RawFilesystemCapabilities>,
1701    #[serde(default)]
1702    shell: Option<RawShellCapabilities>,
1703    #[serde(default)]
1704    spawn: Option<RawSpawnCapabilities>,
1705    #[serde(default)]
1706    env: Option<RawEnvCapabilities>,
1707    #[serde(default)]
1708    limits: Option<RawResourceLimits>,
1709    #[serde(default)]
1710    resources: Option<RawResourceCapabilities>,
1711    #[serde(default)]
1712    state: Option<RawStateCapabilities>,
1713    #[serde(default)]
1714    task_io: Option<RawTaskIoCapabilities>,
1715    #[serde(default)]
1716    conversation: Option<RawConversationCapabilities>,
1717    #[serde(default)]
1718    plan: Option<RawPlanCapabilities>,
1719    /// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
1720    /// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
1721    /// "unknown variant" error attributed to the whole `capabilities:` block.
1722    #[serde(default)]
1723    containment: Option<String>,
1724    #[serde(flatten)]
1725    unknown: UnknownKeys,
1726}
1727
1728#[derive(Debug, Deserialize)]
1729struct RawTaskIoCapabilities {
1730    // `Option` so an omitted `read` is distinguishable from an explicit `false`: the omission
1731    // is rejected outright rather than defaulted, because a capability is never inferred.
1732    #[serde(default)]
1733    read: Option<bool>,
1734    #[serde(flatten)]
1735    unknown: UnknownKeys,
1736}
1737
1738#[derive(Debug, Deserialize)]
1739struct RawConversationCapabilities {
1740    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
1741    // than defaulted.
1742    #[serde(default)]
1743    read: Option<bool>,
1744    #[serde(flatten)]
1745    unknown: UnknownKeys,
1746}
1747
1748#[derive(Debug, Deserialize)]
1749struct RawPlanCapabilities {
1750    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
1751    // than defaulted.
1752    #[serde(default)]
1753    submit: Option<bool>,
1754    #[serde(flatten)]
1755    unknown: UnknownKeys,
1756}
1757
1758#[derive(Debug, Deserialize)]
1759struct RawSpawnCapabilities {
1760    #[serde(default)]
1761    allow: Vec<String>,
1762    #[serde(flatten)]
1763    unknown: UnknownKeys,
1764}
1765
1766#[derive(Debug, Deserialize)]
1767struct RawEnvCapabilities {
1768    #[serde(default)]
1769    allow: Vec<String>,
1770    #[serde(flatten)]
1771    unknown: UnknownKeys,
1772}
1773
1774#[derive(Debug, Deserialize)]
1775struct RawResourceLimits {
1776    #[serde(default)]
1777    memory_bytes: Option<usize>,
1778    #[serde(default)]
1779    table_elements: Option<usize>,
1780    #[serde(default)]
1781    instances: Option<usize>,
1782    #[serde(default)]
1783    deadline_seconds: Option<u64>,
1784    #[serde(flatten)]
1785    unknown: UnknownKeys,
1786}
1787
1788#[derive(Debug, Deserialize)]
1789struct RawStateCapabilities {
1790    #[serde(default)]
1791    store: Option<String>,
1792    #[serde(flatten)]
1793    unknown: UnknownKeys,
1794}
1795
1796#[derive(Debug, Deserialize)]
1797struct RawResourceCapabilities {
1798    #[serde(default)]
1799    max_processes: Option<u64>,
1800    #[serde(default)]
1801    max_open_files: Option<u64>,
1802    #[serde(default)]
1803    max_file_size_bytes: Option<u64>,
1804    #[serde(default)]
1805    cpu_seconds: Option<u64>,
1806    #[serde(default)]
1807    memory_bytes: Option<u64>,
1808    #[serde(default)]
1809    cgroup_memory_bytes: Option<u64>,
1810    #[serde(default)]
1811    cgroup_pids_max: Option<u64>,
1812    #[serde(default)]
1813    cgroup_cpu_percent: Option<u32>,
1814    #[serde(default)]
1815    cgroup_io_bytes_per_sec: Option<u64>,
1816    #[serde(default)]
1817    workdir_max_bytes: Option<u64>,
1818    #[serde(flatten)]
1819    unknown: UnknownKeys,
1820}
1821
1822#[derive(Debug, Deserialize)]
1823struct RawNetworkCapabilities {
1824    #[serde(default)]
1825    allow: Vec<String>,
1826    #[serde(default)]
1827    unix_sockets: bool,
1828    #[serde(flatten)]
1829    unknown: UnknownKeys,
1830}
1831
1832#[derive(Debug, Deserialize)]
1833struct RawPeerFetchCapabilities {
1834    #[serde(default)]
1835    allow: Vec<String>,
1836    #[serde(flatten)]
1837    unknown: UnknownKeys,
1838}
1839
1840#[derive(Debug, Deserialize)]
1841struct RawFilesystemCapabilities {
1842    #[serde(default)]
1843    scope: Option<String>,
1844    /// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
1845    /// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
1846    /// "The key is absent" and "the key says `false`" are the same declaration.
1847    #[serde(default)]
1848    workdir_exec: bool,
1849    /// Always a list after parsing, never an `Option`: an absent key and an empty list are the
1850    /// same declaration — nothing is protected.
1851    #[serde(default)]
1852    read_only: Vec<String>,
1853    #[serde(flatten)]
1854    unknown: UnknownKeys,
1855}
1856
1857#[derive(Debug, Deserialize)]
1858struct RawShellCapabilities {
1859    #[serde(default)]
1860    allow: Vec<String>,
1861    #[serde(default)]
1862    strip_env: Option<Vec<String>>,
1863    #[serde(default)]
1864    baseline_env: Option<Vec<String>>,
1865    #[serde(default)]
1866    interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
1867    #[serde(default)]
1868    staged_runtime: Vec<RawStagedRuntimeGrant>,
1869    #[serde(flatten)]
1870    unknown: UnknownKeys,
1871}
1872
1873#[derive(Debug, Deserialize)]
1874struct RawInterpreterRuntimeGrant {
1875    #[serde(default)]
1876    binary: Option<String>,
1877    #[serde(default)]
1878    dirs: Vec<RawInterpreterRuntimeDir>,
1879    #[serde(flatten)]
1880    unknown: UnknownKeys,
1881}
1882
1883#[derive(Debug, Deserialize)]
1884struct RawInterpreterRuntimeDir {
1885    #[serde(default)]
1886    path: Option<String>,
1887    // `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
1888    // omission is rejected outright rather than defaulted, because enumerability is never
1889    // inferred.
1890    #[serde(default)]
1891    list_dir: Option<bool>,
1892    #[serde(flatten)]
1893    unknown: UnknownKeys,
1894}
1895
1896#[derive(Debug, Deserialize)]
1897struct RawStagedRuntimeGrant {
1898    #[serde(default)]
1899    binary: Option<String>,
1900    #[serde(default)]
1901    source_path: Option<String>,
1902    // `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
1903    // field-naming message, rather than defaulting to an empty string that would silently mean
1904    // "unpinned" — the one thing this field exists to make impossible.
1905    #[serde(default)]
1906    pin: Option<String>,
1907    #[serde(flatten)]
1908    unknown: UnknownKeys,
1909}
1910
1911#[derive(Debug, Deserialize)]
1912struct RawInferenceConfig {
1913    transport: Option<String>,
1914    endpoint: Option<String>,
1915    model: Option<String>,
1916    #[serde(default)]
1917    api_key: Option<String>,
1918    #[serde(default)]
1919    command: Option<String>,
1920    #[serde(default)]
1921    system_prompt: Option<String>,
1922    #[serde(default)]
1923    system_prompt_file: Option<String>,
1924    #[serde(default)]
1925    system_prompt_artifact: Option<String>,
1926    #[serde(default)]
1927    driver: Option<RawInferenceDriver>,
1928    #[serde(default)]
1929    provider: Option<RawInferenceDriver>,
1930    #[serde(default)]
1931    compaction: Option<RawCompactionConfig>,
1932    #[serde(default)]
1933    max_turns: Option<u32>,
1934    /// The `inference` spelling of the reopen budget is not a live field. It is deserialized
1935    /// only so `parse_inference` can refuse it; without it serde would ignore the key.
1936    #[serde(default)]
1937    max_task_reopens: Option<u32>,
1938    #[serde(default)]
1939    max_tokens: Option<u32>,
1940    #[serde(default)]
1941    max_session_tokens: Option<u64>,
1942    #[serde(flatten)]
1943    unknown: UnknownKeys,
1944}
1945
1946#[derive(Debug, Deserialize)]
1947struct RawCompactionConfig {
1948    threshold: Option<f32>,
1949    model: Option<String>,
1950    system_prompt: Option<String>,
1951    system_prompt_file: Option<String>,
1952    dump_summaries: Option<bool>,
1953    #[serde(flatten)]
1954    unknown: UnknownKeys,
1955}
1956
1957#[derive(Debug, Deserialize)]
1958struct RawInferenceDriver {
1959    artifact: Option<String>,
1960    #[serde(default)]
1961    config: Option<serde_yaml::Value>,
1962    #[serde(flatten)]
1963    unknown: UnknownKeys,
1964}
1965
1966/// The keys one `Raw*` block carried that no field of it claims.
1967///
1968/// A `BTreeMap` so the capture order is the manifest's key order sorted, which keeps the
1969/// `W-SEC-019` lines for one manifest identical on every run. The values are discarded: a key this
1970/// build does not recognize is reported by name and by containing block, and printing whatever an
1971/// operator wrote under it would put manifest contents into a diagnostic that has no use for them.
1972type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
1973
1974/// A `Raw*` deserialization block that captures the keys this build does not recognize.
1975///
1976/// Implemented for every `Raw*` struct in this file, which is asserted structurally by
1977/// `every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names` rather than left to
1978/// review: a manifest key added without its block learning about it is exactly the defect
1979/// `W-SEC-019` exists to report.
1980trait RawBlock {
1981    /// Every key serde accepts on this block, in declaration order and under the name serde
1982    /// matches (so a `#[serde(rename)]` field appears under its renamed spelling). This is the
1983    /// candidate set [`crate::unknown_manifest_keys::nearest_known_key`] suggests from.
1984    const KNOWN_KEYS: &'static [&'static str];
1985
1986    fn unknown_keys(&self) -> &UnknownKeys;
1987
1988    /// Descends into the blocks this one owns, in declaration order, so the reported paths read
1989    /// down the manifest the way the document does.
1990    ///
1991    /// Defaulted to a no-op, which is correct for a block that owns no other block. A block that
1992    /// does own one must override this: `every_raw_struct_descends_into_the_blocks_it_owns` reads
1993    /// this file's own text and fails, naming the struct and the field, if it does not — an
1994    /// unwalked block reports none of its keys, silently.
1995    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
1996        let _ = (path, out);
1997    }
1998}
1999
2000impl RawBlock for RawRuntimeManifest {
2001    const KNOWN_KEYS: &'static [&'static str] = &[
2002        "name",
2003        "version",
2004        "artifacts",
2005        "capabilities",
2006        "inference",
2007        "context",
2008        "observability",
2009        "trace",
2010        "network",
2011        "lifecycle",
2012        "exports",
2013        "mur_version",
2014        "config",
2015        "runtime",
2016        "implementation",
2017        "execution",
2018        "requires_files",
2019    ];
2020    fn unknown_keys(&self) -> &UnknownKeys {
2021        &self.unknown
2022    }
2023
2024    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2025        let artifacts = child_path(path, "artifacts");
2026        for (index, artifact) in self.artifacts.iter().enumerate() {
2027            collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2028        }
2029        if let Some(capabilities) = &self.capabilities {
2030            collect_block(capabilities, &child_path(path, "capabilities"), out);
2031        }
2032        if let Some(inference) = &self.inference {
2033            collect_block(inference, &child_path(path, "inference"), out);
2034        }
2035        if let Some(context) = &self.context {
2036            collect_block(context, &child_path(path, "context"), out);
2037        }
2038        if let Some(observability) = &self.observability {
2039            collect_block(observability, &child_path(path, "observability"), out);
2040        }
2041        if let Some(trace) = &self.trace {
2042            collect_block(trace, &child_path(path, "trace"), out);
2043        }
2044        if let Some(network) = &self.network {
2045            collect_block(network, &child_path(path, "network"), out);
2046        }
2047        if let Some(lifecycle) = &self.lifecycle {
2048            collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2049        }
2050        if let Some(exports) = &self.exports {
2051            collect_block(exports, &child_path(path, "exports"), out);
2052        }
2053    }
2054}
2055
2056impl RawBlock for RawExports {
2057    const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files"];
2058    fn unknown_keys(&self) -> &UnknownKeys {
2059        &self.unknown
2060    }
2061
2062    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2063        if let Some(files) = &self.files {
2064            collect_block(files, &child_path(path, "files"), out);
2065        }
2066        if let Some(peer_files) = &self.peer_files {
2067            collect_block(peer_files, &child_path(path, "peer_files"), out);
2068        }
2069    }
2070}
2071
2072impl RawBlock for RawPeerFilesExport {
2073    const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2074    fn unknown_keys(&self) -> &UnknownKeys {
2075        &self.unknown
2076    }
2077}
2078
2079impl RawBlock for RawFileExport {
2080    const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2081    fn unknown_keys(&self) -> &UnknownKeys {
2082        &self.unknown
2083    }
2084}
2085
2086impl RawBlock for RawLifecycleConfig {
2087    const KNOWN_KEYS: &'static [&'static str] = &[
2088        "task_acceptance",
2089        "after_task",
2090        "queue_depth",
2091        "input_timeout_secs",
2092        "conversation",
2093        "max_task_reopens",
2094        "shell_grace_secs",
2095        "delegation_deadline_secs",
2096    ];
2097    fn unknown_keys(&self) -> &UnknownKeys {
2098        &self.unknown
2099    }
2100}
2101
2102impl RawBlock for RawNetworkConfig {
2103    const KNOWN_KEYS: &'static [&'static str] = &["internal_port"];
2104    fn unknown_keys(&self) -> &UnknownKeys {
2105        &self.unknown
2106    }
2107}
2108
2109impl RawBlock for RawContextConfig {
2110    const KNOWN_KEYS: &'static [&'static str] = &[
2111        "max_tokens",
2112        "record",
2113        "record_store",
2114        "seed_budget",
2115        "seed_overflow_margin",
2116        "retain",
2117    ];
2118    fn unknown_keys(&self) -> &UnknownKeys {
2119        &self.unknown
2120    }
2121}
2122
2123impl RawBlock for RawObservabilityConfig {
2124    const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
2125    fn unknown_keys(&self) -> &UnknownKeys {
2126        &self.unknown
2127    }
2128
2129    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2130        if let Some(eval) = &self.eval {
2131            collect_block(eval, &child_path(path, "eval"), out);
2132        }
2133    }
2134}
2135
2136impl RawBlock for RawTraceConfig {
2137    const KNOWN_KEYS: &'static [&'static str] = &["capture", "include_tool_output", "retain"];
2138    fn unknown_keys(&self) -> &UnknownKeys {
2139        &self.unknown
2140    }
2141}
2142
2143impl RawBlock for RawEvalConfig {
2144    const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
2145    fn unknown_keys(&self) -> &UnknownKeys {
2146        &self.unknown
2147    }
2148
2149    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2150        let scorers = child_path(path, "scorers");
2151        for (index, scorer) in self.scorers.iter().enumerate() {
2152            collect_block(scorer, &format!("{scorers}[{index}]"), out);
2153        }
2154    }
2155}
2156
2157impl RawBlock for RawScorerConfig {
2158    const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
2159    fn unknown_keys(&self) -> &UnknownKeys {
2160        &self.unknown
2161    }
2162}
2163
2164impl RawBlock for RawArtifact {
2165    const KNOWN_KEYS: &'static [&'static str] = &[
2166        "name",
2167        "version",
2168        "runtime",
2169        "source",
2170        "local_source",
2171        "prompt_payload",
2172        "capabilities",
2173        "on_overflow",
2174        "config",
2175    ];
2176    fn unknown_keys(&self) -> &UnknownKeys {
2177        &self.unknown
2178    }
2179
2180    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2181        if let Some(capabilities) = &self.capabilities {
2182            collect_block(capabilities, &child_path(path, "capabilities"), out);
2183        }
2184    }
2185}
2186
2187impl RawBlock for RawCapabilities {
2188    const KNOWN_KEYS: &'static [&'static str] = &[
2189        "network",
2190        "peer_fetch",
2191        "filesystem",
2192        "shell",
2193        "spawn",
2194        "env",
2195        "limits",
2196        "resources",
2197        "state",
2198        "task_io",
2199        "conversation",
2200        "plan",
2201        "containment",
2202    ];
2203    fn unknown_keys(&self) -> &UnknownKeys {
2204        &self.unknown
2205    }
2206
2207    /// Reached from both [`RawRuntimeManifest`] and [`RawArtifact`], so the capsule-wide block and
2208    /// a per-artifact one report through identical code and differ only in the `path` they are
2209    /// handed — `capabilities.shell` against `artifacts[0].capabilities.shell`.
2210    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2211        if let Some(network) = &self.network {
2212            collect_block(network, &child_path(path, "network"), out);
2213        }
2214        if let Some(peer_fetch) = &self.peer_fetch {
2215            collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
2216        }
2217        if let Some(filesystem) = &self.filesystem {
2218            collect_block(filesystem, &child_path(path, "filesystem"), out);
2219        }
2220        if let Some(shell) = &self.shell {
2221            collect_block(shell, &child_path(path, "shell"), out);
2222        }
2223        if let Some(spawn) = &self.spawn {
2224            collect_block(spawn, &child_path(path, "spawn"), out);
2225        }
2226        if let Some(env) = &self.env {
2227            collect_block(env, &child_path(path, "env"), out);
2228        }
2229        if let Some(limits) = &self.limits {
2230            collect_block(limits, &child_path(path, "limits"), out);
2231        }
2232        if let Some(resources) = &self.resources {
2233            collect_block(resources, &child_path(path, "resources"), out);
2234        }
2235        if let Some(state) = &self.state {
2236            collect_block(state, &child_path(path, "state"), out);
2237        }
2238        if let Some(task_io) = &self.task_io {
2239            collect_block(task_io, &child_path(path, "task_io"), out);
2240        }
2241        if let Some(conversation) = &self.conversation {
2242            collect_block(conversation, &child_path(path, "conversation"), out);
2243        }
2244        if let Some(plan) = &self.plan {
2245            collect_block(plan, &child_path(path, "plan"), out);
2246        }
2247    }
2248}
2249
2250impl RawBlock for RawTaskIoCapabilities {
2251    const KNOWN_KEYS: &'static [&'static str] = &["read"];
2252    fn unknown_keys(&self) -> &UnknownKeys {
2253        &self.unknown
2254    }
2255}
2256
2257impl RawBlock for RawConversationCapabilities {
2258    const KNOWN_KEYS: &'static [&'static str] = &["read"];
2259    fn unknown_keys(&self) -> &UnknownKeys {
2260        &self.unknown
2261    }
2262}
2263
2264impl RawBlock for RawPlanCapabilities {
2265    const KNOWN_KEYS: &'static [&'static str] = &["submit"];
2266    fn unknown_keys(&self) -> &UnknownKeys {
2267        &self.unknown
2268    }
2269}
2270
2271impl RawBlock for RawSpawnCapabilities {
2272    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2273    fn unknown_keys(&self) -> &UnknownKeys {
2274        &self.unknown
2275    }
2276}
2277
2278impl RawBlock for RawEnvCapabilities {
2279    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2280    fn unknown_keys(&self) -> &UnknownKeys {
2281        &self.unknown
2282    }
2283}
2284
2285impl RawBlock for RawResourceLimits {
2286    const KNOWN_KEYS: &'static [&'static str] = &[
2287        "memory_bytes",
2288        "table_elements",
2289        "instances",
2290        "deadline_seconds",
2291    ];
2292    fn unknown_keys(&self) -> &UnknownKeys {
2293        &self.unknown
2294    }
2295}
2296
2297impl RawBlock for RawStateCapabilities {
2298    const KNOWN_KEYS: &'static [&'static str] = &["store"];
2299    fn unknown_keys(&self) -> &UnknownKeys {
2300        &self.unknown
2301    }
2302}
2303
2304impl RawBlock for RawResourceCapabilities {
2305    const KNOWN_KEYS: &'static [&'static str] = &[
2306        "max_processes",
2307        "max_open_files",
2308        "max_file_size_bytes",
2309        "cpu_seconds",
2310        "memory_bytes",
2311        "cgroup_memory_bytes",
2312        "cgroup_pids_max",
2313        "cgroup_cpu_percent",
2314        "cgroup_io_bytes_per_sec",
2315        "workdir_max_bytes",
2316    ];
2317    fn unknown_keys(&self) -> &UnknownKeys {
2318        &self.unknown
2319    }
2320}
2321
2322impl RawBlock for RawNetworkCapabilities {
2323    const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
2324    fn unknown_keys(&self) -> &UnknownKeys {
2325        &self.unknown
2326    }
2327}
2328
2329impl RawBlock for RawPeerFetchCapabilities {
2330    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2331    fn unknown_keys(&self) -> &UnknownKeys {
2332        &self.unknown
2333    }
2334}
2335
2336impl RawBlock for RawFilesystemCapabilities {
2337    const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only"];
2338    fn unknown_keys(&self) -> &UnknownKeys {
2339        &self.unknown
2340    }
2341}
2342
2343impl RawBlock for RawShellCapabilities {
2344    const KNOWN_KEYS: &'static [&'static str] = &[
2345        "allow",
2346        "strip_env",
2347        "baseline_env",
2348        "interpreter_runtime",
2349        "staged_runtime",
2350    ];
2351    fn unknown_keys(&self) -> &UnknownKeys {
2352        &self.unknown
2353    }
2354
2355    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2356        let interpreter_runtime = child_path(path, "interpreter_runtime");
2357        for (index, grant) in self.interpreter_runtime.iter().enumerate() {
2358            collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
2359        }
2360        let staged_runtime = child_path(path, "staged_runtime");
2361        for (index, grant) in self.staged_runtime.iter().enumerate() {
2362            collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
2363        }
2364    }
2365}
2366
2367impl RawBlock for RawInterpreterRuntimeGrant {
2368    const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
2369    fn unknown_keys(&self) -> &UnknownKeys {
2370        &self.unknown
2371    }
2372
2373    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2374        let dirs = child_path(path, "dirs");
2375        for (index, dir) in self.dirs.iter().enumerate() {
2376            collect_block(dir, &format!("{dirs}[{index}]"), out);
2377        }
2378    }
2379}
2380
2381impl RawBlock for RawInterpreterRuntimeDir {
2382    const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
2383    fn unknown_keys(&self) -> &UnknownKeys {
2384        &self.unknown
2385    }
2386}
2387
2388impl RawBlock for RawStagedRuntimeGrant {
2389    const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
2390    fn unknown_keys(&self) -> &UnknownKeys {
2391        &self.unknown
2392    }
2393}
2394
2395impl RawBlock for RawInferenceConfig {
2396    const KNOWN_KEYS: &'static [&'static str] = &[
2397        "transport",
2398        "endpoint",
2399        "model",
2400        "api_key",
2401        "command",
2402        "system_prompt",
2403        "system_prompt_file",
2404        "system_prompt_artifact",
2405        "driver",
2406        "provider",
2407        "compaction",
2408        "max_turns",
2409        "max_task_reopens",
2410        "max_tokens",
2411        "max_session_tokens",
2412    ];
2413    fn unknown_keys(&self) -> &UnknownKeys {
2414        &self.unknown
2415    }
2416
2417    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2418        if let Some(driver) = &self.driver {
2419            collect_block(driver, &child_path(path, "driver"), out);
2420        }
2421        if let Some(provider) = &self.provider {
2422            collect_block(provider, &child_path(path, "provider"), out);
2423        }
2424        if let Some(compaction) = &self.compaction {
2425            collect_block(compaction, &child_path(path, "compaction"), out);
2426        }
2427    }
2428}
2429
2430impl RawBlock for RawCompactionConfig {
2431    const KNOWN_KEYS: &'static [&'static str] = &[
2432        "threshold",
2433        "model",
2434        "system_prompt",
2435        "system_prompt_file",
2436        "dump_summaries",
2437    ];
2438    fn unknown_keys(&self) -> &UnknownKeys {
2439        &self.unknown
2440    }
2441}
2442
2443impl RawBlock for RawInferenceDriver {
2444    const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
2445    fn unknown_keys(&self) -> &UnknownKeys {
2446        &self.unknown
2447    }
2448}
2449
2450/// Walks a parsed raw manifest and reports every key no block of it claimed, each block's own
2451/// keys before the blocks it owns, in the order the blocks appear in the manifest's own type.
2452///
2453/// Called once, immediately after `serde_yaml` returns and before any field is moved out, so the
2454/// result covers the whole document rather than the parts validation happens to reach. Reporting
2455/// only; nothing here can refuse a manifest.
2456fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
2457    let mut out = Vec::new();
2458    collect_block(raw, "", &mut out);
2459    out
2460}
2461
2462/// One block's captured keys, each paired with the nearest key that block does recognize, then
2463/// the same for every block it owns.
2464///
2465/// `path` is empty for the manifest root, which is what
2466/// [`crate::unknown_manifest_keys::UnknownManifestKey`] renders as "at the top level".
2467fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
2468    for key in block.unknown_keys().keys() {
2469        out.push(UnknownManifestKey {
2470            key: key.clone(),
2471            block_path: path.to_string(),
2472            nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
2473        });
2474    }
2475    block.walk_children(path, out);
2476}
2477
2478/// The dotted path of `field` inside the block at `parent`, built in one place so no
2479/// [`RawBlock::walk_children`] has to reproduce the rule that the manifest root has no prefix.
2480/// An indexed child appends its own `[index]` to the result.
2481fn child_path(parent: &str, field: &str) -> String {
2482    if parent.is_empty() {
2483        return field.to_string();
2484    }
2485    format!("{parent}.{field}")
2486}
2487
2488/// Read a manifest file's text, mapping a missing file to
2489/// [`RuntimeManifestError::NotFound`] and any other read failure to
2490/// [`RuntimeManifestError::Io`].
2491///
2492/// For a caller that needs the manifest text as well as the parsed manifest — `mur doctor` reads
2493/// the references a manifest makes out of the same bytes it parses, so the file is opened once.
2494pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
2495    fs::read_to_string(path).map_err(|source| {
2496        if source.kind() == std::io::ErrorKind::NotFound {
2497            return RuntimeManifestError::NotFound(path.display().to_string());
2498        }
2499
2500        RuntimeManifestError::Io {
2501            path: path.display().to_string(),
2502            source,
2503        }
2504    })
2505}
2506
2507#[must_use = "validated runtime manifest is required before run"]
2508pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
2509    let content = read_runtime_manifest_text(path)?;
2510
2511    RuntimeManifest::from_yaml_str(&content)
2512}
2513
2514/// One `${VAR}` reference a manifest makes, and the field that makes it.
2515#[derive(Debug, Clone, PartialEq, Eq)]
2516pub struct ReferencedEnvVariable {
2517    /// The manifest field holding the reference, e.g. `inference.api_key`.
2518    pub field: &'static str,
2519    /// The variable the reference names.
2520    pub variable: String,
2521}
2522
2523/// The narrow view of a manifest this scan needs: the fields whose value may be an `${ENV_REF}`,
2524/// and nothing else. Every other key is ignored, so a manifest this build would refuse for an
2525/// unrelated reason still yields its references.
2526///
2527/// Deliberately not one of the `Raw*` structs: those carry the unknown-key overflow map that
2528/// makes `warn_on_unknown_manifest_keys` complete, and this one is a second, partial reader of a
2529/// document that reader has already covered.
2530#[derive(Deserialize)]
2531struct ReferencedManifestFields {
2532    #[serde(default)]
2533    inference: Option<ReferencedInferenceFields>,
2534}
2535
2536#[derive(Deserialize)]
2537struct ReferencedInferenceFields {
2538    #[serde(default)]
2539    api_key: Option<String>,
2540}
2541
2542/// Every environment variable `manifest_yaml` references through an `${ENV_REF}`, sorted by
2543/// variable name and deduplicated.
2544///
2545/// Never fails and never reads the environment: unparseable YAML, an absent field and a literal
2546/// (non-reference) value all yield an empty list. What counts as a reference is decided by the
2547/// same `parse_env_reference` the resolving parse applies, so a name reported here is exactly a
2548/// name [`RuntimeManifest::from_yaml_str`] would have looked up.
2549pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
2550    let Ok(raw) = serde_yaml::from_str::<ReferencedManifestFields>(manifest_yaml) else {
2551        return Vec::new();
2552    };
2553
2554    let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
2555    if let Some(api_key) = raw.inference.and_then(|inference| inference.api_key) {
2556        if let Some(variable) = parse_env_reference(api_key.trim()) {
2557            referenced.push(ReferencedEnvVariable {
2558                field: "inference.api_key",
2559                variable: variable.to_string(),
2560            });
2561        }
2562    }
2563
2564    referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(b.field)));
2565    referenced.dedup();
2566    referenced
2567}
2568
2569/// Whether a parse keeps what the manifest says about a secret it references.
2570///
2571/// The two [`RuntimeManifest`] constructors differ by this value and nothing else, so validation
2572/// cannot drift between a manifest `mur run` accepts and one a referee accepts. Neither reads the
2573/// process environment.
2574#[derive(Clone, Copy, PartialEq, Eq)]
2575enum SecretResolution {
2576    /// `inference.api_key` is kept as an [`ApiKeyReference`], unresolved.
2577    Resolve,
2578    /// `inference.api_key` is left unread. The parsed [`InferenceConfig::api_key`] is `None`
2579    /// whatever the manifest wrote.
2580    Skip,
2581}
2582
2583impl RuntimeManifest {
2584    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
2585        Self::parse(input, SecretResolution::Resolve)
2586    }
2587
2588    /// Parse a manifest without resolving any secret it references.
2589    ///
2590    /// Validation is identical to [`RuntimeManifest::from_yaml_str`] on every block, `inference`
2591    /// included, with one difference: the resulting [`InferenceConfig::api_key`] is always
2592    /// `None`, whether the manifest wrote an `${ENV_REF}` or a literal key.
2593    ///
2594    /// For a caller that reads a foreign capsule's manifest for its capability policy alone —
2595    /// `mur-roost` refereeing a delegation — and is entitled to none of the credentials that
2596    /// manifest names.
2597    pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
2598        Self::parse(input, SecretResolution::Skip)
2599    }
2600
2601    fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
2602        let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
2603            if let Some(location) = err.location() {
2604                RuntimeManifestError::YamlSyntax(format!(
2605                    "{}: YAML syntax error at line {}, column {}: {}",
2606                    MANIFEST_FILENAME,
2607                    location.line(),
2608                    location.column(),
2609                    err
2610                ))
2611            } else {
2612                RuntimeManifestError::YamlSyntax(format!(
2613                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
2614                ))
2615            }
2616        })?;
2617
2618        // Collected before any field is moved out of `raw`, so the walk sees the whole document
2619        // rather than the part validation happens to reach before its first refusal.
2620        let unknown_keys = collect_unknown_keys(&raw);
2621
2622        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
2623            RuntimeManifestError::MissingField {
2624                field: "name".to_string(),
2625            }
2626        })?;
2627
2628        let version = raw
2629            .version
2630            .filter(|s| !s.trim().is_empty())
2631            .ok_or_else(|| RuntimeManifestError::MissingField {
2632                field: "version".to_string(),
2633            })?;
2634
2635        let artifacts = raw
2636            .artifacts
2637            .into_iter()
2638            .enumerate()
2639            .map(|(index, artifact)| {
2640                let name = artifact
2641                    .name
2642                    .filter(|s| !s.trim().is_empty())
2643                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
2644                        index,
2645                        message: "missing required field 'name'".to_string(),
2646                    })?;
2647
2648                let source = artifact.source.filter(|s| !s.trim().is_empty());
2649
2650                let runtime = match artifact.runtime.as_deref() {
2651                    None | Some("tool") => ArtifactRuntime::Tool,
2652                    Some("wasm") | Some("native") => {
2653                        return Err(RuntimeManifestError::InvalidArtifact {
2654                            index,
2655                            message: format!(
2656                                "use 'runtime: tool'; implementation is declared in the \
2657                                 artifact's own manifest (got '{}')",
2658                                artifact.runtime.as_deref().unwrap_or("")
2659                            ),
2660                        });
2661                    }
2662                    Some("driver") => ArtifactRuntime::Driver,
2663                    Some("hook") => ArtifactRuntime::Hook,
2664                    Some("skill") => ArtifactRuntime::Skill,
2665                    Some(other) => {
2666                        return Err(RuntimeManifestError::InvalidArtifact {
2667                            index,
2668                            message: format!(
2669                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
2670                            ),
2671                        });
2672                    }
2673                };
2674
2675                // Capabilities are keyed on declared properties, not on the role. When the
2676                // property is absent we derive it from the role, which reproduces the previous
2677                // skill-only gate exactly and leaves every existing manifest unchanged.
2678                let local_source = artifact
2679                    .local_source
2680                    .unwrap_or(runtime == ArtifactRuntime::Skill);
2681                let prompt_payload = artifact
2682                    .prompt_payload
2683                    .unwrap_or(runtime == ArtifactRuntime::Skill);
2684
2685                // `source:` requires the artifact to declare `local_source: true`.
2686                if source.is_some() && !local_source {
2687                    return Err(RuntimeManifestError::InvalidArtifact {
2688                        index,
2689                        message: format!(
2690                            "artifact '{name}' declares 'source:' but does not declare \
2691                             'local_source: true' (runtime: {})",
2692                            runtime.as_str()
2693                        ),
2694                    });
2695                }
2696
2697                // When `source:` is present, `version:` is optional — substitute "local".
2698                // If both are set, `version:` is ignored and a warning is printed to stderr.
2699                let version = if source.is_some() {
2700                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
2701                        eprintln!(
2702                            "warning: artifact '{name}' declares both 'source:' and \
2703                             'version: {explicit}'; version is ignored for local-source skills \
2704                             (using 'local')"
2705                        );
2706                    }
2707                    "local".to_string()
2708                } else {
2709                    artifact
2710                        .version
2711                        .filter(|s| !s.trim().is_empty())
2712                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
2713                            index,
2714                            message: "missing required field 'version'".to_string(),
2715                        })?
2716                };
2717
2718                // Per-artifact `capabilities:` is enforced on every role that actually
2719                // executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
2720                // below the capsule ceiling). A `skill` has no execution surface, so the
2721                // block would be silently ignored there; that reads like a scoped artifact
2722                // when it is not, hence a parse error rather than a no-op.
2723                let capabilities = match artifact.capabilities {
2724                    None => None,
2725                    Some(raw_caps) => {
2726                        if runtime == ArtifactRuntime::Skill {
2727                            return Err(RuntimeManifestError::InvalidArtifact {
2728                                index,
2729                                message: format!(
2730                                    "artifact '{name}' declares per-artifact 'capabilities:' but \
2731                                     has 'runtime: {}'; per-artifact capabilities are only \
2732                                     recognized on 'runtime: hook', 'runtime: tool', and \
2733                                     'runtime: driver' entries — use the capsule-wide top-level \
2734                                     'capabilities:' block instead",
2735                                    runtime.as_str()
2736                                ),
2737                            });
2738                        }
2739                        // `task_io` grants the `murmur:task-io/read` host import, which only
2740                        // hook components can be handed. On a tool or driver entry nothing
2741                        // would enforce it, and a silently-inert grant reads like a scoped
2742                        // artifact — the same reason `on_overflow:` below is a parse error off
2743                        // a hook.
2744                        if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
2745                            return Err(RuntimeManifestError::InvalidArtifact {
2746                                index,
2747                                message: format!(
2748                                    "artifact '{name}' declares 'capabilities.task_io:' but has \
2749                                     'runtime: {}'; the key grants the murmur:task-io/read host \
2750                                     import and is only recognized on 'runtime: hook' entries",
2751                                    runtime.as_str()
2752                                ),
2753                            });
2754                        }
2755                        // `conversation` grants the `murmur:conversation/read` host import, on the
2756                        // same terms as `task_io` above: only a hook component's world can import
2757                        // it, so on any other role the grant would be silently inert.
2758                        if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
2759                            return Err(RuntimeManifestError::InvalidArtifact {
2760                                index,
2761                                message: format!(
2762                                    "artifact '{name}' declares 'capabilities.conversation:' but \
2763                                     has 'runtime: {}'; the key grants the \
2764                                     murmur:conversation/read host import and is only recognized \
2765                                     on 'runtime: hook' entries",
2766                                    runtime.as_str()
2767                                ),
2768                            });
2769                        }
2770                        parse_capabilities(Some(raw_caps))?
2771                    }
2772                };
2773
2774                // `on_overflow:` governs an async hook's job queue, which only exists for
2775                // `runtime: hook`. On any other role it would be silently ignored, so it is
2776                // a parse error for the same reason per-artifact `capabilities:` is on a
2777                // skill. It stays legal on a *blocking* hook entry (simply inert): the
2778                // execution mode lives in the artifact's own manifest and is unknown here.
2779                let on_overflow = match artifact.on_overflow.as_deref() {
2780                    None => HookOverflowPolicy::default(),
2781                    Some(raw) => {
2782                        if runtime != ArtifactRuntime::Hook {
2783                            return Err(RuntimeManifestError::InvalidArtifact {
2784                                index,
2785                                message: format!(
2786                                    "artifact '{name}' declares 'on_overflow:' but has \
2787                                     'runtime: {}'; the key governs an async hook's job queue \
2788                                     and is only recognized on 'runtime: hook' entries",
2789                                    runtime.as_str()
2790                                ),
2791                            });
2792                        }
2793                        match raw {
2794                            "drop" => HookOverflowPolicy::Drop,
2795                            "block" => HookOverflowPolicy::Block,
2796                            other => {
2797                                return Err(RuntimeManifestError::InvalidArtifact {
2798                                    index,
2799                                    message: format!(
2800                                        "artifact '{name}' declares unknown on_overflow \
2801                                         '{other}'; expected: drop, block"
2802                                    ),
2803                                });
2804                            }
2805                        }
2806                    }
2807                };
2808
2809                // `config:` reaches an artifact through that artifact's own grant, and a skill
2810                // holds no grant and runs no component — nothing would ever deliver it. Refused
2811                // rather than ignored, for the reason the two gates above are: a key that reads
2812                // like configuration and is silently dropped is worse than one that is refused.
2813                // The block's *shape* is the runtime's business, not this parser's; only the
2814                // role is decided here.
2815                if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
2816                    return Err(RuntimeManifestError::InvalidArtifact {
2817                        index,
2818                        message: format!(
2819                            "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
2820                             is recognized only on 'runtime: hook', 'runtime: tool', and \
2821                             'runtime: driver' entries",
2822                            runtime.as_str()
2823                        ),
2824                    });
2825                }
2826
2827                Ok(RuntimeArtifact {
2828                    name,
2829                    version,
2830                    runtime,
2831                    source,
2832                    local_source,
2833                    prompt_payload,
2834                    capabilities,
2835                    on_overflow,
2836                    config: artifact.config,
2837                })
2838            })
2839            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
2840
2841        // The capsule-wide block reaches capsule, tool and driver components, none of which
2842        // can be handed the murmur:task-io/read import. Rejected rather than ignored, on the
2843        // same terms as declaring it on a non-hook artifact entry.
2844        if raw
2845            .capabilities
2846            .as_ref()
2847            .is_some_and(|caps| caps.task_io.is_some())
2848        {
2849            return Err(RuntimeManifestError::InvalidCapabilities {
2850                field: "capabilities.task_io".to_string(),
2851                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
2852                          capsule-wide capabilities block — the grant is per-hook"
2853                    .to_string(),
2854            });
2855        }
2856        // Config is delivered on one artifact's own grant, so there is no capsule-wide form of
2857        // it: the capsule's guest holds no artifact grant, and a top-level block would reach no
2858        // component at all. Refused rather than ignored, on the same terms as the capsule-wide
2859        // `task_io` block above.
2860        if raw.config.is_some() {
2861            return Err(RuntimeManifestError::InvalidCapabilities {
2862                field: "config".to_string(),
2863                message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
2864                          'runtime: driver' entry that reads it — there is no capsule-wide \
2865                          config block"
2866                    .to_string(),
2867            });
2868        }
2869        let capabilities = parse_capabilities(raw.capabilities)?;
2870        let inference = parse_inference(raw.inference, secrets)?;
2871
2872        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
2873        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
2874        if let Some(ref sp_art) = inference
2875            .as_ref()
2876            .and_then(|i| i.system_prompt_artifact.clone())
2877        {
2878            let matching = artifacts.iter().find(|a| &a.name == sp_art);
2879            match matching {
2880                None => {
2881                    return Err(RuntimeManifestError::InvalidInferenceConfig {
2882                        field: "inference.system_prompt_artifact".to_string(),
2883                        message: format!("artifact '{sp_art}' is not declared in artifacts:"),
2884                    });
2885                }
2886                Some(a) if !a.prompt_payload => {
2887                    return Err(RuntimeManifestError::InvalidInferenceConfig {
2888                        field: "inference.system_prompt_artifact".to_string(),
2889                        message: format!(
2890                            "artifact '{sp_art}' (runtime: {}) does not declare \
2891                             'prompt_payload: true', which system_prompt_artifact requires",
2892                            a.runtime.as_str()
2893                        ),
2894                    });
2895                }
2896                Some(_) => {} // valid
2897            }
2898        }
2899        let context = parse_context(raw.context)?;
2900        let observability = parse_observability(raw.observability);
2901        let trace = raw
2902            .trace
2903            .map(|raw_trace| {
2904                let capture = resolve_trace_capture(
2905                    raw_trace.capture.as_deref(),
2906                    raw_trace.include_tool_output,
2907                )?;
2908                let retain = parse_trace_retain(raw_trace.retain)?;
2909                Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
2910            })
2911            .transpose()?;
2912        let network = raw.network.map(|n| NetworkConfig {
2913            internal_port: n.internal_port,
2914        });
2915        let lifecycle = raw.lifecycle.map(|raw_lc| {
2916            let defaults = LifecycleConfig::default();
2917            LifecycleConfig {
2918                task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
2919                after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
2920                queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
2921                input_timeout_secs: raw_lc.input_timeout_secs,
2922                conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
2923                max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
2924                shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
2925                delegation_deadline_secs: raw_lc
2926                    .delegation_deadline_secs
2927                    .unwrap_or(defaults.delegation_deadline_secs),
2928            }
2929        });
2930
2931        let exports = parse_exports(raw.exports)?;
2932
2933        Ok(Self {
2934            name,
2935            version,
2936            artifacts,
2937            capabilities,
2938            inference,
2939            context,
2940            observability,
2941            trace,
2942            network,
2943            lifecycle,
2944            exports,
2945            mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
2946            unknown_keys,
2947        })
2948    }
2949}
2950
2951/// Lowers the top-level `exports:` block, rejecting anything the runtime would otherwise have to
2952/// decide about later.
2953///
2954/// The `root` rules are checked here and never again by a caller: a gateway that "cleans up" a
2955/// path is a gateway that has taken over the containment boundary, so the only legal answer to a
2956/// root naming somewhere outside the workdir is a refusal.
2957fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
2958    let Some(raw_exports) = raw else {
2959        return Ok(None);
2960    };
2961    let files = raw_exports.files.map(parse_file_export).transpose()?;
2962    let peer_files = raw_exports
2963        .peer_files
2964        .map(parse_peer_files_export)
2965        .transpose()?;
2966    Ok(Some(Exports { files, peer_files }))
2967}
2968
2969/// Lowers `exports.peer_files`, on exactly the same terms as [`parse_file_export`]: a root that
2970/// could name somewhere outside the workdir is refused here and never repaired later.
2971///
2972/// `max_ttl` is left as the operator declared it, including absent. Whether absent is legal
2973/// depends on `lifecycle.after_task`, which is a different block of the same manifest and a
2974/// different kind of question — one about what survives teardown rather than about whether a
2975/// value is well-formed. The runtime asks it, at launch, and refuses with `E-CAP-008`.
2976fn parse_peer_files_export(
2977    raw: RawPeerFilesExport,
2978) -> Result<PeerFilesExport, RuntimeManifestError> {
2979    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
2980        field: field.to_string(),
2981        message,
2982    };
2983
2984    let root = raw
2985        .root
2986        .map(|root| root.trim().to_string())
2987        .filter(|root| !root.is_empty())
2988        .ok_or_else(|| {
2989            invalid(
2990                "exports.peer_files.root",
2991                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
2992            )
2993        })?;
2994    check_export_root_shape("exports.peer_files.root", &root)?;
2995
2996    let max_ttl_secs = match raw.max_ttl {
2997        None => None,
2998        Some(value) => {
2999            let text = scalar_text(&value).ok_or_else(|| {
3000                invalid(
3001                    "exports.peer_files.max_ttl",
3002                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
3003                )
3004            })?;
3005            let parsed = parse_duration_secs(&text)
3006                .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
3007            if parsed == 0 {
3008                return Err(invalid(
3009                    "exports.peer_files.max_ttl",
3010                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
3011                ));
3012            }
3013            Some(parsed)
3014        }
3015    };
3016
3017    let max_bytes = match raw.max_bytes {
3018        None => DEFAULT_PEER_FILES_MAX_BYTES,
3019        Some(value) => {
3020            let text = scalar_text(&value).ok_or_else(|| {
3021                invalid(
3022                    "exports.peer_files.max_bytes",
3023                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
3024                )
3025            })?;
3026            let parsed = parse_byte_size(&text)
3027                .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
3028            if parsed == 0 {
3029                return Err(invalid(
3030                    "exports.peer_files.max_bytes",
3031                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
3032                ));
3033            }
3034            parsed
3035        }
3036    };
3037
3038    Ok(PeerFilesExport {
3039        root,
3040        max_ttl_secs,
3041        max_bytes,
3042    })
3043}
3044
3045/// A YAML scalar as the text the byte-size and duration parsers expect, or `None` for a mapping
3046/// or a sequence — a shape neither parser could report on without quoting the whole node.
3047/// `Some` for a key that is present, whatever its value — including an explicit null.
3048///
3049/// The plain `Option<serde_yaml::Value>` a `#[serde(default)]` field gets collapses `retain:`
3050/// with nothing under it into the same `None` an absent key produces, and those two say opposite
3051/// things: absent means "keep everything", and empty is refused.
3052fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_yaml::Value>, D::Error>
3053where
3054    D: serde::Deserializer<'de>,
3055{
3056    serde_yaml::Value::deserialize(deserializer).map(Some)
3057}
3058
3059fn scalar_text(value: &serde_yaml::Value) -> Option<String> {
3060    match value {
3061        serde_yaml::Value::String(text) => Some(text.clone()),
3062        serde_yaml::Value::Number(number) => Some(number.to_string()),
3063        _ => None,
3064    }
3065}
3066
3067/// The shared `root` shape rule: relative, no `..`, no absolute prefix. Both export blocks
3068/// resolve their root against the same accessible workdir, so both refuse the same shapes with
3069/// the same sentence.
3070fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
3071    for component in Path::new(root).components() {
3072        match component {
3073            std::path::Component::Normal(_) | std::path::Component::CurDir => {}
3074            std::path::Component::ParentDir => {
3075                return Err(RuntimeManifestError::InvalidExports {
3076                    field: field.to_string(),
3077                    message: format!(
3078                        "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
3079                    ),
3080                });
3081            }
3082            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
3083                return Err(RuntimeManifestError::InvalidExports {
3084                    field: field.to_string(),
3085                    message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
3086                });
3087            }
3088        }
3089    }
3090    Ok(())
3091}
3092
3093fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
3094    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
3095        field: field.to_string(),
3096        message,
3097    };
3098
3099    let root = raw
3100        .root
3101        .map(|root| root.trim().to_string())
3102        .filter(|root| !root.is_empty())
3103        .ok_or_else(|| {
3104            invalid(
3105                "exports.files.root",
3106                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
3107            )
3108        })?;
3109    check_export_root_shape("exports.files.root", &root)?;
3110
3111    let mode = match raw.mode.as_deref().map(str::trim) {
3112        None | Some("") => {
3113            return Err(invalid(
3114                "exports.files.mode",
3115                "is required and must be 'read-only'".to_string(),
3116            ));
3117        }
3118        Some("read-only") => ExportMode::ReadOnly,
3119        Some(other) => {
3120            return Err(invalid(
3121                "exports.files.mode",
3122                format!("'{other}' must be 'read-only'"),
3123            ));
3124        }
3125    };
3126
3127    let max_bytes = match raw.max_bytes {
3128        None => DEFAULT_EXPORT_MAX_BYTES,
3129        Some(value) => {
3130            let Some(text) = scalar_text(&value) else {
3131                return Err(invalid(
3132                    "exports.files.max_bytes",
3133                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
3134                ));
3135            };
3136            let parsed = parse_byte_size(&text)
3137                .map_err(|message| invalid("exports.files.max_bytes", message))?;
3138            if parsed == 0 {
3139                return Err(invalid(
3140                    "exports.files.max_bytes",
3141                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
3142                ));
3143            }
3144            parsed
3145        }
3146    };
3147
3148    Ok(FileExport {
3149        root,
3150        mode,
3151        max_bytes,
3152    })
3153}
3154
3155/// The accepted spelling of `exports.files.root`, stated once so every rejection says the same
3156/// thing about what a legal root looks like.
3157const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
3158
3159/// The accepted spelling of `capabilities.peer_fetch.allow`, stated once.
3160pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
3161
3162/// Structural check on one network destination: an `http`/`https` URL with no path, query or
3163/// fragment, or a bare `host[:port]`.
3164///
3165/// Deliberately shape-only. The authoritative matcher is the runtime's network-rule parser, which
3166/// this crate cannot see; what this catches is the class of entry that could never become a rule
3167/// at all, at the line that wrote it rather than at launch.
3168fn check_network_destination_shape(entry: &str) -> Result<(), String> {
3169    let trimmed = entry.trim();
3170    if trimmed.is_empty() {
3171        return Err(format!(
3172            "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3173        ));
3174    }
3175
3176    if trimmed.contains("://") {
3177        let url = Url::parse(trimmed).map_err(|error| {
3178            format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
3179        })?;
3180        let scheme = url.scheme().to_ascii_lowercase();
3181        if scheme != "http" && scheme != "https" {
3182            return Err(format!(
3183                "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
3184                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3185            ));
3186        }
3187        if url.host_str().is_none_or(str::is_empty) {
3188            return Err(format!(
3189                "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3190            ));
3191        }
3192        if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
3193            return Err(format!(
3194                "'{entry}' must not include a path, query or fragment; \
3195                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3196            ));
3197        }
3198        return Ok(());
3199    }
3200
3201    if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
3202        return Err(format!(
3203            "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3204        ));
3205    }
3206    // Split from the right so an IPv6 literal's own colons are not mistaken for a port separator.
3207    if let Some((host, port)) = trimmed.rsplit_once(':') {
3208        if !host.ends_with(']') {
3209            if host.is_empty() {
3210                return Err(format!(
3211                    "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3212                ));
3213            }
3214            if port.parse::<u16>().is_err() {
3215                return Err(format!(
3216                    "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3217                ));
3218            }
3219        }
3220    }
3221    Ok(())
3222}
3223
3224fn parse_capabilities(
3225    raw: Option<RawCapabilities>,
3226) -> Result<Option<Capabilities>, RuntimeManifestError> {
3227    let Some(raw_caps) = raw else {
3228        return Ok(None);
3229    };
3230
3231    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
3232        allow: raw_network.allow,
3233        unix_sockets: raw_network.unix_sockets,
3234    });
3235
3236    let peer_fetch = raw_caps
3237        .peer_fetch
3238        .map(|raw_peer_fetch| {
3239            if raw_peer_fetch.allow.is_empty() {
3240                return Err(RuntimeManifestError::InvalidCapabilities {
3241                    field: "capabilities.peer_fetch.allow".to_string(),
3242                    message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
3243                });
3244            }
3245            for entry in &raw_peer_fetch.allow {
3246                check_network_destination_shape(entry).map_err(|message| {
3247                    RuntimeManifestError::InvalidCapabilities {
3248                        field: "capabilities.peer_fetch.allow".to_string(),
3249                        message,
3250                    }
3251                })?;
3252            }
3253            Ok(PeerFetchCapabilities {
3254                allow: raw_peer_fetch.allow,
3255            })
3256        })
3257        .transpose()?;
3258
3259    let filesystem = raw_caps
3260        .filesystem
3261        .map(|raw_filesystem| FilesystemCapabilities {
3262            scope: raw_filesystem.scope.and_then(|scope| {
3263                let trimmed = scope.trim();
3264                (!trimmed.is_empty()).then(|| trimmed.to_string())
3265            }),
3266            // Copied straight across: a bool has no empty/whitespace case to normalise, and there
3267            // is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
3268            // refusal when `capabilities.containment: scoped` is also declared) belongs to the
3269            // runtime's containment layer, not to manifest parsing.
3270            workdir_exec: raw_filesystem.workdir_exec,
3271            // Normalised on exactly the terms `scope` above is: trimmed, and an entry left empty
3272            // by that trim is dropped rather than carried as a rule covering nothing. Shape is
3273            // not judged here either — an absolute or escaping entry is the runtime's refusal.
3274            read_only: raw_filesystem
3275                .read_only
3276                .into_iter()
3277                .filter_map(|entry| {
3278                    let trimmed = entry.trim();
3279                    (!trimmed.is_empty()).then(|| trimmed.to_string())
3280                })
3281                .collect(),
3282        });
3283
3284    let shell = raw_caps
3285        .shell
3286        .map(|raw_shell| {
3287            if raw_shell.allow.is_empty() {
3288                return Err(RuntimeManifestError::InvalidCapabilities {
3289                    field: "capabilities.shell.allow".to_string(),
3290                    message: "must contain at least one binary".to_string(),
3291                });
3292            }
3293
3294            let interpreter_runtime =
3295                parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
3296            // Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
3297            // already-validated grant list to test against.
3298            let staged_runtime = parse_staged_runtime(
3299                &raw_shell.allow,
3300                &interpreter_runtime,
3301                raw_shell.staged_runtime,
3302            )?;
3303
3304            Ok(ShellCapabilities {
3305                allow: raw_shell.allow,
3306                strip_env: raw_shell.strip_env,
3307                baseline_env: raw_shell.baseline_env,
3308                interpreter_runtime,
3309                staged_runtime,
3310            })
3311        })
3312        .transpose()?;
3313
3314    let spawn = raw_caps.spawn.map(|raw_spawn| SpawnCapabilities {
3315        allow: raw_spawn.allow,
3316    });
3317
3318    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
3319        allow: raw_env.allow,
3320    });
3321
3322    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
3323
3324    let resources = raw_caps
3325        .resources
3326        .map(parse_resource_capabilities)
3327        .transpose()?;
3328
3329    let state = raw_caps.state.map(parse_state_capabilities);
3330
3331    let task_io = raw_caps
3332        .task_io
3333        .map(|raw_task_io| {
3334            raw_task_io
3335                .read
3336                .map(|read| TaskIoCapabilities { read })
3337                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3338                    field: "capabilities.task_io.read".to_string(),
3339                    message: "must be set explicitly to true or false — a capability is never \
3340                              inferred"
3341                        .to_string(),
3342                })
3343        })
3344        .transpose()?;
3345
3346    let conversation = raw_caps
3347        .conversation
3348        .map(|raw_conversation| {
3349            raw_conversation
3350                .read
3351                .map(|read| ConversationCapabilities { read })
3352                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3353                    field: "capabilities.conversation.read".to_string(),
3354                    message: "must be set explicitly to true or false — a capability is never \
3355                              inferred"
3356                        .to_string(),
3357                })
3358        })
3359        .transpose()?;
3360
3361    let plan = raw_caps
3362        .plan
3363        .map(|raw_plan| {
3364            raw_plan
3365                .submit
3366                .map(|submit| PlanCapabilities { submit })
3367                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3368                    field: "capabilities.plan.submit".to_string(),
3369                    message: "must be set explicitly to true or false — a capability is never \
3370                              inferred"
3371                        .to_string(),
3372                })
3373        })
3374        .transpose()?;
3375
3376    let containment = raw_caps
3377        .containment
3378        .as_deref()
3379        .map(str::trim)
3380        .filter(|value| !value.is_empty())
3381        .map(|value| {
3382            value.parse::<ContainmentClass>().map_err(|error| {
3383                RuntimeManifestError::InvalidCapabilities {
3384                    field: "capabilities.containment".to_string(),
3385                    message: error.to_string(),
3386                }
3387            })
3388        })
3389        .transpose()?;
3390
3391    Ok(Some(Capabilities {
3392        network,
3393        peer_fetch,
3394        filesystem,
3395        shell,
3396        spawn,
3397        env,
3398        limits,
3399        resources,
3400        state,
3401        task_io,
3402        conversation,
3403        plan,
3404        containment,
3405    }))
3406}
3407
3408/// Lower `capabilities.state`. Infallible: the only field is a name, and a name's *shape* is a
3409/// runtime question (does it resolve to one directory under `~/.murmur/state/`?) rather than a
3410/// manifest-parse one — `capsule_runtime::state_store::validate_store_name` answers it and refuses
3411/// with `E-CAP-009`.
3412///
3413/// Surrounding whitespace is trimmed but an emptied value is *kept*, unlike
3414/// `capabilities.filesystem.scope`, which normalises empty to absent. `store: ""` is a written
3415/// declaration that names nothing, and silently defaulting it to the capsule name would hand a
3416/// store to someone who asked for a different one; it reaches the runtime and is refused there.
3417fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
3418    StateCapabilities {
3419        store: raw.store.map(|store| store.trim().to_string()),
3420    }
3421}
3422
3423/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
3424/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
3425/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
3426/// expand a whole install prefix: each grant names specific directories, and each directory
3427/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
3428fn parse_interpreter_runtime(
3429    allow: &[String],
3430    raw_grants: Vec<RawInterpreterRuntimeGrant>,
3431) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
3432    let mut grants = Vec::with_capacity(raw_grants.len());
3433    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
3434        let base = format!("capabilities.shell.interpreter_runtime[{index}]");
3435
3436        let binary = raw_grant
3437            .binary
3438            .filter(|b| !b.trim().is_empty())
3439            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3440                field: format!("{base}.binary"),
3441                message: "must name a binary".to_string(),
3442            })?;
3443
3444        // The whole point of this mechanism: it narrows filesystem access alongside an exec
3445        // grant that already exists. It can never itself grant exec, so the binary must already
3446        // be allowlisted in this same block's `allow`.
3447        if !allow.contains(&binary) {
3448            return Err(RuntimeManifestError::InvalidCapabilities {
3449                field: format!("{base}.binary"),
3450                message: format!(
3451                    "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
3452                     only narrow filesystem access for an already-allowlisted binary, never \
3453                     grant exec"
3454                ),
3455            });
3456        }
3457
3458        if raw_grant.dirs.is_empty() {
3459            return Err(RuntimeManifestError::InvalidCapabilities {
3460                field: format!("{base}.dirs"),
3461                message: "must name at least one directory".to_string(),
3462            });
3463        }
3464
3465        let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
3466        for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
3467            let dir_base = format!("{base}.dirs[{dir_index}]");
3468
3469            let path = raw_dir
3470                .path
3471                .filter(|p| !p.trim().is_empty())
3472                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3473                    field: format!("{dir_base}.path"),
3474                    message: "must name a host directory".to_string(),
3475                })?;
3476            if !path.starts_with('/') {
3477                return Err(RuntimeManifestError::InvalidCapabilities {
3478                    field: format!("{dir_base}.path"),
3479                    message: format!(
3480                        "'{path}' must be an absolute host path (start with '/') — these are \
3481                         filesystem paths outside the workdir"
3482                    ),
3483                });
3484            }
3485
3486            let list_dir =
3487                raw_dir
3488                    .list_dir
3489                    .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3490                        field: format!("{dir_base}.list_dir"),
3491                        message: format!(
3492                            "'{path}' must set list_dir explicitly to true or false — \
3493                         enumerability is never inferred"
3494                        ),
3495                    })?;
3496
3497            dirs.push(InterpreterRuntimeDir { path, list_dir });
3498        }
3499
3500        grants.push(InterpreterRuntimeGrant { binary, dirs });
3501    }
3502
3503    Ok(grants)
3504}
3505
3506/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
3507/// and its already-validated `interpreter_runtime` grants. Every rejection is a
3508/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
3509/// [`parse_interpreter_runtime`] and the rest of this file.
3510///
3511/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
3512/// loudly wrong here:
3513///
3514///   1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
3515///   2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
3516///      and declaring both means the author expects a host-widening grant that a composed root
3517///      makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
3518///   3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
3519///      launch host and not relative to anything the runtime knows.
3520///   4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
3521///      so an absent one defeats the field's only purpose.
3522///
3523/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
3524/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
3525/// will never run it (`mur build` on a laptop for a Linux fleet).
3526fn parse_staged_runtime(
3527    allow: &[String],
3528    interpreter_runtime: &[InterpreterRuntimeGrant],
3529    raw_grants: Vec<RawStagedRuntimeGrant>,
3530) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
3531    let mut grants = Vec::with_capacity(raw_grants.len());
3532    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
3533        let base = format!("capabilities.shell.staged_runtime[{index}]");
3534
3535        let binary = raw_grant
3536            .binary
3537            .filter(|b| !b.trim().is_empty())
3538            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3539                field: format!("{base}.binary"),
3540                message: "must name a binary".to_string(),
3541            })?;
3542
3543        if !allow.contains(&binary) {
3544            return Err(RuntimeManifestError::InvalidCapabilities {
3545                field: format!("{base}.binary"),
3546                message: format!(
3547                    "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
3548                     stage the runtime tree behind an already-allowlisted binary, never grant exec"
3549                ),
3550            });
3551        }
3552
3553        if interpreter_runtime
3554            .iter()
3555            .any(|grant| grant.binary == binary)
3556        {
3557            return Err(RuntimeManifestError::InvalidCapabilities {
3558                field: format!("{base}.binary"),
3559                message: format!(
3560                    "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
3561                     are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
3562                     tree into the capsule's own root, which is what makes widening the capsule's \
3563                     host filesystem scope unnecessary rather than something to add to it. Remove \
3564                     the interpreter_runtime grant for '{binary}'"
3565                ),
3566            });
3567        }
3568
3569        let source_path = raw_grant
3570            .source_path
3571            .filter(|p| !p.trim().is_empty())
3572            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3573                field: format!("{base}.source_path"),
3574                message: "must name the host directory holding the pinned runtime tree".to_string(),
3575            })?;
3576        if !source_path.starts_with('/') {
3577            return Err(RuntimeManifestError::InvalidCapabilities {
3578                field: format!("{base}.source_path"),
3579                message: format!(
3580                    "'{source_path}' must be an absolute host path (start with '/') — \
3581                     staged-runtime sources are host filesystem paths outside the workdir"
3582                ),
3583            });
3584        }
3585
3586        let pin = raw_grant
3587            .pin
3588            .map(|p| p.trim().to_string())
3589            .filter(|p| !p.is_empty())
3590            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3591                field: format!("{base}.pin"),
3592                message: format!(
3593                    "'{source_path}' must declare a non-empty pin identifying which build this \
3594                     tree is — it is never inferred, because it is the value a human compares \
3595                     across two hosts to confirm the same runtime shipped to both"
3596                ),
3597            })?;
3598
3599        grants.push(StagedRuntimeGrant {
3600            binary,
3601            source_path,
3602            pin,
3603        });
3604    }
3605
3606    Ok(grants)
3607}
3608
3609/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
3610/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
3611/// catching it here means `mur run` fails at parse time with a field name rather than
3612/// surfacing much later as an opaque wasm trap.
3613fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
3614    let reject_zero_usize =
3615        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
3616            match value {
3617                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
3618                    field: format!("capabilities.limits.{field}"),
3619                    message: "must be greater than zero".to_string(),
3620                }),
3621                _ => Ok(()),
3622            }
3623        };
3624
3625    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
3626    reject_zero_usize(raw.table_elements, "table_elements")?;
3627    reject_zero_usize(raw.instances, "instances")?;
3628    if raw.deadline_seconds == Some(0) {
3629        return Err(RuntimeManifestError::InvalidCapabilities {
3630            field: "capabilities.limits.deadline_seconds".to_string(),
3631            message: "must be greater than zero".to_string(),
3632        });
3633    }
3634
3635    Ok(ResourceLimits {
3636        memory_bytes: raw.memory_bytes,
3637        table_elements: raw.table_elements,
3638        instances: raw.instances,
3639        deadline_seconds: raw.deadline_seconds,
3640    })
3641}
3642
3643/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
3644/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
3645/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
3646/// never what an author means, and letting it through would turn a typo into a subprocess that
3647/// cannot fork, open a file, or run for a single CPU-second.
3648fn parse_resource_capabilities(
3649    raw: RawResourceCapabilities,
3650) -> Result<ResourceCapabilities, RuntimeManifestError> {
3651    let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
3652        match value {
3653            Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
3654                field: format!("capabilities.resources.{field}"),
3655                message: "must be greater than zero".to_string(),
3656            }),
3657            _ => Ok(()),
3658        }
3659    };
3660
3661    reject_zero(raw.max_processes, "max_processes")?;
3662    reject_zero(raw.max_open_files, "max_open_files")?;
3663    reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
3664    reject_zero(raw.cpu_seconds, "cpu_seconds")?;
3665    reject_zero(raw.memory_bytes, "memory_bytes")?;
3666    reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
3667    reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
3668    reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
3669    reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
3670    reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
3671
3672    Ok(ResourceCapabilities {
3673        max_processes: raw.max_processes,
3674        max_open_files: raw.max_open_files,
3675        max_file_size_bytes: raw.max_file_size_bytes,
3676        cpu_seconds: raw.cpu_seconds,
3677        memory_bytes: raw.memory_bytes,
3678        cgroup_memory_bytes: raw.cgroup_memory_bytes,
3679        cgroup_pids_max: raw.cgroup_pids_max,
3680        cgroup_cpu_percent: raw.cgroup_cpu_percent,
3681        cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
3682        workdir_max_bytes: raw.workdir_max_bytes,
3683    })
3684}
3685
3686fn parse_inference(
3687    raw: Option<RawInferenceConfig>,
3688    secrets: SecretResolution,
3689) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
3690    let Some(raw) = raw else {
3691        return Ok(None);
3692    };
3693
3694    let transport = raw
3695        .transport
3696        .map(|s| s.trim().to_string())
3697        .filter(|s| !s.is_empty())
3698        .unwrap_or_else(|| "http".to_string());
3699
3700    let compaction = parse_compaction(raw.compaction)?;
3701    let system_prompt = optional_trimmed_string(raw.system_prompt);
3702    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
3703    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
3704
3705    let prompt_sources_set = [
3706        system_prompt.is_some(),
3707        system_prompt_file.is_some(),
3708        system_prompt_artifact.is_some(),
3709    ]
3710    .iter()
3711    .filter(|&&b| b)
3712    .count();
3713    if prompt_sources_set > 1 {
3714        return Err(RuntimeManifestError::InvalidInferenceConfig {
3715            field: "inference.system_prompt".to_string(),
3716            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
3717                      may be set"
3718                .to_string(),
3719        });
3720    }
3721
3722    let max_turns = match raw.max_turns {
3723        None => 10,
3724        Some(0) => {
3725            return Err(RuntimeManifestError::InvalidInferenceConfig {
3726                field: "inference.max_turns".to_string(),
3727                message: "must be greater than 0".to_string(),
3728            });
3729        }
3730        Some(n) => n,
3731    };
3732
3733    // The reopen budget is a lifecycle knob, read from `lifecycle.max_task_reopens`. Nothing
3734    // here consumes the `inference` spelling, so it would be silently inert — reject it rather
3735    // than let it look effective.
3736    if raw.max_task_reopens.is_some() {
3737        return Err(RuntimeManifestError::InvalidInferenceConfig {
3738            field: "inference.max_task_reopens".to_string(),
3739            message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
3740                .to_string(),
3741        });
3742    }
3743
3744    match transport.as_str() {
3745        "http" => {
3746            let endpoint = required_inference_field(raw.endpoint, "endpoint")?;
3747            validate_inference_endpoint(&endpoint)?;
3748            let model = required_inference_field(raw.model, "model")?;
3749
3750            let api_key = match (raw.api_key, secrets) {
3751                (None, _) | (Some(_), SecretResolution::Skip) => None,
3752                (Some(value), SecretResolution::Resolve) => {
3753                    let trimmed = value.trim();
3754                    if trimmed.is_empty() {
3755                        None
3756                    } else {
3757                        Some(match parse_env_reference(trimmed) {
3758                            Some(name) => ApiKeyReference::Environment(name.to_string()),
3759                            None => ApiKeyReference::Literal(trimmed.to_string()),
3760                        })
3761                    }
3762                }
3763            };
3764
3765            let driver_raw = raw.driver.or(raw.provider).ok_or_else(|| {
3766                RuntimeManifestError::InvalidInferenceConfig {
3767                    field: "inference.driver".to_string(),
3768                    message: "missing required field".to_string(),
3769                }
3770            })?;
3771            let driver = parse_inference_driver(driver_raw)?;
3772
3773            // Advisory only: catch an authoring typo at parse time rather than as a confusing
3774            // provider 400. Large values are deliberately NOT clamped — a ceiling here would
3775            // block a model whose real limit is higher than anything we could hard-code.
3776            if raw.max_tokens == Some(0) {
3777                return Err(RuntimeManifestError::InvalidInferenceConfig {
3778                    field: "inference.max_tokens".to_string(),
3779                    message: "must be greater than 0".to_string(),
3780                });
3781            }
3782            if raw.max_session_tokens == Some(0) {
3783                return Err(RuntimeManifestError::InvalidInferenceConfig {
3784                    field: "inference.max_session_tokens".to_string(),
3785                    message: "must be greater than 0".to_string(),
3786                });
3787            }
3788
3789            Ok(Some(InferenceConfig {
3790                transport,
3791                endpoint: Some(endpoint),
3792                model,
3793                api_key,
3794                driver: Some(driver),
3795                command: None,
3796                compaction,
3797                system_prompt,
3798                system_prompt_file,
3799                system_prompt_artifact,
3800                max_turns,
3801                max_tokens: raw.max_tokens,
3802                max_session_tokens: raw.max_session_tokens,
3803            }))
3804        }
3805        "process" => {
3806            // Fields that are invalid with transport: process
3807            if raw.driver.is_some() || raw.provider.is_some() {
3808                return Err(RuntimeManifestError::InvalidInferenceConfig {
3809                    field: "inference.driver.artifact".to_string(),
3810                    message: "is not valid with transport: process".to_string(),
3811                });
3812            }
3813            if raw
3814                .endpoint
3815                .as_ref()
3816                .map(|s| !s.trim().is_empty())
3817                .unwrap_or(false)
3818            {
3819                return Err(RuntimeManifestError::InvalidInferenceConfig {
3820                    field: "inference.endpoint".to_string(),
3821                    message: "is not valid with transport: process".to_string(),
3822                });
3823            }
3824            if raw
3825                .api_key
3826                .as_ref()
3827                .map(|s| !s.trim().is_empty())
3828                .unwrap_or(false)
3829            {
3830                return Err(RuntimeManifestError::InvalidInferenceConfig {
3831                    field: "inference.api_key".to_string(),
3832                    message: "is not valid with transport: process".to_string(),
3833                });
3834            }
3835            // The CLI subprocess path never builds a driver payload, so this value would be
3836            // silently inert here — reject it rather than let it look effective.
3837            if raw.max_tokens.is_some() {
3838                return Err(RuntimeManifestError::InvalidInferenceConfig {
3839                    field: "inference.max_tokens".to_string(),
3840                    message: "is not valid with transport: process".to_string(),
3841                });
3842            }
3843
3844            // murmur holds no key under this transport and sees no spend, so a ceiling here could
3845            // never be enforced.
3846            if raw.max_session_tokens.is_some() {
3847                return Err(RuntimeManifestError::InvalidInferenceConfig {
3848                    field: "inference.max_session_tokens".to_string(),
3849                    message: "is not valid with transport: process".to_string(),
3850                });
3851            }
3852
3853            let command = required_inference_field(raw.command, "command")?;
3854            // model is OPTIONAL for transport: process — an empty string means "use the CLI's
3855            // configured/account-default model" (e.g. a codex subscription's default; passing an
3856            // unsupported model there is a hard 400). The Claude dialect still needs a real model
3857            // and will surface the CLI's own error if given none.
3858            let model = optional_trimmed_string(raw.model).unwrap_or_default();
3859
3860            Ok(Some(InferenceConfig {
3861                transport,
3862                endpoint: None,
3863                model,
3864                api_key: None,
3865                driver: None,
3866                command: Some(command),
3867                compaction,
3868                system_prompt,
3869                system_prompt_file,
3870                system_prompt_artifact,
3871                max_turns,
3872                max_tokens: None,
3873                max_session_tokens: None,
3874            }))
3875        }
3876        other => Err(RuntimeManifestError::InvalidInferenceConfig {
3877            field: "inference.transport".to_string(),
3878            message: format!("unknown value '{other}'"),
3879        }),
3880    }
3881}
3882
3883fn parse_context(
3884    raw: Option<RawContextConfig>,
3885) -> Result<Option<ContextConfig>, RuntimeManifestError> {
3886    let Some(raw) = raw else {
3887        return Ok(None);
3888    };
3889
3890    if let Some(max_tokens) = raw.max_tokens {
3891        if max_tokens == 0 {
3892            return Err(RuntimeManifestError::InvalidInferenceConfig {
3893                field: "context.max_tokens".to_string(),
3894                message: "must be greater than 0".to_string(),
3895            });
3896        }
3897    }
3898
3899    let fraction = |field: &str, value: Option<f32>, default: f32| match value {
3900        None => Ok(default),
3901        Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
3902        Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
3903            field: field.to_string(),
3904            message: "must be between 0.0 and 1.0".to_string(),
3905        }),
3906    };
3907
3908    let record = match raw.record.as_deref().map(str::trim) {
3909        None => true,
3910        Some("on") => true,
3911        Some("off") => false,
3912        Some(other) => {
3913            return Err(RuntimeManifestError::InvalidInferenceConfig {
3914                field: "context.record".to_string(),
3915                message: format!("unknown value '{other}'; expected: on, off"),
3916            })
3917        }
3918    };
3919
3920    Ok(Some(ContextConfig {
3921        max_tokens: raw.max_tokens,
3922        record,
3923        record_store: raw.record_store.map(|store| store.trim().to_string()),
3924        seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
3925        seed_overflow_margin: fraction(
3926            "context.seed_overflow_margin",
3927            raw.seed_overflow_margin,
3928            DEFAULT_SEED_OVERFLOW_MARGIN,
3929        )?,
3930        retain: parse_context_retain(raw.retain)?,
3931    }))
3932}
3933
3934/// The keys `trace.retain` accepts, in the order an error lists them.
3935const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
3936
3937/// The keys `context.retain` accepts, in the order an error lists them.
3938const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
3939
3940/// What both `retain:` blocks say when they carry nothing. Stated once so the two errors, which
3941/// travel through different `RuntimeManifestError` variants, cannot drift apart.
3942const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
3943                                  keep everything, which is what no policy means";
3944
3945/// One `retain:` block, reduced to its two recognized keys.
3946///
3947/// Shared by `trace.retain` and `context.retain`: they differ only in the name of the count key,
3948/// so the shape check, the unknown-key refusal and the empty-block refusal are written once.
3949/// `count_key` is `max_sessions` or `max_messages`; `block` is the dotted path an error names.
3950fn parse_retain_block(
3951    block: &str,
3952    count_key: &str,
3953    accepted: &[&str],
3954    raw: serde_yaml::Value,
3955    invalid: &dyn Fn(String, String) -> RuntimeManifestError,
3956) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
3957    let mapping = match raw {
3958        serde_yaml::Value::Mapping(mapping) => mapping,
3959        // `retain:` with nothing under it parses as null and means exactly `retain: {}`.
3960        serde_yaml::Value::Null => {
3961            return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
3962        }
3963        other => {
3964            return Err(invalid(
3965                block.to_string(),
3966                format!(
3967                    "must be a block of keys ({}), got '{other:?}'",
3968                    accepted.join(", ")
3969                ),
3970            ))
3971        }
3972    };
3973    if mapping.is_empty() {
3974        return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
3975    }
3976
3977    let mut count: Option<u32> = None;
3978    let mut max_age_secs: Option<u64> = None;
3979    for (key, value) in &mapping {
3980        let key = key.as_str().unwrap_or_default();
3981        let field = format!("{block}.{key}");
3982        if key == count_key {
3983            let parsed = value
3984                .as_u64()
3985                .and_then(|n| u32::try_from(n).ok())
3986                .ok_or_else(|| {
3987                    invalid(
3988                        field.clone(),
3989                        "must be a whole number of at least 1".to_string(),
3990                    )
3991                })?;
3992            if parsed == 0 {
3993                return Err(invalid(
3994                    field,
3995                    "must be at least 1 — a limit of zero would delete what retention exists \
3996                     to keep; remove the key to leave it unbounded"
3997                        .to_string(),
3998                ));
3999            }
4000            count = Some(parsed);
4001        } else if key == "max_age" {
4002            let text = scalar_text(value).ok_or_else(|| {
4003                invalid(
4004                    field.clone(),
4005                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4006                )
4007            })?;
4008            let parsed =
4009                parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
4010            if parsed == 0 {
4011                return Err(invalid(
4012                    field,
4013                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4014                ));
4015            }
4016            max_age_secs = Some(parsed);
4017        } else {
4018            return Err(invalid(
4019                block.to_string(),
4020                format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
4021            ));
4022        }
4023    }
4024    Ok((count, max_age_secs))
4025}
4026
4027fn parse_trace_retain(
4028    raw: Option<serde_yaml::Value>,
4029) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
4030    let Some(raw) = raw else {
4031        return Ok(None);
4032    };
4033    let (max_sessions, max_age_secs) = parse_retain_block(
4034        "trace.retain",
4035        "max_sessions",
4036        &TRACE_RETAIN_KEYS,
4037        raw,
4038        &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
4039    )?;
4040    Ok(Some(TraceRetainConfig {
4041        max_sessions,
4042        max_age_secs,
4043    }))
4044}
4045
4046fn parse_context_retain(
4047    raw: Option<serde_yaml::Value>,
4048) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
4049    let Some(raw) = raw else {
4050        return Ok(None);
4051    };
4052    let (max_messages, max_age_secs) = parse_retain_block(
4053        "context.retain",
4054        "max_messages",
4055        &CONTEXT_RETAIN_KEYS,
4056        raw,
4057        &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
4058    )?;
4059    Ok(Some(ContextRetainConfig {
4060        max_messages,
4061        max_age_secs,
4062    }))
4063}
4064
4065fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
4066    raw.map(|raw| ObservabilityConfig {
4067        otel_endpoint: raw
4068            .otel_endpoint
4069            .map(|value| value.trim().to_string())
4070            .filter(|value| !value.is_empty()),
4071        eval: raw.eval.map(parse_eval_config),
4072    })
4073}
4074
4075fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
4076    let scorers = raw
4077        .scorers
4078        .into_iter()
4079        .filter_map(|raw| {
4080            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
4081            match raw.scorer_type.as_str() {
4082                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
4083                "max_turns" => Some(ScorerConfig::MaxTurns {
4084                    name,
4085                    max: raw.max.unwrap_or(10) as u32,
4086                }),
4087                "max_tokens" => Some(ScorerConfig::MaxTokens {
4088                    name,
4089                    max: raw.max.unwrap_or(100_000),
4090                }),
4091                "tool_sequence" => Some(ScorerConfig::ToolSequence {
4092                    name,
4093                    expected: raw.expected.unwrap_or_default(),
4094                }),
4095                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
4096                other => {
4097                    eprintln!("[murmur-artifact] unknown scorer type '{other}' — skipping");
4098                    None
4099                }
4100            }
4101        })
4102        .collect();
4103
4104    EvalConfig {
4105        dataset_id: raw.dataset_id,
4106        scorers,
4107    }
4108}
4109
4110fn parse_compaction(
4111    raw: Option<RawCompactionConfig>,
4112) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
4113    let Some(raw) = raw else {
4114        return Ok(None);
4115    };
4116
4117    if let Some(threshold) = raw.threshold {
4118        if threshold <= 0.0 || threshold > 1.0 {
4119            return Err(RuntimeManifestError::InvalidInferenceConfig {
4120                field: "inference.compaction.threshold".to_string(),
4121                message: format!("must be in (0.0, 1.0] but got {threshold}"),
4122            });
4123        }
4124    }
4125
4126    if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
4127        return Err(RuntimeManifestError::InvalidInferenceConfig {
4128            field: "inference.compaction.system_prompt".to_string(),
4129            message: "at most one of inference.compaction.system_prompt, \
4130                      inference.compaction.system_prompt_file may be set"
4131                .to_string(),
4132        });
4133    }
4134
4135    Ok(Some(CompactionConfig {
4136        threshold: raw.threshold,
4137        model: raw.model,
4138        system_prompt: raw.system_prompt,
4139        system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
4140        // A plain optional boolean: nothing to validate, and deliberately independent of the
4141        // threshold-range and prompt-source checks above.
4142        dump_summaries: raw.dump_summaries,
4143    }))
4144}
4145
4146fn optional_trimmed_string(value: Option<String>) -> Option<String> {
4147    value
4148        .map(|s| s.trim().to_string())
4149        .filter(|s| !s.is_empty())
4150}
4151
4152fn required_inference_field(
4153    value: Option<String>,
4154    key: &str,
4155) -> Result<String, RuntimeManifestError> {
4156    value
4157        .map(|value| value.trim().to_string())
4158        .filter(|value| !value.is_empty())
4159        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
4160            field: format!("inference.{key}"),
4161            message: "missing required field".to_string(),
4162        })
4163}
4164
4165/// Rejects `inference.endpoint` values that would let a manifest redirect the
4166/// inference trust root to an unencrypted, non-loopback host (murmur-security-assessment.md C-3).
4167///
4168/// Accepted: any `https://` URL, or `http://` URLs whose host is `localhost` or an
4169/// IP literal for which [`IpAddr::is_loopback`] returns true (e.g. `127.0.0.1`, `::1`).
4170/// Rejected: malformed URLs, unsupported schemes (anything but `http`/`https`), and
4171/// `http://` URLs whose host is not loopback.
4172fn validate_inference_endpoint(endpoint: &str) -> Result<(), RuntimeManifestError> {
4173    let url =
4174        Url::parse(endpoint).map_err(|source| RuntimeManifestError::InvalidInferenceConfig {
4175            field: "inference.endpoint".to_string(),
4176            message: format!("failed to parse '{endpoint}' as a URL: {source}"),
4177        })?;
4178
4179    match url.scheme() {
4180        "https" => Ok(()),
4181        "http" => {
4182            let host = url.host_str().unwrap_or("");
4183            let is_loopback = host == "localhost"
4184                || host
4185                    .parse::<IpAddr>()
4186                    .map(|ip| ip.is_loopback())
4187                    .unwrap_or(false);
4188
4189            if is_loopback {
4190                Ok(())
4191            } else {
4192                Err(RuntimeManifestError::InvalidInferenceConfig {
4193                    field: "inference.endpoint".to_string(),
4194                    message: format!(
4195                        "'{endpoint}' uses plain http:// with non-loopback host '{host}' — \
4196                         use https:// for remote endpoints, or http://localhost or \
4197                         a loopback IP literal for local inference"
4198                    ),
4199                })
4200            }
4201        }
4202        other => Err(RuntimeManifestError::InvalidInferenceConfig {
4203            field: "inference.endpoint".to_string(),
4204            message: format!(
4205                "unsupported scheme '{other}' in '{endpoint}' — expected http or https"
4206            ),
4207        }),
4208    }
4209}
4210
4211fn parse_inference_driver(
4212    raw: RawInferenceDriver,
4213) -> Result<InferenceDriver, RuntimeManifestError> {
4214    let artifact = raw
4215        .artifact
4216        .map(|value| value.trim().to_string())
4217        .filter(|value| !value.is_empty())
4218        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
4219            field: "inference.driver.artifact".to_string(),
4220            message: "missing required field".to_string(),
4221        })?;
4222
4223    let config = parse_inference_driver_config(raw.config)?;
4224
4225    Ok(InferenceDriver { artifact, config })
4226}
4227
4228fn parse_inference_driver_config(
4229    raw: Option<serde_yaml::Value>,
4230) -> Result<Option<String>, RuntimeManifestError> {
4231    let Some(raw) = raw else {
4232        return Ok(None);
4233    };
4234
4235    if matches!(raw, serde_yaml::Value::Null) {
4236        return Ok(None);
4237    }
4238
4239    if !matches!(raw, serde_yaml::Value::Mapping(_)) {
4240        return Err(RuntimeManifestError::InvalidInferenceConfig {
4241            field: "inference.driver.config".to_string(),
4242            message: "expected mapping/object".to_string(),
4243        });
4244    }
4245
4246    let value =
4247        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
4248            field: "inference.driver.config".to_string(),
4249            message: format!("failed to convert to JSON: {err}"),
4250        })?;
4251
4252    serde_json::to_string(&value).map(Some).map_err(|err| {
4253        RuntimeManifestError::InvalidInferenceConfig {
4254            field: "inference.driver.config".to_string(),
4255            message: format!("failed to encode JSON: {err}"),
4256        }
4257    })
4258}
4259
4260fn parse_env_reference(value: &str) -> Option<&str> {
4261    if !value.starts_with("${") || !value.ends_with('}') {
4262        return None;
4263    }
4264
4265    let variable = &value[2..value.len() - 1];
4266    is_valid_env_variable(variable).then_some(variable)
4267}
4268
4269fn is_valid_env_variable(value: &str) -> bool {
4270    let mut chars = value.chars();
4271    let Some(first) = chars.next() else {
4272        return false;
4273    };
4274
4275    if !(first == '_' || first.is_ascii_uppercase()) {
4276        return false;
4277    }
4278
4279    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
4280}
4281
4282#[cfg(test)]
4283mod tests {
4284    use super::*;
4285    use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
4286
4287    fn manifest_with_trace(block: &str) -> String {
4288        format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
4289    }
4290
4291    /// No `trace:` block at all leaves the config absent; the runtime substitutes
4292    /// [`TraceCapture::default`] rather than the manifest carrying a synthesized one.
4293    #[test]
4294    fn absent_trace_block_stays_absent_and_defaults_to_meta() {
4295        let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
4296        assert_eq!(manifest.trace, None);
4297        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
4298    }
4299
4300    #[test]
4301    fn each_capture_mode_parses_from_the_trace_block() {
4302        for (yaml, expected) in [
4303            ("none", TraceCapture::None),
4304            ("meta", TraceCapture::Meta),
4305            ("content", TraceCapture::Content),
4306        ] {
4307            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
4308                "  capture: {yaml}\n"
4309            )))
4310            .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
4311            assert_eq!(
4312                manifest.trace,
4313                Some(TraceConfig {
4314                    capture: expected,
4315                    retain: None
4316                })
4317            );
4318        }
4319    }
4320
4321    // ── retain ───────────────────────────────────────────────────────────────
4322
4323    /// A `trace:` block with only `capture` and a `context:` block with only `record` declare no
4324    /// retention at all: an absent `retain:` is the only way to say "keep everything", and it is
4325    /// what a capsule that never mentions retention gets.
4326    #[test]
4327    fn no_retain_block_anywhere_parses_as_no_policy() {
4328        let manifest = RuntimeManifest::from_yaml_str(
4329            "name: cap\nversion: 0.0.1\ntrace:\n  capture: content\ncontext:\n  record: on\n",
4330        )
4331        .unwrap();
4332        assert_eq!(manifest.trace.unwrap().retain, None);
4333        assert_eq!(manifest.context.unwrap().retain, None);
4334    }
4335
4336    #[test]
4337    fn trace_retain_parses_both_keys_and_the_day_suffix() {
4338        let manifest = RuntimeManifest::from_yaml_str(
4339            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 50\n    max_age: 14d\n",
4340        )
4341        .unwrap();
4342        assert_eq!(
4343            manifest.trace.unwrap().retain,
4344            Some(TraceRetainConfig {
4345                max_sessions: Some(50),
4346                max_age_secs: Some(14 * 86_400),
4347            })
4348        );
4349    }
4350
4351    #[test]
4352    fn context_retain_parses_both_keys_and_either_alone() {
4353        let both = RuntimeManifest::from_yaml_str(
4354            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n    max_messages: 2000\n    max_age: 90d\n",
4355        )
4356        .unwrap();
4357        assert_eq!(
4358            both.context.unwrap().retain,
4359            Some(ContextRetainConfig {
4360                max_messages: Some(2000),
4361                max_age_secs: Some(90 * 86_400),
4362            })
4363        );
4364
4365        let count_only = RuntimeManifest::from_yaml_str(
4366            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 10\n",
4367        )
4368        .unwrap();
4369        assert_eq!(
4370            count_only.context.unwrap().retain,
4371            Some(ContextRetainConfig {
4372                max_messages: Some(10),
4373                max_age_secs: None,
4374            })
4375        );
4376    }
4377
4378    /// An empty block is not a way to declare no policy — omitting the block is. Both spellings
4379    /// of empty are refused, naming the block an operator has to go and change.
4380    #[test]
4381    fn an_empty_retain_block_is_refused_naming_the_block() {
4382        for (yaml, expect_trace) in [
4383            (
4384                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain: {}\n",
4385                true,
4386            ),
4387            (
4388                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n",
4389                true,
4390            ),
4391            (
4392                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain: {}\n",
4393                false,
4394            ),
4395            (
4396                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n",
4397                false,
4398            ),
4399        ] {
4400            let err = RuntimeManifest::from_yaml_str(yaml)
4401                .expect_err("an empty retain block must be refused");
4402            let (field, message) = match (&err, expect_trace) {
4403                (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
4404                    (field, message)
4405                }
4406                (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
4407                    (field, message)
4408                }
4409                _ => panic!("wrong variant for {yaml:?}: {err:?}"),
4410            };
4411            assert_eq!(
4412                field,
4413                if expect_trace {
4414                    "trace.retain"
4415                } else {
4416                    "context.retain"
4417                }
4418            );
4419            assert!(message.contains("at least one key"), "{message}");
4420        }
4421    }
4422
4423    /// A limit of zero would delete what retention exists to keep, so it is
4424    /// refused naming the key rather than accepted as "keep nothing".
4425    #[test]
4426    fn a_zero_retain_limit_is_refused_naming_the_key() {
4427        let trace = RuntimeManifest::from_yaml_str(
4428            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 0\n",
4429        )
4430        .expect_err("max_sessions: 0 must be refused");
4431        assert!(
4432            matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
4433                     if field == "trace.retain.max_sessions"),
4434            "{trace:?}"
4435        );
4436
4437        let context = RuntimeManifest::from_yaml_str(
4438            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 0\n",
4439        )
4440        .expect_err("max_messages: 0 must be refused");
4441        assert!(
4442            matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
4443                     if field == "context.retain.max_messages"),
4444            "{context:?}"
4445        );
4446
4447        let age = RuntimeManifest::from_yaml_str(
4448            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_age: 0\n",
4449        )
4450        .expect_err("max_age: 0 must be refused");
4451        assert!(
4452            matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
4453                     if field == "trace.retain.max_age"),
4454            "{age:?}"
4455        );
4456    }
4457
4458    #[test]
4459    fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
4460        let err = RuntimeManifest::from_yaml_str(
4461            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_traces: 3\n",
4462        )
4463        .expect_err("an unknown key must be refused");
4464        match err {
4465            RuntimeManifestError::InvalidTraceConfig { field, message } => {
4466                assert_eq!(field, "trace.retain");
4467                assert!(message.contains("max_traces"), "{message}");
4468                assert!(message.contains("max_sessions"), "{message}");
4469            }
4470            other => panic!("expected InvalidTraceConfig, got {other:?}"),
4471        }
4472    }
4473
4474    #[test]
4475    fn an_unparseable_retain_duration_reports_the_accepted_form() {
4476        let err = RuntimeManifest::from_yaml_str(
4477            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_age: 5 weeks\n",
4478        )
4479        .expect_err("'5 weeks' must be refused");
4480        match err {
4481            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
4482                assert_eq!(field, "context.retain.max_age");
4483                assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
4484            }
4485            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
4486        }
4487    }
4488
4489    /// The retired boolean keeps working, mapping `true` to `Content` and `false` to `Meta`.
4490    #[test]
4491    fn include_tool_output_still_resolves_to_content_and_meta() {
4492        let opted_in =
4493            RuntimeManifest::from_yaml_str(&manifest_with_trace("  include_tool_output: true\n"))
4494                .unwrap();
4495        assert_eq!(
4496            opted_in.trace,
4497            Some(TraceConfig {
4498                capture: TraceCapture::Content,
4499                retain: None
4500            })
4501        );
4502
4503        let opted_out =
4504            RuntimeManifest::from_yaml_str(&manifest_with_trace("  include_tool_output: false\n"))
4505                .unwrap();
4506        assert_eq!(
4507            opted_out.trace,
4508            Some(TraceConfig {
4509                capture: TraceCapture::Meta,
4510                retain: None
4511            })
4512        );
4513    }
4514
4515    #[test]
4516    fn setting_both_capture_keys_is_refused_naming_both() {
4517        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(
4518            "  capture: content\n  include_tool_output: true\n",
4519        ))
4520        .expect_err("both keys must be refused even when they agree");
4521        assert!(matches!(
4522            err,
4523            RuntimeManifestError::InvalidTraceConfig { .. }
4524        ));
4525        let rendered = err.to_string();
4526        assert!(rendered.contains("trace.capture"), "{rendered}");
4527        assert!(rendered.contains("trace.include_tool_output"), "{rendered}");
4528    }
4529
4530    #[test]
4531    fn unparseable_capture_value_names_the_field_and_accepted_values() {
4532        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace("  capture: verbose\n"))
4533            .expect_err("'verbose' is not a capture mode");
4534        let rendered = err.to_string();
4535        assert!(rendered.contains("trace.capture"), "{rendered}");
4536        for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
4537            assert!(rendered.contains(accepted), "{rendered}");
4538        }
4539    }
4540
4541    #[test]
4542    fn resources_block_is_optional_and_absent_stays_absent() {
4543        let manifest = RuntimeManifest::from_yaml_str(
4544            r#"
4545name: cap
4546version: 0.0.1
4547capabilities:
4548  shell:
4549    allow: [bash]
4550"#,
4551        )
4552        .unwrap();
4553
4554        // `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
4555        // its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
4556        // keeping the two states distinct is what lets it own them in one place.
4557        let caps = manifest.capabilities.expect("shell block must parse");
4558        assert_eq!(caps.resources, None);
4559    }
4560
4561    #[test]
4562    fn resources_fields_parse_and_each_stays_independently_optional() {
4563        let manifest = RuntimeManifest::from_yaml_str(
4564            r#"
4565name: cap
4566version: 0.0.1
4567capabilities:
4568  resources:
4569    max_processes: 32
4570    max_open_files: 16
4571    max_file_size_bytes: 1048576
4572    cpu_seconds: 30
4573    memory_bytes: 268435456
4574    cgroup_memory_bytes: 536870912
4575    cgroup_pids_max: 64
4576    cgroup_cpu_percent: 50
4577    cgroup_io_bytes_per_sec: 1048576
4578    workdir_max_bytes: 2097152
4579"#,
4580        )
4581        .unwrap();
4582
4583        let resources = manifest
4584            .capabilities
4585            .and_then(|caps| caps.resources)
4586            .expect("resources block must parse");
4587        assert_eq!(resources.max_processes, Some(32));
4588        assert_eq!(resources.max_open_files, Some(16));
4589        assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
4590        assert_eq!(resources.cpu_seconds, Some(30));
4591        assert_eq!(resources.memory_bytes, Some(268_435_456));
4592        assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
4593        assert_eq!(resources.cgroup_pids_max, Some(64));
4594        assert_eq!(resources.cgroup_cpu_percent, Some(50));
4595        assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
4596        assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
4597    }
4598
4599    #[test]
4600    fn state_block_is_optional_and_absent_stays_absent() {
4601        let manifest = RuntimeManifest::from_yaml_str(
4602            r#"
4603name: cap
4604version: 0.0.1
4605artifacts:
4606  - name: notes-tool
4607    version: 1.0.0
4608    runtime: tool
4609    capabilities:
4610      filesystem:
4611        scope: cache
4612"#,
4613        )
4614        .unwrap();
4615
4616        // Absent is deny: no store name to default, and nothing downstream creates a directory.
4617        let caps = manifest.artifacts[0]
4618            .capabilities
4619            .as_ref()
4620            .expect("filesystem block must parse");
4621        assert_eq!(caps.state, None);
4622    }
4623
4624    /// A declared-but-empty `state:` block is a real grant — it means "give me a store, named
4625    /// after the capsule" — so it must lower to `Some(..)` with no name, distinguishable from
4626    /// the absent block above.
4627    #[test]
4628    fn state_block_declared_empty_grants_the_capsule_named_store() {
4629        let manifest = RuntimeManifest::from_yaml_str(
4630            r#"
4631name: cap
4632version: 0.0.1
4633artifacts:
4634  - name: notes-tool
4635    version: 1.0.0
4636    runtime: tool
4637    capabilities:
4638      state: {}
4639"#,
4640        )
4641        .unwrap();
4642
4643        let state = manifest.artifacts[0]
4644            .capabilities
4645            .as_ref()
4646            .and_then(|caps| caps.state.as_ref())
4647            .expect("state block must parse");
4648        assert_eq!(state.store, None);
4649    }
4650
4651    #[test]
4652    fn state_store_name_parses_and_stays_independently_optional() {
4653        let manifest = RuntimeManifest::from_yaml_str(
4654            r#"
4655name: cap
4656version: 0.0.1
4657artifacts:
4658  - name: notes-tool
4659    version: 1.0.0
4660    runtime: tool
4661    capabilities:
4662      state:
4663        store: "  shey  "
4664      network:
4665        allow: [https://api.example.com]
4666"#,
4667        )
4668        .unwrap();
4669
4670        let caps = manifest.artifacts[0]
4671            .capabilities
4672            .as_ref()
4673            .expect("capabilities must parse");
4674        // Trimmed, but otherwise verbatim: whether the name is a usable directory segment is
4675        // decided by the runtime (`E-CAP-009`), not here.
4676        assert_eq!(
4677            caps.state.as_ref().and_then(|state| state.store.as_deref()),
4678            Some("shey")
4679        );
4680        // Declaring a store never touches any sibling sub-block.
4681        assert_eq!(
4682            caps.network
4683                .as_ref()
4684                .map(|network| network.allow.as_slice()),
4685            Some(["https://api.example.com".to_string()].as_slice())
4686        );
4687        assert_eq!(caps.filesystem, None);
4688    }
4689
4690    /// An explicitly empty name is kept rather than normalised to "default to the capsule name":
4691    /// the operator wrote a name, and handing them a different store than the one they wrote
4692    /// would be worse than the refusal the runtime raises for it.
4693    #[test]
4694    fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
4695        let manifest = RuntimeManifest::from_yaml_str(
4696            r#"
4697name: cap
4698version: 0.0.1
4699artifacts:
4700  - name: notes-tool
4701    version: 1.0.0
4702    runtime: tool
4703    capabilities:
4704      state:
4705        store: ""
4706"#,
4707        )
4708        .unwrap();
4709
4710        assert_eq!(
4711            manifest.artifacts[0]
4712                .capabilities
4713                .as_ref()
4714                .and_then(|caps| caps.state.as_ref())
4715                .and_then(|state| state.store.as_deref()),
4716            Some("")
4717        );
4718    }
4719
4720    #[test]
4721    fn resources_partial_block_leaves_undeclared_fields_none() {
4722        let manifest = RuntimeManifest::from_yaml_str(
4723            r#"
4724name: cap
4725version: 0.0.1
4726capabilities:
4727  resources:
4728    max_open_files: 16
4729"#,
4730        )
4731        .unwrap();
4732
4733        let resources = manifest
4734            .capabilities
4735            .and_then(|caps| caps.resources)
4736            .expect("resources block must parse");
4737        assert_eq!(resources.max_open_files, Some(16));
4738        assert_eq!(resources.max_processes, None);
4739        assert_eq!(resources.workdir_max_bytes, None);
4740    }
4741
4742    /// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
4743    /// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
4744    /// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
4745    #[test]
4746    fn resources_zero_is_rejected_on_every_field() {
4747        for field in [
4748            "max_processes",
4749            "max_open_files",
4750            "max_file_size_bytes",
4751            "cpu_seconds",
4752            "memory_bytes",
4753            "cgroup_memory_bytes",
4754            "cgroup_pids_max",
4755            "cgroup_cpu_percent",
4756            "cgroup_io_bytes_per_sec",
4757            "workdir_max_bytes",
4758        ] {
4759            let yaml =
4760                format!("name: cap\nversion: 0.0.1\ncapabilities:\n  resources:\n    {field}: 0\n");
4761            let error =
4762                RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
4763
4764            match error {
4765                RuntimeManifestError::InvalidCapabilities {
4766                    field: reported,
4767                    message,
4768                } => {
4769                    assert_eq!(reported, format!("capabilities.resources.{field}"));
4770                    assert_eq!(message, "must be greater than zero");
4771                }
4772                other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
4773            }
4774        }
4775    }
4776
4777    #[test]
4778    fn artifact_runtime_defaults_to_tool() {
4779        let manifest = RuntimeManifest::from_yaml_str(
4780            r#"
4781name: cap
4782version: 0.0.1
4783artifacts:
4784  - name: echo
4785    version: 1.2.3
4786"#,
4787        )
4788        .unwrap();
4789
4790        assert_eq!(manifest.artifacts.len(), 1);
4791        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
4792    }
4793
4794    #[test]
4795    fn artifact_runtime_tool_explicit() {
4796        let manifest = RuntimeManifest::from_yaml_str(
4797            r#"
4798name: cap
4799version: 0.0.1
4800artifacts:
4801  - name: echo
4802    version: 1.2.3
4803    runtime: tool
4804"#,
4805        )
4806        .unwrap();
4807
4808        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
4809    }
4810
4811    #[test]
4812    fn artifact_runtime_wasm_is_error() {
4813        let err = RuntimeManifest::from_yaml_str(
4814            r#"
4815name: cap
4816version: 0.0.1
4817artifacts:
4818  - name: echo
4819    version: 1.2.3
4820    runtime: wasm
4821"#,
4822        )
4823        .unwrap_err();
4824
4825        let msg = err.to_string();
4826        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
4827        assert!(msg.contains("wasm"), "error was: {msg}");
4828    }
4829
4830    #[test]
4831    fn artifact_runtime_native_is_error() {
4832        let err = RuntimeManifest::from_yaml_str(
4833            r#"
4834name: cap
4835version: 0.0.1
4836artifacts:
4837  - name: echo
4838    version: 1.2.3
4839    runtime: native
4840"#,
4841        )
4842        .unwrap_err();
4843
4844        let msg = err.to_string();
4845        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
4846        assert!(msg.contains("native"), "error was: {msg}");
4847    }
4848
4849    #[test]
4850    fn invalid_artifact_reports_index() {
4851        let err = RuntimeManifest::from_yaml_str(
4852            r#"
4853name: cap
4854version: 0.0.1
4855artifacts:
4856  - name: ""
4857    version: 1.2.3
4858"#,
4859        )
4860        .unwrap_err();
4861
4862        let msg = err.to_string();
4863        assert!(msg.contains("index 0"));
4864        assert!(msg.contains("missing required field 'name'"));
4865    }
4866
4867    #[test]
4868    fn capabilities_parse_when_all_fields_present() {
4869        let manifest = RuntimeManifest::from_yaml_str(
4870            r#"
4871name: cap
4872version: 0.0.1
4873artifacts:
4874  - name: echo
4875    version: 1.2.3
4876capabilities:
4877  network:
4878    allow:
4879      - https://api.anthropic.com
4880      - http://127.0.0.1:8080
4881  filesystem:
4882    scope: ./sandbox
4883"#,
4884        )
4885        .unwrap();
4886
4887        let capabilities = manifest.capabilities.expect("capabilities should exist");
4888        assert_eq!(
4889            capabilities.network.unwrap().allow,
4890            vec![
4891                "https://api.anthropic.com".to_string(),
4892                "http://127.0.0.1:8080".to_string()
4893            ]
4894        );
4895        assert_eq!(
4896            capabilities.filesystem.unwrap().scope,
4897            Some("./sandbox".to_string())
4898        );
4899    }
4900
4901    #[test]
4902    fn env_capabilities_parse_allowlist() {
4903        let manifest = RuntimeManifest::from_yaml_str(
4904            r#"
4905name: cap
4906version: 0.0.1
4907artifacts:
4908  - name: echo
4909    version: 1.2.3
4910capabilities:
4911  env:
4912    allow:
4913      - MY_APP_REGION
4914      - GITHUB_TOKEN
4915"#,
4916        )
4917        .unwrap();
4918
4919        let capabilities = manifest.capabilities.expect("capabilities should exist");
4920        assert_eq!(
4921            capabilities.env.unwrap().allow,
4922            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
4923        );
4924    }
4925
4926    #[test]
4927    fn env_capabilities_absent_when_block_omitted() {
4928        let manifest = RuntimeManifest::from_yaml_str(
4929            r#"
4930name: cap
4931version: 0.0.1
4932artifacts:
4933  - name: echo
4934    version: 1.2.3
4935capabilities:
4936  network:
4937    allow:
4938      - https://api.anthropic.com
4939"#,
4940        )
4941        .unwrap();
4942
4943        assert!(manifest.capabilities.unwrap().env.is_none());
4944    }
4945
4946    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
4947    /// than a manifest error.
4948    #[test]
4949    fn env_capabilities_allow_empty_list_is_accepted() {
4950        let manifest = RuntimeManifest::from_yaml_str(
4951            r#"
4952name: cap
4953version: 0.0.1
4954artifacts:
4955  - name: echo
4956    version: 1.2.3
4957capabilities:
4958  env: {}
4959"#,
4960        )
4961        .unwrap();
4962
4963        assert_eq!(
4964            manifest.capabilities.unwrap().env,
4965            Some(EnvCapabilities { allow: vec![] })
4966        );
4967    }
4968
4969    #[test]
4970    fn missing_capabilities_block_is_backward_compatible() {
4971        let manifest = RuntimeManifest::from_yaml_str(
4972            r#"
4973name: cap
4974version: 0.0.1
4975artifacts:
4976  - name: echo
4977    version: 1.2.3
4978"#,
4979        )
4980        .unwrap();
4981
4982        assert!(manifest.capabilities.is_none());
4983    }
4984
4985    #[test]
4986    fn network_only_capabilities_parse() {
4987        let manifest = RuntimeManifest::from_yaml_str(
4988            r#"
4989name: cap
4990version: 0.0.1
4991artifacts:
4992  - name: echo
4993    version: 1.2.3
4994capabilities:
4995  network:
4996    allow:
4997      - https://allowed.example.com
4998"#,
4999        )
5000        .unwrap();
5001
5002        let capabilities = manifest.capabilities.unwrap();
5003        assert_eq!(
5004            capabilities.network.unwrap().allow,
5005            vec!["https://allowed.example.com".to_string()]
5006        );
5007        assert!(capabilities.filesystem.is_none());
5008    }
5009
5010    #[test]
5011    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
5012        let manifest = RuntimeManifest::from_yaml_str(
5013            r#"
5014name: cap
5015version: 0.0.1
5016artifacts:
5017  - name: echo
5018    version: 1.2.3
5019capabilities:
5020  network: {}
5021"#,
5022        )
5023        .unwrap();
5024
5025        let capabilities = manifest.capabilities.unwrap();
5026        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
5027    }
5028
5029    /// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
5030    /// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
5031    #[test]
5032    fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
5033        let manifest = RuntimeManifest::from_yaml_str(
5034            r#"
5035name: cap
5036version: 0.0.1
5037artifacts:
5038  - name: echo
5039    version: 1.2.3
5040capabilities:
5041  network:
5042    allow:
5043      - https://api.anthropic.com
5044"#,
5045        )
5046        .unwrap();
5047
5048        let network = manifest.capabilities.unwrap().network.unwrap();
5049        assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
5050        assert!(!network.unix_sockets);
5051    }
5052
5053    #[test]
5054    fn network_unix_sockets_parses_explicit_false() {
5055        let manifest = RuntimeManifest::from_yaml_str(
5056            r#"
5057name: cap
5058version: 0.0.1
5059artifacts:
5060  - name: echo
5061    version: 1.2.3
5062capabilities:
5063  network:
5064    unix_sockets: false
5065"#,
5066        )
5067        .unwrap();
5068
5069        assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
5070    }
5071
5072    #[test]
5073    fn network_unix_sockets_parses_explicit_true() {
5074        let manifest = RuntimeManifest::from_yaml_str(
5075            r#"
5076name: cap
5077version: 0.0.1
5078artifacts:
5079  - name: echo
5080    version: 1.2.3
5081capabilities:
5082  network:
5083    unix_sockets: true
5084"#,
5085        )
5086        .unwrap();
5087
5088        let network = manifest.capabilities.unwrap().network.unwrap();
5089        assert!(network.unix_sockets);
5090        // The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
5091        assert_eq!(network.allow, Vec::<String>::new());
5092    }
5093
5094    /// `read_only` is normalised on exactly the terms `scope` is — trimmed, with an entry left
5095    /// empty by that trim dropped rather than carried as a rule covering the whole workdir — and
5096    /// path shape is deliberately not judged here.
5097    #[test]
5098    fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
5099        let manifest = RuntimeManifest::from_yaml_str(
5100            r#"
5101name: cap
5102version: 0.0.1
5103capabilities:
5104  filesystem:
5105    read_only:
5106      - tests
5107      - "  bench/fixtures  "
5108      - ""
5109      - "   "
5110"#,
5111        )
5112        .unwrap();
5113
5114        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
5115        assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
5116    }
5117
5118    /// An absent key and an empty list are one declaration: nothing is protected.
5119    #[test]
5120    fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
5121        let manifest = RuntimeManifest::from_yaml_str(
5122            r#"
5123name: cap
5124version: 0.0.1
5125capabilities:
5126  filesystem:
5127    scope: workdir
5128"#,
5129        )
5130        .unwrap();
5131
5132        assert!(manifest
5133            .capabilities
5134            .unwrap()
5135            .filesystem
5136            .unwrap()
5137            .read_only
5138            .is_empty());
5139    }
5140
5141    /// Path shape is the runtime's refusal, not the parser's: an absolute or escaping entry parses
5142    /// here exactly as an absolute `scope` does, and fails at launch with `E-CAP-012`.
5143    #[test]
5144    fn filesystem_read_only_does_not_judge_path_shape() {
5145        let manifest = RuntimeManifest::from_yaml_str(
5146            r#"
5147name: cap
5148version: 0.0.1
5149capabilities:
5150  filesystem:
5151    read_only:
5152      - /etc
5153      - ../outside
5154"#,
5155        )
5156        .expect("shape is judged by the runtime, not the parser");
5157
5158        assert_eq!(
5159            manifest.capabilities.unwrap().filesystem.unwrap().read_only,
5160            vec!["/etc", "../outside"]
5161        );
5162    }
5163
5164    /// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
5165    /// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
5166    /// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
5167    #[test]
5168    fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
5169        let manifest = RuntimeManifest::from_yaml_str(
5170            r#"
5171name: cap
5172version: 0.0.1
5173artifacts:
5174  - name: echo
5175    version: 1.2.3
5176capabilities:
5177  filesystem:
5178    scope: workdir
5179"#,
5180        )
5181        .unwrap();
5182
5183        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
5184        assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
5185        assert!(!filesystem.workdir_exec);
5186    }
5187
5188    /// The other absent case: no `filesystem:` block at all. There is nothing to default here —
5189    /// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
5190    /// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
5191    /// covers with its own test.
5192    #[test]
5193    fn filesystem_block_stays_absent_when_undeclared() {
5194        let manifest = RuntimeManifest::from_yaml_str(
5195            r#"
5196name: cap
5197version: 0.0.1
5198artifacts:
5199  - name: echo
5200    version: 1.2.3
5201capabilities:
5202  network:
5203    allow:
5204      - https://api.anthropic.com
5205"#,
5206        )
5207        .unwrap();
5208
5209        assert!(manifest.capabilities.unwrap().filesystem.is_none());
5210    }
5211
5212    #[test]
5213    fn filesystem_workdir_exec_parses_explicit_false() {
5214        let manifest = RuntimeManifest::from_yaml_str(
5215            r#"
5216name: cap
5217version: 0.0.1
5218artifacts:
5219  - name: echo
5220    version: 1.2.3
5221capabilities:
5222  filesystem:
5223    workdir_exec: false
5224"#,
5225        )
5226        .unwrap();
5227
5228        assert!(
5229            !manifest
5230                .capabilities
5231                .unwrap()
5232                .filesystem
5233                .unwrap()
5234                .workdir_exec
5235        );
5236    }
5237
5238    #[test]
5239    fn filesystem_workdir_exec_parses_explicit_true() {
5240        let manifest = RuntimeManifest::from_yaml_str(
5241            r#"
5242name: cap
5243version: 0.0.1
5244artifacts:
5245  - name: echo
5246    version: 1.2.3
5247capabilities:
5248  filesystem:
5249    workdir_exec: true
5250"#,
5251        )
5252        .unwrap();
5253
5254        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
5255        assert!(filesystem.workdir_exec);
5256        // Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
5257        // declaring one says nothing about the other.
5258        assert_eq!(filesystem.scope, None);
5259    }
5260
5261    #[test]
5262    fn network_internal_port_parses() {
5263        let manifest = RuntimeManifest::from_yaml_str(
5264            r#"
5265name: worker
5266version: 0.1.0
5267artifacts: []
5268
5269network:
5270  internal_port: 58172
5271"#,
5272        )
5273        .unwrap();
5274
5275        assert_eq!(
5276            manifest.network.unwrap().internal_port,
5277            Some(58172),
5278            "network.internal_port should parse to Some(58172)"
5279        );
5280    }
5281
5282    #[test]
5283    fn network_internal_port_absent_when_omitted() {
5284        let manifest = RuntimeManifest::from_yaml_str(
5285            r#"
5286name: worker
5287version: 0.1.0
5288artifacts: []
5289"#,
5290        )
5291        .unwrap();
5292
5293        assert!(manifest.network.is_none());
5294    }
5295
5296    #[test]
5297    fn shell_capabilities_parse_with_allow_and_env_fields() {
5298        let manifest = RuntimeManifest::from_yaml_str(
5299            r#"
5300name: cap
5301version: 0.0.1
5302artifacts: []
5303capabilities:
5304  shell:
5305    allow:
5306      - bash
5307      - git
5308    strip_env:
5309      - "*_TOKEN"
5310      - SECRET_KEY
5311    baseline_env:
5312      - SSH_AUTH_SOCK
5313"#,
5314        )
5315        .unwrap();
5316
5317        let shell = manifest
5318            .capabilities
5319            .unwrap()
5320            .shell
5321            .expect("shell capabilities should be present");
5322        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
5323        assert_eq!(
5324            shell.strip_env,
5325            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
5326        );
5327        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
5328    }
5329
5330    #[test]
5331    fn shell_capabilities_require_non_empty_allowlist() {
5332        let err = RuntimeManifest::from_yaml_str(
5333            r#"
5334name: cap
5335version: 0.0.1
5336artifacts: []
5337capabilities:
5338  shell:
5339    allow: []
5340"#,
5341        )
5342        .unwrap_err();
5343
5344        let msg = err.to_string();
5345        assert!(msg.contains("capabilities.shell.allow"));
5346        assert!(msg.contains("at least one"));
5347    }
5348
5349    #[test]
5350    fn interpreter_runtime_parses_accepted_shape() {
5351        let manifest = RuntimeManifest::from_yaml_str(
5352            r#"
5353name: cap
5354version: 0.0.1
5355artifacts: []
5356capabilities:
5357  shell:
5358    allow:
5359      - python3
5360    interpreter_runtime:
5361      - binary: python3
5362        dirs:
5363          - path: /usr/lib/python3.11
5364            list_dir: true
5365          - path: /usr/lib/python3.11/lib-dynload
5366            list_dir: false
5367"#,
5368        )
5369        .unwrap();
5370
5371        let shell = manifest.capabilities.unwrap().shell.unwrap();
5372        assert_eq!(
5373            shell.interpreter_runtime,
5374            vec![InterpreterRuntimeGrant {
5375                binary: "python3".to_string(),
5376                dirs: vec![
5377                    InterpreterRuntimeDir {
5378                        path: "/usr/lib/python3.11".to_string(),
5379                        list_dir: true,
5380                    },
5381                    InterpreterRuntimeDir {
5382                        path: "/usr/lib/python3.11/lib-dynload".to_string(),
5383                        list_dir: false,
5384                    },
5385                ],
5386            }]
5387        );
5388    }
5389
5390    #[test]
5391    fn interpreter_runtime_defaults_to_empty_when_absent() {
5392        let manifest = RuntimeManifest::from_yaml_str(
5393            r#"
5394name: cap
5395version: 0.0.1
5396artifacts: []
5397capabilities:
5398  shell:
5399    allow:
5400      - python3
5401"#,
5402        )
5403        .unwrap();
5404
5405        let shell = manifest.capabilities.unwrap().shell.unwrap();
5406        assert!(shell.interpreter_runtime.is_empty());
5407    }
5408
5409    /// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
5410    /// (indented to sit under `interpreter_runtime:`), returning the error message.
5411    fn interpreter_runtime_reject(grant_yaml: &str) -> String {
5412        let yaml = format!(
5413            r#"
5414name: cap
5415version: 0.0.1
5416artifacts: []
5417capabilities:
5418  shell:
5419    allow:
5420      - python3
5421    interpreter_runtime:
5422{grant_yaml}
5423"#,
5424        );
5425        RuntimeManifest::from_yaml_str(&yaml)
5426            .expect_err("grant should be rejected")
5427            .to_string()
5428    }
5429
5430    #[test]
5431    fn interpreter_runtime_rejects_binary_not_in_allow() {
5432        let msg = interpreter_runtime_reject(
5433            "      - binary: ruby\n        dirs:\n          - path: /usr/lib/ruby\n            list_dir: true\n",
5434        );
5435        assert!(
5436            msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
5437            "{msg}"
5438        );
5439        assert!(msg.contains("ruby"), "{msg}");
5440        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
5441    }
5442
5443    #[test]
5444    fn interpreter_runtime_rejects_relative_path() {
5445        let msg = interpreter_runtime_reject(
5446            "      - binary: python3\n        dirs:\n          - path: usr/lib/python3.11\n            list_dir: true\n",
5447        );
5448        assert!(
5449            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
5450            "{msg}"
5451        );
5452        assert!(msg.contains("absolute"), "{msg}");
5453    }
5454
5455    // ── capabilities.plan (capsule-wide) ─────────────────────────────────────
5456
5457    /// A capsule manifest whose capsule-wide capabilities carry `plan: <block>`.
5458    fn manifest_with_plan(block: &str) -> String {
5459        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n{block}")
5460    }
5461
5462    #[test]
5463    fn plan_submit_true_grants_the_capsule() {
5464        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n"))
5465            .expect("a plan block parses");
5466        assert_eq!(
5467            manifest.capabilities.unwrap().plan,
5468            Some(PlanCapabilities { submit: true })
5469        );
5470    }
5471
5472    /// `submit: false` and an absent `plan:` block both leave the capsule ungranted; the
5473    /// difference between them is only whether the operator wrote the denial down.
5474    #[test]
5475    fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
5476        let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: false\n"))
5477            .expect("an explicit denial parses");
5478        assert_eq!(
5479            explicit.capabilities.unwrap().plan,
5480            Some(PlanCapabilities { submit: false })
5481        );
5482
5483        let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
5484            .expect("no capabilities block parses");
5485        assert!(absent.capabilities.is_none());
5486    }
5487
5488    /// The capability is never inferred: a `plan:` block that omits `submit:` is a parse error
5489    /// naming the key, exactly as `task_io:` without `read:` is.
5490    #[test]
5491    fn plan_without_submit_is_rejected() {
5492        let err = RuntimeManifest::from_yaml_str(&manifest_with_plan("    {}\n"))
5493            .expect_err("an empty plan block is refused");
5494        let msg = err.to_string();
5495        assert!(msg.contains("capabilities.plan.submit"), "{msg}");
5496        assert!(msg.contains("never"), "{msg}");
5497    }
5498
5499    /// An unknown key inside the block is reported by path, like every other capability block.
5500    #[test]
5501    fn an_unknown_plan_key_is_reported_by_path() {
5502        let manifest =
5503            RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n    depth: 3\n"))
5504                .expect("an unknown key is a warning, not a parse error");
5505        assert!(
5506            manifest
5507                .unknown_keys
5508                .iter()
5509                .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
5510            "{:?}",
5511            manifest.unknown_keys
5512        );
5513    }
5514
5515    // ── capabilities.task_io (per-hook, honored only on runtime: hook) ────────
5516
5517    /// A capsule manifest with one artifact entry carrying `capabilities: task_io: <block>`.
5518    fn manifest_with_task_io(runtime: &str, block: &str) -> String {
5519        format!(
5520            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: gate\n    version: 1.2.3\n    \
5521             runtime: {runtime}\n    capabilities:\n      task_io:\n{block}"
5522        )
5523    }
5524
5525    #[test]
5526    fn task_io_read_true_grants_the_hook() {
5527        let manifest =
5528            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: true\n"))
5529                .expect("task_io on a hook entry parses");
5530        assert_eq!(
5531            manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
5532            Some(TaskIoCapabilities { read: true })
5533        );
5534    }
5535
5536    /// `read: false` and an absent `task_io:` block both leave the hook ungranted; the
5537    /// difference between them is only whether the operator wrote the denial down.
5538    #[test]
5539    fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
5540        let explicit =
5541            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: false\n"))
5542                .expect("an explicit denial parses");
5543        assert_eq!(
5544            explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
5545            Some(TaskIoCapabilities { read: false })
5546        );
5547
5548        let absent = RuntimeManifest::from_yaml_str(
5549            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: gate\n    version: 1.2.3\n    \
5550             runtime: hook\n",
5551        )
5552        .expect("no capabilities block parses");
5553        assert!(absent.artifacts[0].capabilities.is_none());
5554    }
5555
5556    /// The capability is never inferred: a `task_io:` block that omits `read:` is a parse
5557    /// error naming the key, following `interpreter_runtime[].dirs[].list_dir`.
5558    #[test]
5559    fn task_io_without_read_is_rejected() {
5560        let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        {}\n"))
5561            .unwrap_err();
5562        let msg = err.to_string();
5563        assert!(
5564            msg.contains("capabilities.task_io.read"),
5565            "error was: {msg}"
5566        );
5567        assert!(msg.contains("explicitly"), "error was: {msg}");
5568    }
5569
5570    /// Nothing outside a hook can be handed the import, so declaring the key there is a parse
5571    /// error rather than a silently inert grant.
5572    #[test]
5573    fn task_io_outside_a_hook_entry_is_rejected() {
5574        for runtime in ["tool", "driver", "skill"] {
5575            let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
5576                runtime,
5577                "        read: true\n",
5578            ))
5579            .unwrap_err();
5580            let msg = err.to_string();
5581            assert!(
5582                msg.contains("capabilities") && msg.contains("runtime: hook"),
5583                "{runtime} entry: error was: {msg}"
5584            );
5585        }
5586    }
5587
5588    /// The capsule-wide block reaches capsule, tool and driver components, none of which can
5589    /// receive the import — so it is rejected there too, naming the key.
5590    #[test]
5591    fn task_io_in_the_capsule_wide_block_is_rejected() {
5592        let err = RuntimeManifest::from_yaml_str(
5593            "name: cap\nversion: 0.0.1\ncapabilities:\n  task_io:\n    read: true\n",
5594        )
5595        .unwrap_err();
5596        let msg = err.to_string();
5597        assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
5598        assert!(msg.contains("runtime: hook"), "error was: {msg}");
5599    }
5600
5601    // ── capabilities.conversation (per-hook, honored only on runtime: hook) ───
5602
5603    /// A capsule manifest with one artifact entry carrying `capabilities: conversation: <block>`.
5604    fn manifest_with_conversation(runtime: &str, block: &str) -> String {
5605        format!(
5606            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: memory\n    version: 1.2.3\n    \
5607             runtime: {runtime}\n    capabilities:\n      conversation:\n{block}"
5608        )
5609    }
5610
5611    #[test]
5612    fn conversation_read_lowers_onto_the_hook_entry() {
5613        for (declared, expected) in [("true", true), ("false", false)] {
5614            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
5615                "hook",
5616                &format!("        read: {declared}\n"),
5617            ))
5618            .expect("conversation on a hook entry parses");
5619            assert_eq!(
5620                manifest.artifacts[0]
5621                    .capabilities
5622                    .as_ref()
5623                    .unwrap()
5624                    .conversation,
5625                Some(ConversationCapabilities { read: expected })
5626            );
5627        }
5628    }
5629
5630    /// The capability is never inferred: a block that omits `read:` is a parse error naming the
5631    /// key, exactly as `capabilities.task_io.read` is.
5632    #[test]
5633    fn conversation_without_read_is_rejected() {
5634        let err =
5635            RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", "        {}\n"))
5636                .unwrap_err();
5637        let msg = err.to_string();
5638        assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
5639        assert!(msg.contains("explicitly"), "was: {msg}");
5640    }
5641
5642    /// Only a hook's world can import the interface, so declaring the grant on any other role is
5643    /// a parse error rather than a silently inert block.
5644    #[test]
5645    fn conversation_outside_a_hook_entry_is_rejected() {
5646        for runtime in ["tool", "driver", "skill"] {
5647            let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
5648                runtime,
5649                "        read: true\n",
5650            ))
5651            .unwrap_err();
5652            let msg = err.to_string();
5653            assert!(
5654                msg.contains("capabilities") && msg.contains("runtime: hook"),
5655                "{runtime} entry: error was: {msg}"
5656            );
5657        }
5658    }
5659
5660    /// Unlike `task_io`, the capsule-wide block parses: it is inert, and the runtime says so once
5661    /// as `W-SEC-016` rather than refusing a manifest an operator can fix at leisure.
5662    #[test]
5663    fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
5664        let manifest = RuntimeManifest::from_yaml_str(
5665            "name: cap\nversion: 0.0.1\ncapabilities:\n  conversation:\n    read: true\n",
5666        )
5667        .expect("a capsule-wide block is accepted");
5668        assert_eq!(
5669            manifest.capabilities.unwrap().conversation,
5670            Some(ConversationCapabilities { read: true })
5671        );
5672    }
5673
5674    // ── context.record / context.record_store ────────────────────────────────
5675
5676    /// The record is on unless the manifest says otherwise, and `record_store` defaults to
5677    /// nothing — the runtime substitutes the capsule name.
5678    #[test]
5679    fn a_context_block_records_by_default() {
5680        let context = context_of("context:\n  max_tokens: 1000\n").unwrap();
5681        assert!(context.record);
5682        assert_eq!(context.record_store, None);
5683    }
5684
5685    #[test]
5686    fn record_off_and_on_are_the_two_accepted_values() {
5687        assert!(!context_of("context:\n  record: off\n").unwrap().record);
5688        assert!(context_of("context:\n  record: on\n").unwrap().record);
5689
5690        let err = RuntimeManifest::from_yaml_str(
5691            "name: cap\nversion: 0.0.1\ncontext:\n  record: sometimes\n",
5692        )
5693        .unwrap_err();
5694        let msg = err.to_string();
5695        assert!(msg.contains("context.record"), "was: {msg}");
5696        assert!(msg.contains("sometimes"), "was: {msg}");
5697    }
5698
5699    /// `record_store` is a name the runtime turns into a directory, so it is kept verbatim apart
5700    /// from surrounding whitespace; its *shape* is refused at launch, where the path is built.
5701    #[test]
5702    fn record_store_is_kept_verbatim_and_trimmed() {
5703        assert_eq!(
5704            context_of("context:\n  record_store: '  shey  '\n")
5705                .unwrap()
5706                .record_store,
5707            Some("shey".to_string())
5708        );
5709        assert_eq!(
5710            context_of("context:\n  record: off\n  record_store: shey\n")
5711                .unwrap()
5712                .record_store,
5713            Some("shey".to_string()),
5714            "a store declared beside record: off is kept and simply never used"
5715        );
5716    }
5717
5718    #[test]
5719    fn interpreter_runtime_rejects_missing_list_dir() {
5720        let msg = interpreter_runtime_reject(
5721            "      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.11\n",
5722        );
5723        assert!(
5724            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
5725            "{msg}"
5726        );
5727        assert!(msg.contains("explicitly"), "{msg}");
5728    }
5729
5730    #[test]
5731    fn interpreter_runtime_rejects_empty_dirs() {
5732        let msg = interpreter_runtime_reject("      - binary: python3\n        dirs: []\n");
5733        assert!(
5734            msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
5735            "{msg}"
5736        );
5737        assert!(msg.contains("at least one"), "{msg}");
5738    }
5739
5740    #[test]
5741    fn staged_runtime_parses_accepted_shape() {
5742        let manifest = RuntimeManifest::from_yaml_str(
5743            r#"
5744name: cap
5745version: 0.0.1
5746artifacts: []
5747capabilities:
5748  containment: sealed
5749  shell:
5750    allow:
5751      - python3
5752      - bash
5753    staged_runtime:
5754      - binary: python3
5755        source_path: /opt/testbed/conda/envs/django__django
5756        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
5757"#,
5758        )
5759        .unwrap();
5760
5761        let shell = manifest.capabilities.unwrap().shell.unwrap();
5762        assert_eq!(
5763            shell.staged_runtime,
5764            vec![StagedRuntimeGrant {
5765                binary: "python3".to_string(),
5766                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
5767                pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
5768            }]
5769        );
5770        // The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
5771        // interpreter_runtime list untouched rather than populating it as a side effect.
5772        assert!(shell.interpreter_runtime.is_empty());
5773    }
5774
5775    #[test]
5776    fn staged_runtime_defaults_to_empty_when_absent() {
5777        let manifest = RuntimeManifest::from_yaml_str(
5778            r#"
5779name: cap
5780version: 0.0.1
5781artifacts: []
5782capabilities:
5783  shell:
5784    allow:
5785      - python3
5786"#,
5787        )
5788        .unwrap();
5789
5790        let shell = manifest.capabilities.unwrap().shell.unwrap();
5791        assert!(shell.staged_runtime.is_empty());
5792    }
5793
5794    /// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
5795    /// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
5796    /// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
5797    fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
5798        let yaml = format!(
5799            r#"
5800name: cap
5801version: 0.0.1
5802artifacts: []
5803capabilities:
5804  shell:
5805    allow:
5806      - python3
5807{extra}    staged_runtime:
5808{grant_yaml}
5809"#,
5810        );
5811        RuntimeManifest::from_yaml_str(&yaml)
5812            .expect_err("grant should be rejected")
5813            .to_string()
5814    }
5815
5816    #[test]
5817    fn staged_runtime_rejects_binary_not_in_allow() {
5818        let msg = staged_runtime_reject(
5819            "      - binary: ruby\n        source_path: /opt/ruby\n        pin: ruby-3.2.2\n",
5820            "",
5821        );
5822        assert!(
5823            msg.contains("capabilities.shell.staged_runtime[0].binary"),
5824            "{msg}"
5825        );
5826        assert!(msg.contains("ruby"), "{msg}");
5827        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
5828    }
5829
5830    #[test]
5831    fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
5832        let msg = staged_runtime_reject(
5833            "      - binary: python3\n        source_path: /opt/py\n        pin: cpython-3.9.19\n",
5834            "    interpreter_runtime:\n      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.9\n            list_dir: true\n",
5835        );
5836        assert!(
5837            msg.contains("capabilities.shell.staged_runtime[0].binary"),
5838            "{msg}"
5839        );
5840        assert!(msg.contains("python3"), "{msg}");
5841        assert!(msg.contains("interpreter_runtime"), "{msg}");
5842        assert!(msg.contains("mutually exclusive"), "{msg}");
5843    }
5844
5845    #[test]
5846    fn staged_runtime_rejects_relative_source_path() {
5847        let msg = staged_runtime_reject(
5848            "      - binary: python3\n        source_path: opt/testbed/conda\n        pin: cpython-3.9.19\n",
5849            "",
5850        );
5851        assert!(
5852            msg.contains("capabilities.shell.staged_runtime[0].source_path"),
5853            "{msg}"
5854        );
5855        assert!(msg.contains("absolute"), "{msg}");
5856    }
5857
5858    #[test]
5859    fn staged_runtime_rejects_missing_or_empty_pin() {
5860        // Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
5861        // precisely so that state is unrepresentable.
5862        for grant in [
5863            "      - binary: python3\n        source_path: /opt/testbed/conda\n",
5864            "      - binary: python3\n        source_path: /opt/testbed/conda\n        pin: \"   \"\n",
5865        ] {
5866            let msg = staged_runtime_reject(grant, "");
5867            assert!(
5868                msg.contains("capabilities.shell.staged_runtime[0].pin"),
5869                "{msg}"
5870            );
5871            assert!(msg.contains("never inferred"), "{msg}");
5872        }
5873    }
5874
5875    #[test]
5876    fn parses_inference_block() {
5877        let manifest = RuntimeManifest::from_yaml_str(
5878            r#"
5879name: cap
5880version: 0.0.1
5881artifacts: []
5882inference:
5883  transport: http
5884  endpoint: http://127.0.0.1:8080
5885  model: test-model
5886  api_key: literal-key
5887  driver:
5888    artifact: murmur-driver-anthropic
5889    config:
5890      temperature: 0.2
5891      headers:
5892        x-test: true
5893"#,
5894        )
5895        .unwrap();
5896
5897        let inference = manifest.inference.expect("inference should exist");
5898        assert_eq!(inference.transport, "http");
5899        assert_eq!(
5900            inference.endpoint,
5901            Some("http://127.0.0.1:8080".to_string())
5902        );
5903        assert_eq!(inference.model, "test-model");
5904        assert_eq!(
5905            inference.api_key,
5906            Some(ApiKeyReference::Literal("literal-key".to_string()))
5907        );
5908        let driver = inference.driver.as_ref().expect("driver should be present");
5909        assert_eq!(driver.artifact, "murmur-driver-anthropic");
5910        assert_eq!(
5911            driver.config,
5912            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
5913        );
5914        assert!(inference.system_prompt.is_none());
5915        assert!(inference.system_prompt_file.is_none());
5916    }
5917
5918    #[test]
5919    fn https_endpoint_accepted() {
5920        let manifest = RuntimeManifest::from_yaml_str(
5921            r#"
5922name: cap
5923version: 0.0.1
5924artifacts: []
5925inference:
5926  transport: http
5927  endpoint: https://api.anthropic.com
5928  model: test-model
5929  driver:
5930    artifact: murmur-driver-anthropic
5931"#,
5932        )
5933        .unwrap();
5934
5935        assert_eq!(
5936            manifest.inference.unwrap().endpoint,
5937            Some("https://api.anthropic.com".to_string())
5938        );
5939    }
5940
5941    #[test]
5942    fn http_localhost_endpoint_accepted() {
5943        let manifest = RuntimeManifest::from_yaml_str(
5944            r#"
5945name: cap
5946version: 0.0.1
5947artifacts: []
5948inference:
5949  transport: http
5950  endpoint: http://localhost:11434
5951  model: test-model
5952  driver:
5953    artifact: murmur-driver-anthropic
5954"#,
5955        )
5956        .unwrap();
5957
5958        assert_eq!(
5959            manifest.inference.unwrap().endpoint,
5960            Some("http://localhost:11434".to_string())
5961        );
5962    }
5963
5964    #[test]
5965    fn http_loopback_ip_endpoint_accepted() {
5966        let manifest = RuntimeManifest::from_yaml_str(
5967            r#"
5968name: cap
5969version: 0.0.1
5970artifacts: []
5971inference:
5972  transport: http
5973  endpoint: http://127.0.0.1:11434
5974  model: test-model
5975  driver:
5976    artifact: murmur-driver-anthropic
5977"#,
5978        )
5979        .unwrap();
5980
5981        assert_eq!(
5982            manifest.inference.unwrap().endpoint,
5983            Some("http://127.0.0.1:11434".to_string())
5984        );
5985    }
5986
5987    #[test]
5988    fn http_non_localhost_endpoint_rejected() {
5989        let err = RuntimeManifest::from_yaml_str(
5990            r#"
5991name: cap
5992version: 0.0.1
5993artifacts: []
5994inference:
5995  transport: http
5996  endpoint: http://api.attacker.example.com
5997  model: test-model
5998  driver:
5999    artifact: murmur-driver-anthropic
6000"#,
6001        )
6002        .unwrap_err();
6003
6004        let msg = err.to_string();
6005        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
6006        assert!(msg.contains("api.attacker.example.com"), "error was: {msg}");
6007    }
6008
6009    #[test]
6010    fn schemeless_endpoint_rejected() {
6011        let err = RuntimeManifest::from_yaml_str(
6012            r#"
6013name: cap
6014version: 0.0.1
6015artifacts: []
6016inference:
6017  transport: http
6018  endpoint: api.anthropic.com
6019  model: test-model
6020  driver:
6021    artifact: murmur-driver-anthropic
6022"#,
6023        )
6024        .unwrap_err();
6025
6026        let msg = err.to_string();
6027        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
6028        assert!(msg.contains("failed to parse"), "error was: {msg}");
6029    }
6030
6031    #[test]
6032    fn malformed_endpoint_rejected() {
6033        let err = RuntimeManifest::from_yaml_str(
6034            r#"
6035name: cap
6036version: 0.0.1
6037artifacts: []
6038inference:
6039  transport: http
6040  endpoint: "not a url"
6041  model: test-model
6042  driver:
6043    artifact: murmur-driver-anthropic
6044"#,
6045        )
6046        .unwrap_err();
6047
6048        let msg = err.to_string();
6049        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
6050        assert!(msg.contains("failed to parse"), "error was: {msg}");
6051    }
6052
6053    #[test]
6054    fn unsupported_scheme_endpoint_rejected() {
6055        let err = RuntimeManifest::from_yaml_str(
6056            r#"
6057name: cap
6058version: 0.0.1
6059artifacts: []
6060inference:
6061  transport: http
6062  endpoint: ftp://example.com
6063  model: test-model
6064  driver:
6065    artifact: murmur-driver-anthropic
6066"#,
6067        )
6068        .unwrap_err();
6069
6070        let msg = err.to_string();
6071        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
6072        assert!(msg.contains("unsupported scheme 'ftp'"), "error was: {msg}");
6073    }
6074
6075    #[test]
6076    fn parses_inline_system_prompt() {
6077        let manifest = RuntimeManifest::from_yaml_str(
6078            r#"
6079name: cap
6080version: 0.0.1
6081artifacts: []
6082inference:
6083  endpoint: http://127.0.0.1:8080
6084  model: test-model
6085  system_prompt: "Always begin with CONFIRMED:"
6086  driver:
6087    artifact: murmur-driver-anthropic
6088"#,
6089        )
6090        .unwrap();
6091
6092        let inference = manifest.inference.unwrap();
6093        assert_eq!(
6094            inference.system_prompt,
6095            Some("Always begin with CONFIRMED:".to_string())
6096        );
6097        assert!(inference.system_prompt_file.is_none());
6098    }
6099
6100    #[test]
6101    fn parses_compaction_system_prompt() {
6102        let manifest = RuntimeManifest::from_yaml_str(
6103            r#"
6104name: cap
6105version: 0.0.1
6106artifacts: []
6107inference:
6108  endpoint: http://127.0.0.1:8080
6109  model: test-model
6110  compaction:
6111    model: compaction-model
6112    system_prompt: "task = X, currently editing Y, already tried Z."
6113  driver:
6114    artifact: murmur-driver-anthropic
6115"#,
6116        )
6117        .unwrap();
6118
6119        let compaction = manifest.inference.unwrap().compaction.unwrap();
6120        assert_eq!(
6121            compaction.system_prompt,
6122            Some("task = X, currently editing Y, already tried Z.".to_string())
6123        );
6124        assert_eq!(compaction.model, Some("compaction-model".to_string()));
6125    }
6126
6127    /// The two override fields resolve independently: `model` set, `system_prompt`
6128    /// absent leaves the latter `None` with no default string substituted.
6129    #[test]
6130    fn compaction_system_prompt_absent_stays_none() {
6131        let manifest = RuntimeManifest::from_yaml_str(
6132            r#"
6133name: cap
6134version: 0.0.1
6135artifacts: []
6136inference:
6137  endpoint: http://127.0.0.1:8080
6138  model: test-model
6139  compaction:
6140    model: compaction-model
6141  driver:
6142    artifact: murmur-driver-anthropic
6143"#,
6144        )
6145        .unwrap();
6146
6147        let compaction = manifest.inference.unwrap().compaction.unwrap();
6148        assert!(compaction.system_prompt.is_none());
6149        assert!(compaction.system_prompt_file.is_none());
6150        assert_eq!(compaction.model, Some("compaction-model".to_string()));
6151    }
6152
6153    /// `system_prompt_file` parses as the second, independent prompt source: the path is
6154    /// carried through verbatim (resolution against the manifest dir happens in the
6155    /// runtime), the inline field stays `None`, and `model` is unaffected.
6156    #[test]
6157    fn parses_compaction_system_prompt_file() {
6158        let manifest = RuntimeManifest::from_yaml_str(
6159            r#"
6160name: cap
6161version: 0.0.1
6162artifacts: []
6163inference:
6164  endpoint: http://127.0.0.1:8080
6165  model: test-model
6166  compaction:
6167    model: compaction-model
6168    system_prompt_file: "compaction-instructions.md"
6169  driver:
6170    artifact: murmur-driver-anthropic
6171"#,
6172        )
6173        .unwrap();
6174
6175        let compaction = manifest.inference.unwrap().compaction.unwrap();
6176        assert_eq!(
6177            compaction.system_prompt_file,
6178            Some("compaction-instructions.md".to_string())
6179        );
6180        assert!(compaction.system_prompt.is_none());
6181        assert_eq!(compaction.model, Some("compaction-model".to_string()));
6182    }
6183
6184    /// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
6185    /// depend on each other in either direction.
6186    #[test]
6187    fn compaction_system_prompt_file_independent_of_model() {
6188        let manifest = RuntimeManifest::from_yaml_str(
6189            r#"
6190name: cap
6191version: 0.0.1
6192artifacts: []
6193inference:
6194  endpoint: http://127.0.0.1:8080
6195  model: test-model
6196  compaction:
6197    threshold: 0.5
6198    system_prompt_file: "compaction-instructions.md"
6199  driver:
6200    artifact: murmur-driver-anthropic
6201"#,
6202        )
6203        .unwrap();
6204
6205        let compaction = manifest.inference.unwrap().compaction.unwrap();
6206        assert_eq!(
6207            compaction.system_prompt_file,
6208            Some("compaction-instructions.md".to_string())
6209        );
6210        assert!(compaction.model.is_none());
6211        assert_eq!(compaction.threshold, Some(0.5));
6212    }
6213
6214    /// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
6215    /// are three distinguishable states, so the runtime can tell "explicitly off" from
6216    /// "never configured" even though both resolve to the same behavior.
6217    #[test]
6218    fn parses_compaction_dump_summaries() {
6219        let parse = |line: &str| {
6220            RuntimeManifest::from_yaml_str(&format!(
6221                r#"
6222name: cap
6223version: 0.0.1
6224artifacts: []
6225inference:
6226  endpoint: http://127.0.0.1:8080
6227  model: test-model
6228  compaction:
6229    model: compaction-model
6230{line}  driver:
6231    artifact: murmur-driver-anthropic
6232"#
6233            ))
6234            .unwrap()
6235            .inference
6236            .unwrap()
6237            .compaction
6238            .unwrap()
6239        };
6240
6241        assert_eq!(
6242            parse("    dump_summaries: true\n").dump_summaries,
6243            Some(true)
6244        );
6245        assert_eq!(
6246            parse("    dump_summaries: false\n").dump_summaries,
6247            Some(false)
6248        );
6249        assert_eq!(parse("").dump_summaries, None);
6250    }
6251
6252    /// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
6253    /// prompt-source exclusivity check nor the threshold range check, and does not disturb
6254    /// the values those fields parse to.
6255    #[test]
6256    fn compaction_dump_summaries_does_not_interact_with_other_fields() {
6257        let compaction = RuntimeManifest::from_yaml_str(
6258            r#"
6259name: cap
6260version: 0.0.1
6261artifacts: []
6262inference:
6263  endpoint: http://127.0.0.1:8080
6264  model: test-model
6265  compaction:
6266    threshold: 0.5
6267    model: compaction-model
6268    system_prompt_file: "compaction-instructions.md"
6269    dump_summaries: true
6270  driver:
6271    artifact: murmur-driver-anthropic
6272"#,
6273        )
6274        .unwrap()
6275        .inference
6276        .unwrap()
6277        .compaction
6278        .unwrap();
6279
6280        assert_eq!(compaction.dump_summaries, Some(true));
6281        assert_eq!(compaction.threshold, Some(0.5));
6282        assert_eq!(compaction.model, Some("compaction-model".to_string()));
6283        assert_eq!(
6284            compaction.system_prompt_file,
6285            Some("compaction-instructions.md".to_string())
6286        );
6287        assert!(compaction.system_prompt.is_none());
6288
6289        // Still mutually exclusive with dump_summaries in the mix...
6290        let err = RuntimeManifest::from_yaml_str(
6291            r#"
6292name: cap
6293version: 0.0.1
6294artifacts: []
6295inference:
6296  endpoint: http://127.0.0.1:8080
6297  model: test-model
6298  compaction:
6299    dump_summaries: true
6300    system_prompt: "inline"
6301    system_prompt_file: "compaction-instructions.md"
6302  driver:
6303    artifact: murmur-driver-anthropic
6304"#,
6305        )
6306        .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
6307        assert!(matches!(
6308            err,
6309            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
6310                if field == "inference.compaction.system_prompt"
6311        ));
6312
6313        // ...and the threshold range is still enforced.
6314        let err = RuntimeManifest::from_yaml_str(
6315            r#"
6316name: cap
6317version: 0.0.1
6318artifacts: []
6319inference:
6320  endpoint: http://127.0.0.1:8080
6321  model: test-model
6322  compaction:
6323    dump_summaries: true
6324    threshold: 1.5
6325  driver:
6326    artifact: murmur-driver-anthropic
6327"#,
6328        )
6329        .expect_err("dump_summaries must not suppress the threshold range check");
6330        assert!(matches!(
6331            err,
6332            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
6333                if field == "inference.compaction.threshold"
6334        ));
6335    }
6336
6337    /// Setting both compaction prompt sources is a parse error naming the compaction
6338    /// fields — distinguishable from the top-level three-way `inference.system_prompt`
6339    /// exclusivity error, and with neither value silently preferred.
6340    #[test]
6341    fn compaction_prompt_sources_are_mutually_exclusive() {
6342        let err = RuntimeManifest::from_yaml_str(
6343            r#"
6344name: cap
6345version: 0.0.1
6346artifacts: []
6347inference:
6348  endpoint: http://127.0.0.1:8080
6349  model: test-model
6350  compaction:
6351    system_prompt: "inline"
6352    system_prompt_file: "compaction-instructions.md"
6353  driver:
6354    artifact: murmur-driver-anthropic
6355"#,
6356        )
6357        .expect_err("both compaction prompt sources set must fail");
6358
6359        match err {
6360            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
6361                assert_eq!(field, "inference.compaction.system_prompt");
6362                assert_eq!(
6363                    message,
6364                    "at most one of inference.compaction.system_prompt, \
6365                     inference.compaction.system_prompt_file may be set"
6366                );
6367            }
6368            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
6369        }
6370    }
6371
6372    /// The compaction-level exclusivity check is separate from the top-level one: an
6373    /// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
6374    /// perfectly legal manifest.
6375    #[test]
6376    fn top_level_and_compaction_prompt_sources_do_not_collide() {
6377        let manifest = RuntimeManifest::from_yaml_str(
6378            r#"
6379name: cap
6380version: 0.0.1
6381artifacts: []
6382inference:
6383  endpoint: http://127.0.0.1:8080
6384  model: test-model
6385  system_prompt: "be helpful"
6386  compaction:
6387    system_prompt_file: "compaction-instructions.md"
6388  driver:
6389    artifact: murmur-driver-anthropic
6390"#,
6391        )
6392        .unwrap();
6393
6394        let inference = manifest.inference.unwrap();
6395        assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
6396        assert_eq!(
6397            inference.compaction.unwrap().system_prompt_file,
6398            Some("compaction-instructions.md".to_string())
6399        );
6400    }
6401
6402    #[test]
6403    fn parses_system_prompt_file() {
6404        let manifest = RuntimeManifest::from_yaml_str(
6405            r#"
6406name: cap
6407version: 0.0.1
6408artifacts: []
6409inference:
6410  endpoint: http://127.0.0.1:8080
6411  model: test-model
6412  system_prompt_file: "conventions.md"
6413  driver:
6414    artifact: murmur-driver-anthropic
6415"#,
6416        )
6417        .unwrap();
6418
6419        let inference = manifest.inference.unwrap();
6420        assert_eq!(
6421            inference.system_prompt_file,
6422            Some("conventions.md".to_string())
6423        );
6424        assert!(inference.system_prompt.is_none());
6425    }
6426
6427    #[test]
6428    fn rejects_both_inline_and_file_system_prompt() {
6429        let err = RuntimeManifest::from_yaml_str(
6430            r#"
6431name: cap
6432version: 0.0.1
6433artifacts: []
6434inference:
6435  endpoint: http://127.0.0.1:8080
6436  model: test-model
6437  system_prompt: "inline"
6438  system_prompt_file: "conventions.md"
6439  driver:
6440    artifact: murmur-driver-anthropic
6441"#,
6442        )
6443        .unwrap_err();
6444
6445        let msg = err.to_string();
6446        assert!(msg.contains("inference.system_prompt"));
6447        assert!(msg.contains("system_prompt_file"));
6448    }
6449
6450    /// A `${NAME}` is kept as the name it is, whether or not this process holds the variable: the
6451    /// parse never reads the environment.
6452    #[test]
6453    fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
6454        let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
6455        let yaml = format!(
6456            "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"
6457        );
6458
6459        assert_eq!(
6460            RuntimeManifest::from_yaml_str(&yaml)
6461                .unwrap()
6462                .inference
6463                .unwrap()
6464                .api_key,
6465            Some(ApiKeyReference::Environment(key.to_string()))
6466        );
6467        assert_eq!(
6468            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
6469                .unwrap()
6470                .inference
6471                .unwrap()
6472                .api_key,
6473            None
6474        );
6475    }
6476
6477    #[test]
6478    fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
6479        let literal = ["sk-", "ant-", "debug"].concat();
6480        let debug = format!(
6481            "{:?} {:?}",
6482            ApiKeyReference::Environment("SOME_KEY".to_string()),
6483            ApiKeyReference::Literal(literal.clone())
6484        );
6485        assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
6486        assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
6487        assert!(!debug.contains(&literal), "{debug}");
6488    }
6489
6490    #[test]
6491    fn parse_without_secrets_drops_a_literal_api_key() {
6492        // Key assembled at runtime so the source never contains a
6493        // credential-shaped literal that secret scanners could flag.
6494        let literal = ["sk-", "ant-", "literal"].concat();
6495        let yaml = format!(
6496            "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: {literal}\n  driver:\n    artifact: murmur-driver-anthropic\n"
6497        );
6498
6499        assert_eq!(
6500            RuntimeManifest::from_yaml_str(&yaml)
6501                .unwrap()
6502                .inference
6503                .unwrap()
6504                .api_key,
6505            Some(ApiKeyReference::Literal(literal))
6506        );
6507        assert_eq!(
6508            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
6509                .unwrap()
6510                .inference
6511                .unwrap()
6512                .api_key,
6513            None
6514        );
6515    }
6516
6517    /// The non-resolving parse is narrower only in what it resolves, never in what it validates:
6518    /// a manifest roost accepts is a manifest `mur run` would also accept.
6519    #[test]
6520    fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
6521        let process_with_key = format!(
6522            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  model: test-model\n  api_key: {}\n",
6523            ["sk-", "ant-", "secret"].concat()
6524        );
6525        let http_without_model = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: http\n  endpoint: http://127.0.0.1:8080\n  driver:\n    artifact: murmur-driver-anthropic\n";
6526
6527        for yaml in [process_with_key.as_str(), http_without_model] {
6528            let resolving = RuntimeManifest::from_yaml_str(yaml)
6529                .unwrap_err()
6530                .to_string();
6531            let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
6532                .unwrap_err()
6533                .to_string();
6534            assert_eq!(resolving, without_secrets);
6535        }
6536    }
6537
6538    /// The reference scan answers from the manifest text alone: it reports a reference, ignores
6539    /// a literal, and has nothing to say about a manifest that carries no `inference` block or
6540    /// does not parse at all.
6541    #[test]
6542    fn referenced_env_variables_reports_a_reference_and_nothing_else() {
6543        let referencing = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: http\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  driver:\n    artifact: murmur-driver-anthropic\n  api_key: ${MURMUR_TEST_REFERENCED_KEY}\n";
6544        let referenced = referenced_env_variables(referencing);
6545        assert_eq!(referenced.len(), 1);
6546        assert_eq!(referenced[0].field, "inference.api_key");
6547        assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
6548
6549        let literal = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: http\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  driver:\n    artifact: murmur-driver-anthropic\n  api_key: not-a-reference\n";
6550        assert!(referenced_env_variables(literal).is_empty());
6551
6552        let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
6553        assert!(referenced_env_variables(no_inference).is_empty());
6554
6555        let unparseable = "name: broken\nversion: [\n";
6556        assert!(referenced_env_variables(unparseable).is_empty());
6557    }
6558
6559    #[test]
6560    fn accepts_legacy_inference_provider_field() {
6561        let manifest = RuntimeManifest::from_yaml_str(
6562            r#"
6563name: cap
6564version: 0.0.1
6565artifacts: []
6566inference:
6567  transport: http
6568  endpoint: http://127.0.0.1:8080
6569  model: test-model
6570  provider:
6571    artifact: murmur-driver-openai
6572"#,
6573        )
6574        .unwrap();
6575
6576        assert_eq!(
6577            manifest
6578                .inference
6579                .unwrap()
6580                .driver
6581                .as_ref()
6582                .unwrap()
6583                .artifact,
6584            "murmur-driver-openai"
6585        );
6586    }
6587
6588    #[test]
6589    fn prefers_driver_field_over_legacy_provider_field() {
6590        let manifest = RuntimeManifest::from_yaml_str(
6591            r#"
6592name: cap
6593version: 0.0.1
6594artifacts: []
6595inference:
6596  transport: http
6597  endpoint: http://127.0.0.1:8080
6598  model: test-model
6599  driver:
6600    artifact: murmur-driver-anthropic
6601  provider:
6602    artifact: murmur-driver-openai
6603"#,
6604        )
6605        .unwrap();
6606
6607        assert_eq!(
6608            manifest
6609                .inference
6610                .unwrap()
6611                .driver
6612                .as_ref()
6613                .unwrap()
6614                .artifact,
6615            "murmur-driver-anthropic"
6616        );
6617    }
6618
6619    #[test]
6620    fn inference_requires_driver_artifact() {
6621        let err = RuntimeManifest::from_yaml_str(
6622            r#"
6623name: cap
6624version: 0.0.1
6625artifacts: []
6626inference:
6627  transport: http
6628  endpoint: http://127.0.0.1:8080
6629  model: test-model
6630"#,
6631        )
6632        .unwrap_err();
6633
6634        assert!(err.to_string().contains("inference.driver"));
6635    }
6636
6637    #[test]
6638    fn test_runtime_driver_deserializes() {
6639        let manifest = RuntimeManifest::from_yaml_str(
6640            r#"
6641name: cap
6642version: 0.0.1
6643artifacts:
6644  - name: my-provider
6645    version: 1.0.0
6646    runtime: driver
6647"#,
6648        )
6649        .unwrap();
6650
6651        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
6652    }
6653
6654    #[test]
6655    fn test_runtime_unknown_is_error() {
6656        let err = RuntimeManifest::from_yaml_str(
6657            r#"
6658name: cap
6659version: 0.0.1
6660artifacts:
6661  - name: foo
6662    version: 1.0.0
6663    runtime: unknown_type
6664"#,
6665        )
6666        .unwrap_err();
6667
6668        let msg = err.to_string();
6669        assert!(msg.contains("runtime"), "error should mention 'runtime'");
6670        assert!(
6671            msg.contains("unknown_type"),
6672            "error should identify the bad value"
6673        );
6674    }
6675
6676    #[test]
6677    fn test_runtime_all_variants_roundtrip() {
6678        for variant in [
6679            ArtifactRuntime::Tool,
6680            ArtifactRuntime::Driver,
6681            ArtifactRuntime::Hook,
6682            ArtifactRuntime::Skill,
6683        ] {
6684            let json = serde_json::to_string(&variant).unwrap();
6685            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
6686            assert_eq!(variant, back);
6687        }
6688    }
6689
6690    #[test]
6691    fn skill_source_without_version_defaults_to_local() {
6692        let manifest = RuntimeManifest::from_yaml_str(
6693            r#"
6694name: cap
6695version: 0.0.1
6696artifacts:
6697  - name: my-skill
6698    source: ./skills/my-skill/skill.md
6699    runtime: skill
6700"#,
6701        )
6702        .unwrap();
6703
6704        assert_eq!(manifest.artifacts.len(), 1);
6705        let artifact = &manifest.artifacts[0];
6706        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
6707        assert_eq!(
6708            artifact.source.as_deref(),
6709            Some("./skills/my-skill/skill.md")
6710        );
6711        assert_eq!(artifact.version, "local");
6712    }
6713
6714    #[test]
6715    fn skill_source_directory_path_is_accepted() {
6716        let manifest = RuntimeManifest::from_yaml_str(
6717            r#"
6718name: cap
6719version: 0.0.1
6720artifacts:
6721  - name: my-skill
6722    source: ./skills/my-skill/
6723    runtime: skill
6724"#,
6725        )
6726        .unwrap();
6727
6728        assert_eq!(
6729            manifest.artifacts[0].source.as_deref(),
6730            Some("./skills/my-skill/")
6731        );
6732        assert_eq!(manifest.artifacts[0].version, "local");
6733    }
6734
6735    #[test]
6736    fn skill_source_with_explicit_version_substitutes_local() {
6737        // Both set is allowed; version is ignored and "local" is substituted.
6738        let manifest = RuntimeManifest::from_yaml_str(
6739            r#"
6740name: cap
6741version: 0.0.1
6742artifacts:
6743  - name: my-skill
6744    source: ./skills/my-skill/skill.md
6745    version: 9.9.9
6746    runtime: skill
6747"#,
6748        )
6749        .unwrap();
6750
6751        assert_eq!(manifest.artifacts[0].version, "local");
6752    }
6753
6754    #[test]
6755    fn local_source_true_allows_source_on_non_skill_runtime() {
6756        for runtime in ["tool", "driver", "hook"] {
6757            let yaml = format!(
6758                r#"
6759name: cap
6760version: 0.0.1
6761artifacts:
6762  - name: my-thing
6763    source: ./local/path
6764    local_source: true
6765    runtime: {runtime}
6766"#
6767            );
6768            let manifest = RuntimeManifest::from_yaml_str(&yaml)
6769                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
6770            let artifact = &manifest.artifacts[0];
6771            assert!(artifact.local_source, "runtime {runtime}");
6772            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
6773            assert_eq!(artifact.version, "local", "runtime {runtime}");
6774        }
6775    }
6776
6777    #[test]
6778    fn local_source_false_on_skill_with_source_is_error() {
6779        // The gate reads the declared field, not the role: an explicit `local_source: false`
6780        // overrides the skill default and rejects `source:`.
6781        let err = RuntimeManifest::from_yaml_str(
6782            r#"
6783name: cap
6784version: 0.0.1
6785artifacts:
6786  - name: my-skill
6787    source: ./skills/my-skill
6788    local_source: false
6789    runtime: skill
6790"#,
6791        )
6792        .unwrap_err();
6793        let msg = err.to_string();
6794        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
6795        assert!(msg.contains("my-skill"), "error was: {msg}");
6796    }
6797
6798    #[test]
6799    fn local_source_defaults_from_role_when_absent() {
6800        let manifest = RuntimeManifest::from_yaml_str(
6801            r#"
6802name: cap
6803version: 0.0.1
6804artifacts:
6805  - name: my-skill
6806    source: ./skills/my-skill
6807    runtime: skill
6808  - name: my-tool
6809    version: 1.0.0
6810    runtime: tool
6811"#,
6812        )
6813        .unwrap();
6814        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
6815        assert!(
6816            !manifest.artifacts[1].local_source,
6817            "tool defaults to false"
6818        );
6819    }
6820
6821    #[test]
6822    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
6823        let manifest = RuntimeManifest::from_yaml_str(
6824            r#"
6825name: cap
6826version: 0.0.1
6827artifacts:
6828  - name: my-tool
6829    version: 1.0.0
6830    runtime: tool
6831    prompt_payload: true
6832inference:
6833  endpoint: http://127.0.0.1:8080
6834  model: test-model
6835  system_prompt_artifact: my-tool
6836  driver:
6837    artifact: murmur-driver-anthropic
6838"#,
6839        )
6840        .unwrap();
6841        assert!(manifest.artifacts[0].prompt_payload);
6842    }
6843
6844    #[test]
6845    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
6846        let err = RuntimeManifest::from_yaml_str(
6847            r#"
6848name: cap
6849version: 0.0.1
6850artifacts:
6851  - name: my-skill
6852    version: 1.0.0
6853    runtime: skill
6854    prompt_payload: false
6855inference:
6856  endpoint: http://127.0.0.1:8080
6857  model: test-model
6858  system_prompt_artifact: my-skill
6859  driver:
6860    artifact: murmur-driver-anthropic
6861"#,
6862        )
6863        .unwrap_err();
6864        let msg = err.to_string();
6865        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
6866    }
6867
6868    #[test]
6869    fn prompt_payload_defaults_from_role_when_absent() {
6870        let manifest = RuntimeManifest::from_yaml_str(
6871            r#"
6872name: cap
6873version: 0.0.1
6874artifacts:
6875  - name: my-skill
6876    version: 1.0.0
6877    runtime: skill
6878  - name: my-tool
6879    version: 1.0.0
6880    runtime: tool
6881"#,
6882        )
6883        .unwrap();
6884        assert!(
6885            manifest.artifacts[0].prompt_payload,
6886            "skill defaults to true"
6887        );
6888        assert!(
6889            !manifest.artifacts[1].prompt_payload,
6890            "tool defaults to false"
6891        );
6892    }
6893
6894    #[test]
6895    fn source_on_non_skill_runtime_is_error() {
6896        for runtime in ["tool", "driver", "hook"] {
6897            let yaml = format!(
6898                r#"
6899name: cap
6900version: 0.0.1
6901artifacts:
6902  - name: my-thing
6903    source: ./local/path
6904    runtime: {runtime}
6905"#
6906            );
6907            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
6908            let msg = err.to_string();
6909            assert!(
6910                msg.contains("'local_source: true'"),
6911                "runtime {runtime}: error was: {msg}"
6912            );
6913            assert!(
6914                msg.contains("my-thing"),
6915                "runtime {runtime}: error was: {msg}"
6916            );
6917        }
6918    }
6919
6920    #[test]
6921    fn registry_skill_still_requires_version() {
6922        let err = RuntimeManifest::from_yaml_str(
6923            r#"
6924name: cap
6925version: 0.0.1
6926artifacts:
6927  - name: my-skill
6928    runtime: skill
6929"#,
6930        )
6931        .unwrap_err();
6932
6933        let msg = err.to_string();
6934        assert!(
6935            msg.contains("missing required field 'version'"),
6936            "error was: {msg}"
6937        );
6938    }
6939
6940    #[test]
6941    fn rejects_unknown_inference_transport() {
6942        let err = RuntimeManifest::from_yaml_str(
6943            r#"
6944name: cap
6945version: 0.0.1
6946artifacts: []
6947inference:
6948  transport: grpc
6949  endpoint: ignored
6950  model: test-model
6951  driver:
6952    artifact: murmur-driver-anthropic
6953"#,
6954        )
6955        .unwrap_err();
6956
6957        let msg = err.to_string();
6958        assert!(msg.contains("inference.transport"), "error was: {msg}");
6959        assert!(msg.contains("unknown value"), "error was: {msg}");
6960        assert!(msg.contains("grpc"), "error was: {msg}");
6961    }
6962
6963    #[test]
6964    fn parses_process_transport() {
6965        let manifest = RuntimeManifest::from_yaml_str(
6966            r#"
6967name: cap
6968version: 0.0.1
6969artifacts: []
6970inference:
6971  transport: process
6972  command: claude
6973  model: claude-haiku-4-5-20251001
6974  max_turns: 20
6975"#,
6976        )
6977        .unwrap();
6978
6979        let inference = manifest.inference.expect("inference should exist");
6980        assert_eq!(inference.transport, "process");
6981        assert_eq!(inference.command, Some("claude".to_string()));
6982        assert_eq!(inference.model, "claude-haiku-4-5-20251001");
6983        assert_eq!(inference.max_turns, 20);
6984        assert!(inference.driver.is_none());
6985        assert!(inference.endpoint.is_none());
6986        assert!(inference.api_key.is_none());
6987    }
6988
6989    #[test]
6990    fn process_transport_requires_command() {
6991        let err = RuntimeManifest::from_yaml_str(
6992            r#"
6993name: cap
6994version: 0.0.1
6995artifacts: []
6996inference:
6997  transport: process
6998  model: claude-haiku-4-5-20251001
6999"#,
7000        )
7001        .unwrap_err();
7002
7003        let msg = err.to_string();
7004        assert!(msg.contains("inference.command"), "error was: {msg}");
7005    }
7006
7007    #[test]
7008    fn process_transport_allows_absent_model() {
7009        // model is optional for transport: process — an empty/absent model means "use the CLI's
7010        // account-default model" (e.g. a codex subscription default). The command is still required.
7011        let manifest = RuntimeManifest::from_yaml_str(
7012            r#"
7013name: cap
7014version: 0.0.1
7015artifacts: []
7016inference:
7017  transport: process
7018  command: codex
7019"#,
7020        )
7021        .expect("process transport should parse without a model");
7022        let inference = manifest.inference.expect("inference present");
7023        assert_eq!(inference.transport, "process");
7024        assert_eq!(inference.command.as_deref(), Some("codex"));
7025        assert_eq!(
7026            inference.model, "",
7027            "absent model resolves to empty (provider default)"
7028        );
7029    }
7030
7031    #[test]
7032    fn process_transport_rejects_driver_artifact() {
7033        let err = RuntimeManifest::from_yaml_str(
7034            r#"
7035name: cap
7036version: 0.0.1
7037artifacts: []
7038inference:
7039  transport: process
7040  command: claude
7041  model: claude-haiku-4-5-20251001
7042  driver:
7043    artifact: murmur-driver-anthropic
7044"#,
7045        )
7046        .unwrap_err();
7047
7048        let msg = err.to_string();
7049        assert!(
7050            msg.contains("inference.driver.artifact"),
7051            "error was: {msg}"
7052        );
7053        assert!(
7054            msg.contains("not valid with transport: process"),
7055            "error was: {msg}"
7056        );
7057    }
7058
7059    #[test]
7060    fn process_transport_rejects_endpoint() {
7061        let err = RuntimeManifest::from_yaml_str(
7062            r#"
7063name: cap
7064version: 0.0.1
7065artifacts: []
7066inference:
7067  transport: process
7068  command: claude
7069  model: claude-haiku-4-5-20251001
7070  endpoint: http://localhost:8080
7071"#,
7072        )
7073        .unwrap_err();
7074
7075        let msg = err.to_string();
7076        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
7077        assert!(
7078            msg.contains("not valid with transport: process"),
7079            "error was: {msg}"
7080        );
7081    }
7082
7083    #[test]
7084    fn process_transport_rejects_api_key() {
7085        // Key assembled at runtime so the source never contains a
7086        // credential-shaped literal that secret scanners could flag.
7087        let key = ["sk-", "ant-", "secret"].concat();
7088        let err = RuntimeManifest::from_yaml_str(&format!(
7089            r#"
7090name: cap
7091version: 0.0.1
7092artifacts: []
7093inference:
7094  transport: process
7095  command: claude
7096  model: claude-haiku-4-5-20251001
7097  api_key: {key}
7098"#
7099        ))
7100        .unwrap_err();
7101
7102        let msg = err.to_string();
7103        assert!(msg.contains("inference.api_key"), "error was: {msg}");
7104        assert!(
7105            msg.contains("not valid with transport: process"),
7106            "error was: {msg}"
7107        );
7108    }
7109
7110    #[test]
7111    fn process_transport_rejects_max_tokens() {
7112        // Rejected even at a perfectly valid value: the CLI subprocess path never builds a
7113        // driver payload, so accepting it would leave it silently inert.
7114        let err = RuntimeManifest::from_yaml_str(
7115            r#"
7116name: cap
7117version: 0.0.1
7118artifacts: []
7119inference:
7120  transport: process
7121  command: claude
7122  model: claude-haiku-4-5-20251001
7123  max_tokens: 4096
7124"#,
7125        )
7126        .unwrap_err();
7127
7128        let msg = err.to_string();
7129        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
7130        assert!(
7131            msg.contains("not valid with transport: process"),
7132            "error was: {msg}"
7133        );
7134    }
7135
7136    #[test]
7137    fn process_transport_rejects_max_session_tokens() {
7138        let err = RuntimeManifest::from_yaml_str(
7139            r#"
7140name: cap
7141version: 0.0.1
7142artifacts: []
7143inference:
7144  transport: process
7145  command: claude
7146  model: claude-haiku-4-5-20251001
7147  max_session_tokens: 200000
7148"#,
7149        )
7150        .unwrap_err();
7151
7152        let msg = err.to_string();
7153        assert!(
7154            msg.contains("inference.max_session_tokens"),
7155            "error was: {msg}"
7156        );
7157        assert!(
7158            msg.contains("not valid with transport: process"),
7159            "error was: {msg}"
7160        );
7161    }
7162
7163    #[test]
7164    fn inference_max_session_tokens_round_trips() {
7165        let manifest = RuntimeManifest::from_yaml_str(
7166            r#"
7167name: cap
7168version: 0.0.1
7169artifacts: []
7170inference:
7171  transport: http
7172  endpoint: https://api.anthropic.com
7173  model: claude-opus-4-5
7174  max_session_tokens: 5000000000
7175  driver:
7176    artifact: murmur-driver-anthropic
7177"#,
7178        )
7179        .unwrap();
7180
7181        assert_eq!(
7182            manifest.inference.unwrap().max_session_tokens,
7183            Some(5_000_000_000)
7184        );
7185    }
7186
7187    #[test]
7188    fn inference_max_session_tokens_absent_is_none() {
7189        let manifest = RuntimeManifest::from_yaml_str(
7190            r#"
7191name: cap
7192version: 0.0.1
7193artifacts: []
7194inference:
7195  transport: http
7196  endpoint: https://api.anthropic.com
7197  model: claude-opus-4-5
7198  driver:
7199    artifact: murmur-driver-anthropic
7200"#,
7201        )
7202        .unwrap();
7203
7204        assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
7205    }
7206
7207    #[test]
7208    fn inference_max_session_tokens_zero_is_rejected() {
7209        let err = RuntimeManifest::from_yaml_str(
7210            r#"
7211name: cap
7212version: 0.0.1
7213artifacts: []
7214inference:
7215  transport: http
7216  endpoint: https://api.anthropic.com
7217  model: claude-opus-4-5
7218  max_session_tokens: 0
7219  driver:
7220    artifact: murmur-driver-anthropic
7221"#,
7222        )
7223        .unwrap_err();
7224
7225        let msg = err.to_string();
7226        assert!(
7227            msg.contains("inference.max_session_tokens"),
7228            "error was: {msg}"
7229        );
7230        assert!(msg.contains("greater than 0"), "error was: {msg}");
7231    }
7232
7233    #[test]
7234    fn inference_max_tokens_round_trips() {
7235        let manifest = RuntimeManifest::from_yaml_str(
7236            r#"
7237name: cap
7238version: 0.0.1
7239artifacts: []
7240inference:
7241  transport: http
7242  endpoint: https://api.anthropic.com
7243  model: claude-opus-4-5
7244  max_tokens: 4096
7245  driver:
7246    artifact: murmur-driver-anthropic
7247"#,
7248        )
7249        .unwrap();
7250
7251        let inference = manifest.inference.unwrap();
7252        assert_eq!(inference.max_tokens, Some(4096));
7253    }
7254
7255    #[test]
7256    fn inference_max_tokens_absent_is_none() {
7257        // Absent means "the manifest didn't set it" all the way down; the 8192 default is
7258        // applied once, by the runtime, not smuggled in here.
7259        let manifest = RuntimeManifest::from_yaml_str(
7260            r#"
7261name: cap
7262version: 0.0.1
7263artifacts: []
7264inference:
7265  transport: http
7266  endpoint: https://api.anthropic.com
7267  model: claude-opus-4-5
7268  driver:
7269    artifact: murmur-driver-anthropic
7270"#,
7271        )
7272        .unwrap();
7273
7274        assert_eq!(manifest.inference.unwrap().max_tokens, None);
7275    }
7276
7277    #[test]
7278    fn inference_max_tokens_zero_is_rejected() {
7279        let err = RuntimeManifest::from_yaml_str(
7280            r#"
7281name: cap
7282version: 0.0.1
7283artifacts: []
7284inference:
7285  transport: http
7286  endpoint: https://api.anthropic.com
7287  model: claude-opus-4-5
7288  max_tokens: 0
7289  driver:
7290    artifact: murmur-driver-anthropic
7291"#,
7292        )
7293        .unwrap_err();
7294
7295        let msg = err.to_string();
7296        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
7297        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
7298    }
7299
7300    #[test]
7301    fn inference_max_tokens_large_value_is_accepted() {
7302        // Manifest validation stays advisory: an over-large cap is the provider's to reject
7303        // at request time, not ours to clamp or block at load time.
7304        let manifest = RuntimeManifest::from_yaml_str(
7305            r#"
7306name: cap
7307version: 0.0.1
7308artifacts: []
7309inference:
7310  transport: http
7311  endpoint: https://api.anthropic.com
7312  model: claude-opus-4-5
7313  max_tokens: 999999
7314  driver:
7315    artifact: murmur-driver-anthropic
7316"#,
7317        )
7318        .unwrap();
7319
7320        assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
7321    }
7322
7323    #[test]
7324    fn inference_and_context_max_tokens_are_independent() {
7325        // Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
7326        // session compaction budget. Neither may leak into the other.
7327        let manifest = RuntimeManifest::from_yaml_str(
7328            r#"
7329name: cap
7330version: 0.0.1
7331artifacts: []
7332inference:
7333  transport: http
7334  endpoint: https://api.anthropic.com
7335  model: claude-opus-4-5
7336  max_tokens: 4096
7337  driver:
7338    artifact: murmur-driver-anthropic
7339context:
7340  max_tokens: 200000
7341"#,
7342        )
7343        .unwrap();
7344
7345        assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
7346        assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
7347    }
7348
7349    #[test]
7350    fn hook_config_defaults_when_fields_absent() {
7351        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
7352        assert_eq!(config.binding, HookBinding::All);
7353        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
7354        assert_eq!(config.commit_policy, HookCommitPolicy::None);
7355    }
7356
7357    #[test]
7358    fn hook_config_parses_all_fields() {
7359        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
7360        let config = parse_hook_config_from_yaml(yaml).unwrap();
7361        assert_eq!(config.binding, HookBinding::OnCompaction);
7362        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
7363        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
7364    }
7365
7366    #[test]
7367    fn hook_config_parses_on_task_start() {
7368        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
7369        let config = parse_hook_config_from_yaml(yaml).unwrap();
7370        assert_eq!(config.binding, HookBinding::OnTaskStart);
7371    }
7372
7373    #[test]
7374    fn hook_config_parses_on_task_end() {
7375        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
7376        let config = parse_hook_config_from_yaml(yaml).unwrap();
7377        assert_eq!(config.binding, HookBinding::OnTaskEnd);
7378    }
7379
7380    #[test]
7381    fn hook_config_unknown_binding_lists_task_events() {
7382        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
7383        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7384        assert!(err.contains("on-task-start"), "error was: {err}");
7385        assert!(err.contains("on-task-end"), "error was: {err}");
7386    }
7387
7388    #[test]
7389    fn hook_config_async_with_commit_is_rejected() {
7390        let yaml =
7391            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
7392        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7393        assert!(err.contains("async-with-commit"), "error was: {err}");
7394    }
7395
7396    #[test]
7397    fn hook_config_on_stage_async_is_rejected() {
7398        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
7399        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7400        assert!(err.contains("on-stage"), "error was: {err}");
7401        assert!(err.contains("blocking"), "error was: {err}");
7402    }
7403
7404    #[test]
7405    fn hook_config_async_none_is_valid() {
7406        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
7407        let config = parse_hook_config_from_yaml(yaml).unwrap();
7408        assert_eq!(config.execution_mode, HookExecutionMode::Async);
7409        assert_eq!(config.commit_policy, HookCommitPolicy::None);
7410    }
7411
7412    #[test]
7413    fn hook_config_parses_reopen_task_commit_policy() {
7414        let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
7415        let config = parse_hook_config_from_yaml(yaml).unwrap();
7416        assert_eq!(config.binding, HookBinding::OnTaskEnd);
7417        assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
7418    }
7419
7420    #[test]
7421    fn hook_config_unknown_commit_policy_lists_reopen_task() {
7422        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
7423        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7424        assert!(err.contains("reopen-task"), "error was: {err}");
7425    }
7426
7427    #[test]
7428    fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
7429        for binding in ["on-shell", "on-tool-call"] {
7430            let yaml =
7431                format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
7432            let config = parse_hook_config_from_yaml(&yaml).unwrap();
7433            assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
7434        }
7435        assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
7436    }
7437
7438    /// `deny` is the one policy an omitted `binding:` cannot carry: a hook that does not name
7439    /// which of the two gated events it decides on would be asked at both.
7440    #[test]
7441    fn hook_config_deny_without_a_binding_is_rejected() {
7442        let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
7443        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7444        assert!(
7445            err.contains("requires an explicit binding"),
7446            "error was: {err}"
7447        );
7448        assert!(err.contains("on-shell"), "error was: {err}");
7449        assert!(err.contains("on-tool-call"), "error was: {err}");
7450    }
7451
7452    /// Every other binding refuses `deny` through the existing binding-mismatch check.
7453    #[test]
7454    fn hook_config_deny_on_an_ungated_binding_is_rejected() {
7455        let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
7456        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7457        assert!(err.contains("is not valid for binding"), "error was: {err}");
7458        assert!(err.contains("replace-context"), "error was: {err}");
7459    }
7460
7461    /// The unchanged async/commit check covers `deny` for free; this pins that it does.
7462    #[test]
7463    fn hook_config_async_deny_is_rejected() {
7464        let yaml =
7465            "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
7466        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7467        assert!(err.contains("async-with-commit"), "error was: {err}");
7468        assert!(err.contains("deny"), "error was: {err}");
7469    }
7470
7471    #[test]
7472    fn hook_config_unknown_commit_policy_lists_deny() {
7473        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
7474        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7475        assert!(err.contains("deny"), "error was: {err}");
7476    }
7477
7478    /// The existing, unmodified async/commit validation must also reject
7479    /// `commit_policy: reopen-task` combined with `execution_mode: async`.
7480    #[test]
7481    fn hook_config_async_reopen_task_is_rejected() {
7482        let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
7483        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7484        assert!(err.contains("async-with-commit"), "error was: {err}");
7485        assert!(err.contains("reopen-task"), "error was: {err}");
7486    }
7487
7488    // ── binding is the single source of truth for what a hook commits ─────────────
7489
7490    /// The two bindings whose events honor no `hook-output` arm at all cannot declare
7491    /// any non-`none` `commit_policy`. The error names the binding, the declared policy,
7492    /// and `none` as what the binding honors.
7493    #[test]
7494    fn hook_config_non_committing_binding_rejects_any_commit_policy() {
7495        for binding in ["on-session-start", "on-session-end"] {
7496            let yaml = format!(
7497                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
7498            );
7499            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
7500            assert!(err.contains(binding), "error was: {err}");
7501            assert!(err.contains("replace-context"), "error was: {err}");
7502            assert!(
7503                err.contains("honors commit_policy 'none'"),
7504                "error was: {err}"
7505            );
7506        }
7507    }
7508
7509    /// A binding that honors one arm rejects every *other* policy, naming the one it does
7510    /// honor so the fix is obvious from the message alone.
7511    #[test]
7512    fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
7513        for (binding, declared, honored) in [
7514            ("on-stage", "reopen-task", "write-manifests"),
7515            ("on-compaction", "write-manifests", "replace-context"),
7516            ("on-task-end", "replace-context", "reopen-task"),
7517            ("on-task-start", "replace-context", "seed-context"),
7518            ("on-shell", "replace-context", "deny"),
7519            ("on-tool-call", "replace-context", "deny"),
7520        ] {
7521            let yaml = format!(
7522                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
7523            );
7524            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
7525            assert!(err.contains(binding), "error was: {err}");
7526            assert!(
7527                err.contains(&format!("commit_policy '{declared}' is not valid")),
7528                "error was: {err}"
7529            );
7530            assert!(
7531                err.contains(&format!("honors commit_policy '{honored}'")),
7532                "error was: {err}"
7533            );
7534        }
7535    }
7536
7537    /// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
7538    /// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
7539    #[test]
7540    fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
7541        for declared in ["replace-context", "write-manifests", "reopen-task"] {
7542            let yaml = format!(
7543                "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
7544            );
7545            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
7546            assert!(err.contains("on-inference"), "error was: {err}");
7547            assert!(err.contains(declared), "error was: {err}");
7548            assert!(
7549                err.contains("honors commit_policy 'none'"),
7550                "error was: {err}"
7551            );
7552            assert!(err.contains("artifact"), "error was: {err}");
7553        }
7554    }
7555
7556    /// A binding declaring exactly the policy it honors is accepted, for every
7557    /// representable pair.
7558    #[test]
7559    fn hook_config_matching_binding_and_commit_policy_is_valid() {
7560        for (binding, policy, expected) in [
7561            (
7562                "on-stage",
7563                "write-manifests",
7564                HookCommitPolicy::WriteManifests,
7565            ),
7566            (
7567                "on-compaction",
7568                "replace-context",
7569                HookCommitPolicy::ReplaceContext,
7570            ),
7571            ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
7572            ("on-shell", "deny", HookCommitPolicy::Deny),
7573            ("on-tool-call", "deny", HookCommitPolicy::Deny),
7574        ] {
7575            let yaml =
7576                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
7577            let config = parse_hook_config_from_yaml(&yaml).unwrap();
7578            assert_eq!(config.commit_policy, expected);
7579        }
7580    }
7581
7582    /// Every binding may declare `commit_policy: none` — including the ones that honor
7583    /// an arm, which simply means the hook opts out of committing.
7584    #[test]
7585    fn hook_config_commit_policy_none_is_valid_for_every_binding() {
7586        for binding in [
7587            "on-stage",
7588            "on-session-start",
7589            "on-task-start",
7590            "on-inference",
7591            "on-tool-call",
7592            "on-shell",
7593            "on-compaction",
7594            "on-task-end",
7595            "on-session-end",
7596        ] {
7597            let yaml =
7598                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
7599            let config = parse_hook_config_from_yaml(&yaml).unwrap();
7600            assert_eq!(config.commit_policy, HookCommitPolicy::None);
7601        }
7602    }
7603
7604    /// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
7605    /// to every event — including every one that honors an arm — so every `commit_policy` is
7606    /// accepted for it. Deliberate: no narrowing for `All`. `deny` is the one exception,
7607    /// covered by [`hook_config_deny_without_a_binding_is_rejected`].
7608    #[test]
7609    fn hook_config_omitted_binding_accepts_every_commit_policy() {
7610        for (policy, expected) in [
7611            ("replace-context", HookCommitPolicy::ReplaceContext),
7612            ("write-manifests", HookCommitPolicy::WriteManifests),
7613            ("reopen-task", HookCommitPolicy::ReopenTask),
7614            ("none", HookCommitPolicy::None),
7615        ] {
7616            let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
7617            let config = parse_hook_config_from_yaml(&yaml).unwrap();
7618            assert_eq!(config.binding, HookBinding::All);
7619            assert_eq!(config.commit_policy, expected);
7620        }
7621    }
7622
7623    /// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
7624    /// binding/commit_policy check exists — this change is non-breaking for all eight. The
7625    /// field combinations are reproduced literally rather than read from that repo, so this
7626    /// suite stays self-contained.
7627    #[test]
7628    fn hook_config_shipped_default_artifact_manifests_still_parse() {
7629        let shipped: [(&str, &str, HookConfig); 8] = [
7630            (
7631                "murmur-hook-compact",
7632                "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
7633                HookConfig {
7634                    binding: HookBinding::OnCompaction,
7635                    execution_mode: HookExecutionMode::Blocking,
7636                    commit_policy: HookCommitPolicy::ReplaceContext,
7637                },
7638            ),
7639            (
7640                "murmur-hook-shell-desc",
7641                "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
7642                HookConfig {
7643                    binding: HookBinding::OnStage,
7644                    execution_mode: HookExecutionMode::Blocking,
7645                    commit_policy: HookCommitPolicy::WriteManifests,
7646                },
7647            ),
7648            (
7649                // No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
7650                "murmur-hook-regression-verifier",
7651                "execution_mode: blocking\ncommit_policy: reopen-task\n",
7652                HookConfig {
7653                    binding: HookBinding::All,
7654                    execution_mode: HookExecutionMode::Blocking,
7655                    commit_policy: HookCommitPolicy::ReopenTask,
7656                },
7657            ),
7658            (
7659                "murmur-hook-memory",
7660                "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
7661                HookConfig {
7662                    binding: HookBinding::OnTaskStart,
7663                    execution_mode: HookExecutionMode::Blocking,
7664                    commit_policy: HookCommitPolicy::SeedContext,
7665                },
7666            ),
7667            (
7668                "murmur-hook-debug",
7669                "execution_mode: async\ncommit_policy: none\n",
7670                HookConfig {
7671                    binding: HookBinding::All,
7672                    execution_mode: HookExecutionMode::Async,
7673                    commit_policy: HookCommitPolicy::None,
7674                },
7675            ),
7676            (
7677                "murmur-hook-diff-summary",
7678                "execution_mode: blocking\ncommit_policy: none\n",
7679                HookConfig {
7680                    binding: HookBinding::All,
7681                    execution_mode: HookExecutionMode::Blocking,
7682                    commit_policy: HookCommitPolicy::None,
7683                },
7684            ),
7685            ("murmur-hook-eval", "", HookConfig::default()),
7686            ("murmur-hook-grafana", "", HookConfig::default()),
7687        ];
7688
7689        for (name, fields, expected) in shipped {
7690            let yaml = format!("name: {name}\nruntime: hook\n{fields}");
7691            let config = parse_hook_config_from_yaml(&yaml)
7692                .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
7693            assert_eq!(config, expected, "shipped hook {name}");
7694        }
7695    }
7696
7697    /// The manifest-side honored-policy table agrees with the manifest spellings the
7698    /// parser accepts; `capsule-runtime` separately cross-checks it against its own
7699    /// `HONORED_OUTPUT_ARM` dispatch table.
7700    #[test]
7701    fn commit_policy_for_binding_matches_the_declarable_pairs() {
7702        assert_eq!(
7703            commit_policy_for_binding(&HookBinding::OnStage),
7704            Some(HookCommitPolicy::WriteManifests)
7705        );
7706        assert_eq!(
7707            commit_policy_for_binding(&HookBinding::OnCompaction),
7708            Some(HookCommitPolicy::ReplaceContext)
7709        );
7710        assert_eq!(
7711            commit_policy_for_binding(&HookBinding::OnTaskEnd),
7712            Some(HookCommitPolicy::ReopenTask)
7713        );
7714        assert_eq!(
7715            commit_policy_for_binding(&HookBinding::OnTaskStart),
7716            Some(HookCommitPolicy::SeedContext)
7717        );
7718        assert_eq!(
7719            commit_policy_for_binding(&HookBinding::OnToolCall),
7720            Some(HookCommitPolicy::Deny)
7721        );
7722        assert_eq!(
7723            commit_policy_for_binding(&HookBinding::OnShell),
7724            Some(HookCommitPolicy::Deny)
7725        );
7726        // `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
7727        // other two honor nothing. `All` is unconstrained by design, apart from the `deny`
7728        // carve-out the parser applies.
7729        for binding in [
7730            HookBinding::OnSessionStart,
7731            HookBinding::OnInference,
7732            HookBinding::OnSessionEnd,
7733            HookBinding::All,
7734        ] {
7735            assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
7736        }
7737    }
7738
7739    /// The `on-task-start` pairing is declarable end to end: the parser accepts it, and
7740    /// refuses the same policy on any other binding by name.
7741    #[test]
7742    fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
7743        let config = parse_hook_config_from_yaml(
7744            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
7745             commit_policy: seed-context\n",
7746        )
7747        .expect("on-task-start + seed-context is the declarable pairing");
7748        assert_eq!(config.binding, HookBinding::OnTaskStart);
7749        assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
7750
7751        let error = parse_hook_config_from_yaml(
7752            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
7753             commit_policy: seed-context\n",
7754        )
7755        .expect_err("on-compaction cannot commit a seed");
7756        assert!(error.contains("on-compaction"), "{error}");
7757        assert!(error.contains("seed-context"), "{error}");
7758    }
7759
7760    /// The unknown-value diagnostic lists every accepted spelling, so an operator who
7761    /// mistypes one reads the whole set rather than guessing.
7762    #[test]
7763    fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
7764        let error = parse_hook_config_from_yaml(
7765            "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
7766        )
7767        .expect_err("a misspelled policy is rejected");
7768        assert!(error.contains("seed-context"), "{error}");
7769    }
7770
7771    // ── context.seed_budget / context.seed_overflow_margin ───────────────────
7772
7773    fn context_of(yaml_block: &str) -> Option<ContextConfig> {
7774        RuntimeManifest::from_yaml_str(&format!(
7775            "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
7776        ))
7777        .expect("the manifest under test parses")
7778        .context
7779    }
7780
7781    #[test]
7782    fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
7783        let context = context_of(
7784            "context:\n  max_tokens: 200000\n  seed_budget: 0.10\n  seed_overflow_margin: 0.10\n",
7785        )
7786        .expect("a declared context block parses to Some");
7787        assert_eq!(context.max_tokens, Some(200_000));
7788        assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
7789        assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
7790    }
7791
7792    #[test]
7793    fn seed_budget_and_overflow_margin_default_when_omitted() {
7794        let context = context_of("context:\n  max_tokens: 200000\n")
7795            .expect("a declared context block parses to Some");
7796        assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
7797        assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
7798    }
7799
7800    /// No `context:` block at all stays `None` — the two fractions default *within* a
7801    /// declared block, and never conjure one that the operator did not write.
7802    #[test]
7803    fn seed_budget_defaults_do_not_create_a_context_block() {
7804        assert_eq!(context_of(""), None);
7805    }
7806
7807    #[test]
7808    fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
7809        for (block, field) in [
7810            ("context:\n  seed_budget: 1.5\n", "context.seed_budget"),
7811            ("context:\n  seed_budget: -0.1\n", "context.seed_budget"),
7812            (
7813                "context:\n  seed_overflow_margin: 2.0\n",
7814                "context.seed_overflow_margin",
7815            ),
7816        ] {
7817            let error = RuntimeManifest::from_yaml_str(&format!(
7818                "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
7819            ))
7820            .expect_err("an out-of-range fraction is rejected");
7821            match error {
7822                RuntimeManifestError::InvalidInferenceConfig {
7823                    field: got,
7824                    ref message,
7825                } => {
7826                    assert_eq!(got, field);
7827                    assert!(message.contains("0.0 and 1.0"), "{message}");
7828                }
7829                other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
7830            }
7831        }
7832    }
7833
7834    #[test]
7835    fn parse_tool_implementation_defaults_to_wasm() {
7836        let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
7837        assert_eq!(impl_, ArtifactImplementation::Wasm);
7838    }
7839
7840    #[test]
7841    fn parse_tool_implementation_native() {
7842        let impl_ = parse_tool_implementation_from_yaml(
7843            "name: my-tool\nruntime: tool\nimplementation: native\n",
7844        );
7845        assert_eq!(impl_, ArtifactImplementation::Native);
7846    }
7847
7848    #[test]
7849    fn mur_version_parses_correctly() {
7850        let manifest = RuntimeManifest::from_yaml_str(
7851            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
7852        )
7853        .unwrap();
7854        assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
7855    }
7856
7857    #[test]
7858    fn mur_version_absent_is_none() {
7859        let manifest =
7860            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
7861        assert_eq!(manifest.mur_version, None);
7862    }
7863
7864    #[test]
7865    fn mur_version_empty_string_is_none() {
7866        let manifest = RuntimeManifest::from_yaml_str(
7867            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
7868        )
7869        .unwrap();
7870        assert_eq!(manifest.mur_version, None);
7871    }
7872
7873    #[test]
7874    fn inference_max_turns_defaults_to_10() {
7875        let manifest = RuntimeManifest::from_yaml_str(
7876            "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",
7877        ).unwrap();
7878        assert_eq!(manifest.inference.unwrap().max_turns, 10);
7879    }
7880
7881    #[test]
7882    fn inference_max_turns_explicit_value() {
7883        let manifest = RuntimeManifest::from_yaml_str(
7884            "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",
7885        ).unwrap();
7886        assert_eq!(manifest.inference.unwrap().max_turns, 20);
7887    }
7888
7889    #[test]
7890    fn inference_max_turns_zero_is_rejected() {
7891        let err = RuntimeManifest::from_yaml_str(
7892            "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",
7893        ).unwrap_err();
7894        let msg = err.to_string();
7895        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
7896        assert!(msg.contains("greater than 0"), "error was: {msg}");
7897    }
7898
7899    /// A `lifecycle:` block that says nothing about the budget still permits one reopen.
7900    #[test]
7901    fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
7902        let manifest = RuntimeManifest::from_yaml_str(
7903            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 45\n",
7904        )
7905        .unwrap();
7906        let lifecycle = manifest.lifecycle.unwrap();
7907        assert_eq!(lifecycle.shell_grace_secs, 45);
7908        // Every other lifecycle field is untouched by declaring this one.
7909        assert_eq!(
7910            lifecycle.task_acceptance,
7911            LifecycleConfig::default().task_acceptance
7912        );
7913        assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
7914        assert_eq!(
7915            lifecycle.queue_depth,
7916            LifecycleConfig::default().queue_depth
7917        );
7918        assert_eq!(lifecycle.input_timeout_secs, None);
7919        assert_eq!(
7920            lifecycle.conversation_mode,
7921            LifecycleConfig::default().conversation_mode
7922        );
7923        assert_eq!(
7924            lifecycle.max_task_reopens,
7925            LifecycleConfig::default().max_task_reopens
7926        );
7927    }
7928
7929    #[test]
7930    fn lifecycle_shell_grace_secs_defaults_to_10() {
7931        let manifest = RuntimeManifest::from_yaml_str(
7932            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
7933        )
7934        .unwrap();
7935        let lifecycle = manifest.lifecycle.unwrap();
7936        assert_eq!(lifecycle.shell_grace_secs, 10);
7937        assert_eq!(lifecycle.queue_depth, 3);
7938
7939        let without_block =
7940            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
7941        assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
7942    }
7943
7944    /// The bound a capsule delegates under is declared, and a capsule that declares nothing is
7945    /// still bounded: absent means a ceiling, never "wait forever".
7946    #[test]
7947    fn lifecycle_delegation_deadline_secs_defaults_to_600() {
7948        let without_block =
7949            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
7950        assert_eq!(
7951            without_block.effective_lifecycle().delegation_deadline_secs,
7952            600
7953        );
7954
7955        let without_key = RuntimeManifest::from_yaml_str(
7956            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
7957        )
7958        .unwrap();
7959        let lifecycle = without_key.lifecycle.unwrap();
7960        assert_eq!(lifecycle.delegation_deadline_secs, 600);
7961        assert_eq!(lifecycle.queue_depth, 3);
7962    }
7963
7964    #[test]
7965    fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
7966        let manifest = RuntimeManifest::from_yaml_str(
7967            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
7968             delegation_deadline_secs: 30\n",
7969        )
7970        .unwrap();
7971        let lifecycle = manifest.lifecycle.unwrap();
7972        assert_eq!(lifecycle.delegation_deadline_secs, 30);
7973        // Every other lifecycle field is untouched by declaring this one.
7974        assert_eq!(
7975            lifecycle.shell_grace_secs,
7976            LifecycleConfig::default().shell_grace_secs
7977        );
7978        assert_eq!(
7979            lifecycle.max_task_reopens,
7980            LifecycleConfig::default().max_task_reopens
7981        );
7982    }
7983
7984    /// Declaring the key produces no unknown-key report, and a misspelling of it still does.
7985    #[test]
7986    fn lifecycle_delegation_deadline_secs_is_a_known_key() {
7987        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
7988        let manifest = RuntimeManifest::from_yaml_str(
7989            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
7990             delegation_deadline_secs: 30\n  delegation_deadline: 30\n",
7991        )
7992        .unwrap();
7993        let reported: Vec<(String, String)> = manifest
7994            .unknown_keys
7995            .iter()
7996            .map(|key| (key.key.clone(), key.block_path.clone()))
7997            .collect();
7998        assert_eq!(
7999            reported,
8000            vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
8001            "the declared key is known and only the misspelt neighbour is reported"
8002        );
8003        assert_eq!(
8004            manifest.lifecycle.unwrap().delegation_deadline_secs,
8005            30,
8006            "the misspelt neighbour does not disturb the declared value"
8007        );
8008    }
8009
8010    /// `0` is a valid explicit value, not an absent one: it demotes at the first poll after the
8011    /// spawn, so effectively every command detaches.
8012    #[test]
8013    fn lifecycle_shell_grace_secs_zero_is_accepted() {
8014        let manifest = RuntimeManifest::from_yaml_str(
8015            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 0\n",
8016        )
8017        .unwrap();
8018        let lifecycle = manifest.lifecycle.unwrap();
8019        assert_eq!(lifecycle.shell_grace_secs, 0);
8020        assert_eq!(
8021            lifecycle.max_task_reopens,
8022            LifecycleConfig::default().max_task_reopens
8023        );
8024    }
8025
8026    #[test]
8027    fn lifecycle_max_task_reopens_defaults_to_1() {
8028        let manifest = RuntimeManifest::from_yaml_str(
8029            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  after_task: sleep\n",
8030        )
8031        .unwrap();
8032        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
8033    }
8034
8035    /// No `lifecycle:` block at all lands on the same default through `effective_lifecycle`.
8036    #[test]
8037    fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
8038        let manifest =
8039            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
8040        assert!(manifest.lifecycle.is_none());
8041        assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
8042    }
8043
8044    #[test]
8045    fn lifecycle_max_task_reopens_explicit_value() {
8046        let manifest = RuntimeManifest::from_yaml_str(
8047            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 3\n",
8048        )
8049        .unwrap();
8050        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
8051    }
8052
8053    /// Unlike `inference.max_turns`, `0` is a valid explicit value — it disables reopening.
8054    #[test]
8055    fn lifecycle_max_task_reopens_zero_is_accepted() {
8056        let manifest = RuntimeManifest::from_yaml_str(
8057            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 0\n",
8058        )
8059        .unwrap();
8060        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
8061    }
8062
8063    /// The budget is independent of the inference transport in play.
8064    #[test]
8065    fn lifecycle_max_task_reopens_with_process_transport() {
8066        let manifest = RuntimeManifest::from_yaml_str(
8067            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\nlifecycle:\n  max_task_reopens: 2\n",
8068        ).unwrap();
8069        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
8070    }
8071
8072    #[test]
8073    fn inference_max_task_reopens_is_rejected_under_http_transport() {
8074        let err = RuntimeManifest::from_yaml_str(
8075            "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",
8076        ).unwrap_err();
8077        assert!(
8078            matches!(
8079                &err,
8080                RuntimeManifestError::InvalidInferenceConfig { field, .. }
8081                    if field == "inference.max_task_reopens"
8082            ),
8083            "error was: {err:?}"
8084        );
8085        let msg = err.to_string();
8086        assert!(
8087            msg.contains("lifecycle.max_task_reopens"),
8088            "error was: {msg}"
8089        );
8090    }
8091
8092    #[test]
8093    fn inference_max_task_reopens_is_rejected_under_process_transport() {
8094        let err = RuntimeManifest::from_yaml_str(
8095            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\n",
8096        ).unwrap_err();
8097        assert!(
8098            matches!(
8099                &err,
8100                RuntimeManifestError::InvalidInferenceConfig { field, .. }
8101                    if field == "inference.max_task_reopens"
8102            ),
8103            "error was: {err:?}"
8104        );
8105        let msg = err.to_string();
8106        assert!(
8107            msg.contains("lifecycle.max_task_reopens"),
8108            "error was: {msg}"
8109        );
8110    }
8111
8112    /// The rejection is unconditional: the new key being present alongside it changes nothing.
8113    #[test]
8114    fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
8115        let err = RuntimeManifest::from_yaml_str(
8116            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\nlifecycle:\n  max_task_reopens: 3\n",
8117        ).unwrap_err();
8118        assert!(
8119            matches!(
8120                &err,
8121                RuntimeManifestError::InvalidInferenceConfig { field, .. }
8122                    if field == "inference.max_task_reopens"
8123            ),
8124            "error was: {err:?}"
8125        );
8126    }
8127
8128    #[test]
8129    fn skill_is_llm_visible() {
8130        assert!(ArtifactRuntime::Skill.is_llm_visible());
8131    }
8132
8133    #[test]
8134    fn driver_and_hook_are_not_llm_visible() {
8135        assert!(!ArtifactRuntime::Driver.is_llm_visible());
8136        assert!(!ArtifactRuntime::Hook.is_llm_visible());
8137    }
8138
8139    #[test]
8140    fn parses_system_prompt_artifact() {
8141        let manifest = RuntimeManifest::from_yaml_str(
8142            r#"
8143name: cap
8144version: 0.0.1
8145artifacts:
8146  - name: my-skill
8147    version: 1.0.0
8148    runtime: skill
8149inference:
8150  endpoint: http://127.0.0.1:8080
8151  model: test-model
8152  system_prompt_artifact: my-skill
8153  driver:
8154    artifact: murmur-driver-anthropic
8155"#,
8156        )
8157        .unwrap();
8158
8159        let inference = manifest.inference.unwrap();
8160        assert_eq!(
8161            inference.system_prompt_artifact,
8162            Some("my-skill".to_string())
8163        );
8164        assert!(inference.system_prompt.is_none());
8165        assert!(inference.system_prompt_file.is_none());
8166    }
8167
8168    #[test]
8169    fn rejects_system_prompt_artifact_with_inline_prompt() {
8170        let err = RuntimeManifest::from_yaml_str(
8171            r#"
8172name: cap
8173version: 0.0.1
8174artifacts:
8175  - name: my-skill
8176    version: 1.0.0
8177    runtime: skill
8178inference:
8179  endpoint: http://127.0.0.1:8080
8180  model: test-model
8181  system_prompt: "inline"
8182  system_prompt_artifact: my-skill
8183  driver:
8184    artifact: murmur-driver-anthropic
8185"#,
8186        )
8187        .unwrap_err();
8188
8189        let msg = err.to_string();
8190        assert!(msg.contains("at most one"), "error was: {msg}");
8191    }
8192
8193    #[test]
8194    fn rejects_system_prompt_artifact_naming_non_skill() {
8195        let err = RuntimeManifest::from_yaml_str(
8196            r#"
8197name: cap
8198version: 0.0.1
8199artifacts:
8200  - name: my-tool
8201    version: 1.0.0
8202    runtime: tool
8203inference:
8204  endpoint: http://127.0.0.1:8080
8205  model: test-model
8206  system_prompt_artifact: my-tool
8207  driver:
8208    artifact: murmur-driver-anthropic
8209"#,
8210        )
8211        .unwrap_err();
8212
8213        let msg = err.to_string();
8214        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
8215        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
8216    }
8217
8218    #[test]
8219    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
8220        let err = RuntimeManifest::from_yaml_str(
8221            r#"
8222name: cap
8223version: 0.0.1
8224artifacts: []
8225inference:
8226  endpoint: http://127.0.0.1:8080
8227  model: test-model
8228  system_prompt_artifact: missing-skill
8229  driver:
8230    artifact: murmur-driver-anthropic
8231"#,
8232        )
8233        .unwrap_err();
8234
8235        let msg = err.to_string();
8236        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
8237        assert!(msg.contains("not declared"), "error was: {msg}");
8238    }
8239
8240    #[test]
8241    fn rejects_all_three_system_prompt_sources() {
8242        let err = RuntimeManifest::from_yaml_str(
8243            r#"
8244name: cap
8245version: 0.0.1
8246artifacts:
8247  - name: my-skill
8248    version: 1.0.0
8249    runtime: skill
8250inference:
8251  endpoint: http://127.0.0.1:8080
8252  model: test-model
8253  system_prompt: "inline"
8254  system_prompt_file: "conventions.md"
8255  system_prompt_artifact: my-skill
8256  driver:
8257    artifact: murmur-driver-anthropic
8258"#,
8259        )
8260        .unwrap_err();
8261
8262        let msg = err.to_string();
8263        assert!(msg.contains("at most one"), "error was: {msg}");
8264    }
8265
8266    // ── Per-artifact capability grants ───────────────────────────────────────
8267
8268    #[test]
8269    fn hook_artifact_capabilities_parse_network_and_filesystem() {
8270        let manifest = RuntimeManifest::from_yaml_str(
8271            r#"
8272name: cap
8273version: 0.0.1
8274artifacts:
8275  - name: telemetry-hook
8276    version: 1.0.0
8277    runtime: hook
8278    capabilities:
8279      network:
8280        allow:
8281          - https://telemetry.example.com
8282      filesystem:
8283        scope: hook-state
8284"#,
8285        )
8286        .unwrap();
8287
8288        let caps = manifest.artifacts[0]
8289            .capabilities
8290            .as_ref()
8291            .expect("hook entry carries a capability grant");
8292        assert_eq!(
8293            caps.network.as_ref().unwrap().allow,
8294            vec!["https://telemetry.example.com".to_string()]
8295        );
8296        assert_eq!(
8297            caps.filesystem.as_ref().unwrap().scope,
8298            Some("hook-state".to_string())
8299        );
8300    }
8301
8302    /// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
8303    /// runtime lowers to zero network and zero preopened directories.
8304    #[test]
8305    fn hook_artifact_without_capabilities_is_none() {
8306        let manifest = RuntimeManifest::from_yaml_str(
8307            r#"
8308name: cap
8309version: 0.0.1
8310artifacts:
8311  - name: quiet-hook
8312    version: 1.0.0
8313    runtime: hook
8314"#,
8315        )
8316        .unwrap();
8317
8318        assert!(manifest.artifacts[0].capabilities.is_none());
8319    }
8320
8321    /// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
8322    /// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
8323    #[test]
8324    fn tool_and_driver_artifact_capabilities_parse() {
8325        for role in ["tool", "driver"] {
8326            let yaml = format!(
8327                r#"
8328name: cap
8329version: 0.0.1
8330artifacts:
8331  - name: scoped
8332    version: 1.0.0
8333    runtime: {role}
8334    capabilities:
8335      network:
8336        allow:
8337          - https://api.example.com
8338      filesystem:
8339        scope: cache
8340"#
8341            );
8342            let manifest = RuntimeManifest::from_yaml_str(&yaml)
8343                .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
8344
8345            let caps = manifest.artifacts[0]
8346                .capabilities
8347                .as_ref()
8348                .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
8349            assert_eq!(
8350                caps.network.as_ref().unwrap().allow,
8351                vec!["https://api.example.com".to_string()]
8352            );
8353            assert_eq!(
8354                caps.filesystem.as_ref().unwrap().scope,
8355                Some("cache".to_string())
8356            );
8357        }
8358    }
8359
8360    /// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
8361    /// lowers to the unchanged capsule ceiling.
8362    #[test]
8363    fn tool_artifact_without_capabilities_is_none() {
8364        let manifest = RuntimeManifest::from_yaml_str(
8365            r#"
8366name: cap
8367version: 0.0.1
8368artifacts:
8369  - name: plain-tool
8370    version: 1.0.0
8371    runtime: tool
8372"#,
8373        )
8374        .unwrap();
8375
8376        assert!(manifest.artifacts[0].capabilities.is_none());
8377    }
8378
8379    /// A skill has no execution surface, so a grant on one would be silently unenforced —
8380    /// rejected at parse time, with a message naming the roles that do work.
8381    #[test]
8382    fn skill_artifact_capabilities_are_rejected() {
8383        let yaml = r#"
8384name: cap
8385version: 0.0.1
8386artifacts:
8387  - name: sneaky
8388    version: 1.0.0
8389    runtime: skill
8390    capabilities:
8391      network:
8392        allow:
8393          - https://evil.example.com
8394"#;
8395        let err = match RuntimeManifest::from_yaml_str(yaml) {
8396            Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
8397            Err(err) => err,
8398        };
8399        let msg = err.to_string();
8400        assert!(msg.contains("sneaky"), "error was: {msg}");
8401        assert!(
8402            msg.contains(
8403                "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
8404                 entries"
8405            ),
8406            "error was: {msg}"
8407        );
8408        assert!(msg.contains("runtime: skill"), "error was: {msg}");
8409    }
8410
8411    // ── Per-artifact `config:` (operator-authored artifact configuration) ────
8412
8413    /// The block is carried verbatim as YAML. This crate accepts any mapping; the runtime is
8414    /// what lowers it to JSON and refuses a shape that cannot travel that way.
8415    #[test]
8416    fn artifact_config_is_carried_verbatim() {
8417        let manifest = RuntimeManifest::from_yaml_str(
8418            r#"
8419name: cap
8420version: 0.0.1
8421artifacts:
8422  - name: murmur-tool-corpus
8423    version: 0.1.0
8424    runtime: tool
8425    config:
8426      read_recent: { default: 20, max: 100 }
8427"#,
8428        )
8429        .unwrap();
8430
8431        let config = manifest.artifacts[0]
8432            .config
8433            .as_ref()
8434            .expect("a declared block parses");
8435        assert_eq!(
8436            config["read_recent"]["max"],
8437            serde_yaml::Value::Number(100.into())
8438        );
8439    }
8440
8441    /// Absent means absent: no key, no variable, and nothing for the runtime to lower.
8442    #[test]
8443    fn an_undeclared_config_is_none() {
8444        let manifest = RuntimeManifest::from_yaml_str(
8445            r#"
8446name: cap
8447version: 0.0.1
8448artifacts:
8449  - name: git
8450    version: 1.0.0
8451    runtime: tool
8452"#,
8453        )
8454        .unwrap();
8455
8456        assert!(manifest.artifacts[0].config.is_none());
8457    }
8458
8459    /// `config:` with nothing under it is a written declaration that carries nothing, and must
8460    /// stay distinguishable from the key being absent — the runtime refuses it, and can only do
8461    /// so if the parser does not collapse YAML null into `None`.
8462    #[test]
8463    fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
8464        let manifest = RuntimeManifest::from_yaml_str(
8465            r#"
8466name: cap
8467version: 0.0.1
8468artifacts:
8469  - name: git
8470    version: 1.0.0
8471    runtime: tool
8472    config:
8473"#,
8474        )
8475        .unwrap();
8476
8477        assert_eq!(
8478            manifest.artifacts[0].config,
8479            Some(serde_yaml::Value::Null),
8480            "an empty block must survive parsing as a declaration, not vanish into None"
8481        );
8482    }
8483
8484    /// A skill runs no component and holds no grant, so nothing would ever deliver a config
8485    /// block to one — rejected at parse time, with a message naming the roles that do.
8486    #[test]
8487    fn skill_artifact_config_is_rejected() {
8488        let yaml = r#"
8489name: cap
8490version: 0.0.1
8491artifacts:
8492  - name: sneaky
8493    version: 1.0.0
8494    runtime: skill
8495    config:
8496      who: me
8497"#;
8498        let err = match RuntimeManifest::from_yaml_str(yaml) {
8499            Ok(_) => panic!("config: must be rejected for runtime: skill"),
8500            Err(err) => err,
8501        };
8502        assert!(matches!(
8503            err,
8504            RuntimeManifestError::InvalidArtifact { index: 0, .. }
8505        ));
8506        let msg = err.to_string();
8507        assert!(msg.contains("sneaky"), "error was: {msg}");
8508        assert!(msg.contains("runtime: skill"), "error was: {msg}");
8509        assert!(
8510            msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
8511            "error was: {msg}"
8512        );
8513    }
8514
8515    /// Config is delivered on one artifact's own grant, so a capsule-wide block reaches nothing.
8516    /// Refused rather than silently ignored, and the message points at the artifact entry.
8517    #[test]
8518    fn a_capsule_wide_config_block_is_rejected() {
8519        let yaml = r#"
8520name: cap
8521version: 0.0.1
8522config:
8523  who: capsule
8524artifacts:
8525  - name: git
8526    version: 1.0.0
8527    runtime: tool
8528"#;
8529        let err = match RuntimeManifest::from_yaml_str(yaml) {
8530            Ok(_) => panic!("a capsule-wide config block must be rejected"),
8531            Err(err) => err,
8532        };
8533        let msg = err.to_string();
8534        assert!(msg.contains("config"), "error was: {msg}");
8535        assert!(msg.contains("declared per artifact"), "error was: {msg}");
8536    }
8537
8538    // ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
8539
8540    /// `on_overflow: block` on a hook entry parses to the blocking policy.
8541    #[test]
8542    fn hook_on_overflow_block_parses() {
8543        let manifest = RuntimeManifest::from_yaml_str(
8544            r#"
8545name: cap
8546version: 0.0.1
8547artifacts:
8548  - name: murmur-hook-grafana
8549    version: 1.0.0
8550    runtime: hook
8551    on_overflow: block
8552"#,
8553        )
8554        .unwrap();
8555
8556        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
8557    }
8558
8559    /// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
8560    /// not backpressure they never asked for.
8561    #[test]
8562    fn hook_on_overflow_defaults_to_drop() {
8563        let manifest = RuntimeManifest::from_yaml_str(
8564            r#"
8565name: cap
8566version: 0.0.1
8567artifacts:
8568  - name: murmur-hook-grafana
8569    version: 1.0.0
8570    runtime: hook
8571"#,
8572        )
8573        .unwrap();
8574
8575        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
8576        assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
8577    }
8578
8579    /// Only a hook has a job queue, so the key is rejected on every other role rather than
8580    /// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
8581    #[test]
8582    fn on_overflow_on_a_non_hook_artifact_is_rejected() {
8583        let yaml = r#"
8584name: cap
8585version: 0.0.1
8586artifacts:
8587  - name: git
8588    version: 1.0.0
8589    runtime: tool
8590    on_overflow: block
8591"#;
8592        let err = match RuntimeManifest::from_yaml_str(yaml) {
8593            Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
8594            Err(err) => err,
8595        };
8596        assert!(matches!(
8597            err,
8598            RuntimeManifestError::InvalidArtifact { index: 0, .. }
8599        ));
8600        let msg = err.to_string();
8601        assert!(msg.contains("git"), "error was: {msg}");
8602        assert!(
8603            msg.contains("only recognized on 'runtime: hook' entries"),
8604            "error was: {msg}"
8605        );
8606    }
8607
8608    /// An unknown value is a typo, not a policy — rejected with both spellings named.
8609    #[test]
8610    fn unknown_on_overflow_value_is_rejected() {
8611        let yaml = r#"
8612name: cap
8613version: 0.0.1
8614artifacts:
8615  - name: murmur-hook-grafana
8616    version: 1.0.0
8617    runtime: hook
8618    on_overflow: spill
8619"#;
8620        let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
8621        let msg = err.to_string();
8622        assert!(msg.contains("spill"), "error was: {msg}");
8623        assert!(msg.contains("expected: drop, block"), "error was: {msg}");
8624    }
8625
8626    /// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
8627    /// cannot know a hook's execution mode, which lives in the artifact's own manifest.
8628    #[test]
8629    fn on_overflow_is_accepted_on_any_hook_entry() {
8630        let manifest = RuntimeManifest::from_yaml_str(
8631            r#"
8632name: cap
8633version: 0.0.1
8634artifacts:
8635  - name: murmur-hook-eval
8636    version: 1.0.0
8637    runtime: hook
8638    on_overflow: drop
8639    capabilities:
8640      network:
8641        allow:
8642          - https://telemetry.example.com
8643"#,
8644        )
8645        .unwrap();
8646
8647        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
8648        assert!(manifest.artifacts[0].capabilities.is_some());
8649    }
8650
8651    /// The capsule-wide top-level block is untouched by the per-artifact field: both may be
8652    /// present in one manifest and neither shadows the other.
8653    #[test]
8654    fn capsule_wide_and_hook_capabilities_coexist() {
8655        let manifest = RuntimeManifest::from_yaml_str(
8656            r#"
8657name: cap
8658version: 0.0.1
8659artifacts:
8660  - name: telemetry-hook
8661    version: 1.0.0
8662    runtime: hook
8663    capabilities:
8664      network:
8665        allow:
8666          - https://telemetry.example.com
8667capabilities:
8668  network:
8669    allow:
8670      - https://api.anthropic.com
8671"#,
8672        )
8673        .unwrap();
8674
8675        assert_eq!(
8676            manifest.capabilities.unwrap().network.unwrap().allow,
8677            vec!["https://api.anthropic.com".to_string()]
8678        );
8679        assert_eq!(
8680            manifest.artifacts[0]
8681                .capabilities
8682                .as_ref()
8683                .unwrap()
8684                .network
8685                .as_ref()
8686                .unwrap()
8687                .allow,
8688            vec!["https://telemetry.example.com".to_string()]
8689        );
8690    }
8691
8692    /// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
8693    /// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
8694    /// reads one. A self-declared `capabilities:` block there is inert.
8695    #[test]
8696    fn hook_own_manifest_capabilities_are_not_read_into_config() {
8697        let config = parse_hook_config_from_yaml(
8698            r#"
8699name: telemetry-hook
8700version: 1.0.0
8701binding: on-inference
8702execution_mode: blocking
8703capabilities:
8704  network:
8705    allow:
8706      - https://evil.example.com
8707  filesystem:
8708    scope: /
8709"#,
8710        )
8711        .expect("a self-declared capabilities block is ignored, not an error");
8712
8713        assert_eq!(
8714            config,
8715            HookConfig {
8716                binding: HookBinding::OnInference,
8717                execution_mode: HookExecutionMode::Blocking,
8718                commit_policy: HookCommitPolicy::None,
8719            }
8720        );
8721    }
8722
8723    // ── Containment class ────────────────────────────────────────────────────
8724
8725    #[test]
8726    fn containment_classes_order_weakest_to_strongest() {
8727        assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
8728        assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
8729        assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
8730        assert_eq!(
8731            ContainmentClass::ALL.iter().copied().max(),
8732            Some(ContainmentClass::Sealed)
8733        );
8734    }
8735
8736    #[test]
8737    fn containment_class_defaults_to_advisory() {
8738        assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
8739    }
8740
8741    #[test]
8742    fn containment_class_round_trips_through_string() {
8743        for class in ContainmentClass::ALL {
8744            assert_eq!(
8745                class.to_string().parse::<ContainmentClass>(),
8746                Ok(class),
8747                "{class} must survive Display -> FromStr"
8748            );
8749        }
8750        assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
8751        assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
8752        assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
8753    }
8754
8755    #[test]
8756    fn containment_class_rejects_unknown_names() {
8757        let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
8758        assert_eq!(err.value, "paranoid");
8759        assert_eq!(
8760            err.to_string(),
8761            "must be one of: advisory, scoped, sealed; got 'paranoid'"
8762        );
8763        // Case-sensitive by design: the wire form is lowercase everywhere.
8764        assert!("Sealed".parse::<ContainmentClass>().is_err());
8765    }
8766
8767    #[test]
8768    fn containment_class_serializes_as_its_wire_name() {
8769        assert_eq!(
8770            serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
8771            "\"scoped\""
8772        );
8773        assert_eq!(
8774            serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
8775            ContainmentClass::Sealed
8776        );
8777    }
8778
8779    #[test]
8780    fn effective_floor_is_advisory_when_nothing_is_declared() {
8781        assert_eq!(
8782            effective_containment_floor(None, None, None),
8783            ContainmentClass::Advisory
8784        );
8785    }
8786
8787    #[test]
8788    fn effective_floor_takes_the_strongest_across_every_presence_combination() {
8789        use ContainmentClass::{Advisory, Scoped, Sealed};
8790
8791        // Exactly one source present: that source wins outright.
8792        assert_eq!(
8793            effective_containment_floor(Some(Scoped), None, None),
8794            Scoped
8795        );
8796        assert_eq!(
8797            effective_containment_floor(None, Some(Scoped), None),
8798            Scoped
8799        );
8800        assert_eq!(
8801            effective_containment_floor(None, None, Some(Scoped)),
8802            Scoped
8803        );
8804        assert_eq!(
8805            effective_containment_floor(Some(Sealed), None, None),
8806            Sealed
8807        );
8808        assert_eq!(
8809            effective_containment_floor(None, Some(Sealed), None),
8810            Sealed
8811        );
8812        assert_eq!(
8813            effective_containment_floor(None, None, Some(Sealed)),
8814            Sealed
8815        );
8816
8817        // Two sources present: the stronger wins regardless of which slot holds it, and a
8818        // weaker source can never pull the stronger one down.
8819        assert_eq!(
8820            effective_containment_floor(Some(Advisory), Some(Sealed), None),
8821            Sealed
8822        );
8823        assert_eq!(
8824            effective_containment_floor(Some(Sealed), Some(Advisory), None),
8825            Sealed
8826        );
8827        assert_eq!(
8828            effective_containment_floor(Some(Advisory), None, Some(Scoped)),
8829            Scoped
8830        );
8831        assert_eq!(
8832            effective_containment_floor(Some(Scoped), None, Some(Advisory)),
8833            Scoped
8834        );
8835        assert_eq!(
8836            effective_containment_floor(None, Some(Advisory), Some(Scoped)),
8837            Scoped
8838        );
8839        assert_eq!(
8840            effective_containment_floor(None, Some(Scoped), Some(Advisory)),
8841            Scoped
8842        );
8843
8844        // All three present: max wins from every slot, and all-equal is a fixed point.
8845        assert_eq!(
8846            effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
8847            Sealed
8848        );
8849        assert_eq!(
8850            effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
8851            Sealed
8852        );
8853        assert_eq!(
8854            effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
8855            Sealed
8856        );
8857        assert_eq!(
8858            effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
8859            Scoped
8860        );
8861        assert_eq!(
8862            effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
8863            Advisory
8864        );
8865    }
8866
8867    #[test]
8868    fn manifest_parses_each_declared_containment_class() {
8869        for class in ContainmentClass::ALL {
8870            let manifest = RuntimeManifest::from_yaml_str(&format!(
8871                r#"
8872name: cap
8873version: 0.0.1
8874capabilities:
8875  containment: {class}
8876"#
8877            ))
8878            .expect("a valid containment class parses");
8879
8880            assert_eq!(
8881                manifest.capabilities.unwrap().containment,
8882                Some(class),
8883                "capabilities.containment: {class}"
8884            );
8885        }
8886    }
8887
8888    #[test]
8889    fn manifest_without_containment_key_declares_nothing() {
8890        // Absent `capabilities:` block entirely.
8891        let manifest = RuntimeManifest::from_yaml_str(
8892            r#"
8893name: cap
8894version: 0.0.1
8895"#,
8896        )
8897        .unwrap();
8898        assert!(manifest.capabilities.is_none());
8899
8900        // Present `capabilities:` block that simply omits the key: still None, never a
8901        // silently-defaulted Advisory floor stored as if the operator had written it.
8902        let manifest = RuntimeManifest::from_yaml_str(
8903            r#"
8904name: cap
8905version: 0.0.1
8906capabilities:
8907  network:
8908    allow:
8909      - https://api.example.com
8910"#,
8911        )
8912        .unwrap();
8913        assert_eq!(manifest.capabilities.unwrap().containment, None);
8914    }
8915
8916    #[test]
8917    fn manifest_rejects_an_unknown_containment_class() {
8918        let err = RuntimeManifest::from_yaml_str(
8919            r#"
8920name: cap
8921version: 0.0.1
8922capabilities:
8923  containment: paranoid
8924"#,
8925        )
8926        .expect_err("an unknown containment class is rejected at parse time");
8927
8928        match err {
8929            RuntimeManifestError::InvalidCapabilities { field, message } => {
8930                assert_eq!(field, "capabilities.containment");
8931                assert_eq!(
8932                    message,
8933                    "must be one of: advisory, scoped, sealed; got 'paranoid'"
8934                );
8935            }
8936            other => panic!("expected InvalidCapabilities, got {other:?}"),
8937        }
8938    }
8939
8940    // ── exports.files ────────────────────────────────────────────────────────
8941
8942    fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
8943        RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
8944    }
8945
8946    fn export_error(block: &str) -> (String, String) {
8947        match exports_manifest(block).expect_err("this exports block must not parse") {
8948            RuntimeManifestError::InvalidExports { field, message } => (field, message),
8949            other => panic!("expected InvalidExports, got {other:?}"),
8950        }
8951    }
8952
8953    fn peer_fetch_error(block: &str) -> (String, String) {
8954        match exports_manifest(block).expect_err("this capabilities block must not parse") {
8955            RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
8956            other => panic!("expected InvalidCapabilities, got {other:?}"),
8957        }
8958    }
8959
8960    // ── exports.peer_files ───────────────────────────────────────────────────
8961
8962    #[test]
8963    fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
8964        assert_eq!(parse_duration_secs("90").unwrap(), 90);
8965        assert_eq!(parse_duration_secs("30s").unwrap(), 30);
8966        assert_eq!(parse_duration_secs("15m").unwrap(), 900);
8967        assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
8968        assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
8969        assert_eq!(parse_duration_secs("  2h  ").unwrap(), 7200);
8970        assert_eq!(parse_duration_secs("0").unwrap(), 0);
8971    }
8972
8973    /// Every spelling that is not the one accepted form, refused rather than guessed at: a
8974    /// manifest meaning 5 seconds and one meaning 5 minutes must not both parse.
8975    #[test]
8976    fn parse_duration_secs_rejects_every_other_spelling() {
8977        for input in [
8978            "",
8979            "  ",
8980            "5 minutes",
8981            "30S",
8982            "1H",
8983            "5min",
8984            "1.5h",
8985            "-1",
8986            "m",
8987            "h",
8988            "1h30m",
8989            "0x10",
8990            "30 s",
8991        ] {
8992            let error = parse_duration_secs(input)
8993                .expect_err(&format!("'{input}' must not parse as a duration"));
8994            assert!(
8995                error.contains(DURATION_ACCEPTED_FORM),
8996                "'{input}' must be refused with the accepted form; got: {error}"
8997            );
8998        }
8999    }
9000
9001    #[test]
9002    fn parse_duration_secs_refuses_to_overflow() {
9003        assert!(parse_duration_secs("18446744073709551615h")
9004            .unwrap_err()
9005            .contains("overflows"));
9006    }
9007
9008    #[test]
9009    fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
9010        let manifest = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
9011            .expect("a minimal peer_files block parses");
9012        let peer = manifest.exports.unwrap().peer_files.unwrap();
9013        assert_eq!(peer.root, "out/");
9014        assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
9015        assert_eq!(peer.max_bytes, 10_485_760);
9016        // Undeclared, not defaulted: the two ephemerality cases answer an absent value
9017        // differently, and substituting here would erase the difference.
9018        assert_eq!(peer.max_ttl_secs, None);
9019        assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
9020        assert_eq!(peer.effective_max_ttl_secs(), 3600);
9021    }
9022
9023    #[test]
9024    fn peer_files_accepts_every_duration_spelling() {
9025        for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
9026            let manifest = exports_manifest(&format!(
9027                "exports:\n  peer_files:\n    root: out/\n    max_ttl: {declared}\n"
9028            ))
9029            .expect("a declared max_ttl parses");
9030            let peer = manifest.exports.unwrap().peer_files.unwrap();
9031            assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
9032            assert_eq!(peer.effective_max_ttl_secs(), expected);
9033        }
9034    }
9035
9036    /// The two export blocks are separate authorisers: declaring one says nothing about the
9037    /// other, and both may be declared side by side over different subtrees.
9038    #[test]
9039    fn the_two_export_blocks_are_independent() {
9040        let both = exports_manifest(
9041            "exports:\n  files:\n    root: out/\n    mode: read-only\n  \
9042             peer_files:\n    root: out/handoff/\n",
9043        )
9044        .expect("both blocks parse together")
9045        .exports
9046        .unwrap();
9047        assert_eq!(both.files.unwrap().root, "out/");
9048        assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
9049
9050        let only_files =
9051            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
9052                .unwrap()
9053                .exports
9054                .unwrap();
9055        assert!(only_files.peer_files.is_none());
9056
9057        let only_peer = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
9058            .unwrap()
9059            .exports
9060            .unwrap();
9061        assert!(only_peer.files.is_none());
9062
9063        let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
9064        assert!(neither.files.is_none() && neither.peer_files.is_none());
9065    }
9066
9067    #[test]
9068    fn peer_files_root_must_be_relative_and_inside_the_workdir() {
9069        for root in ["../out", "/etc", "out/../../etc"] {
9070            let (field, message) =
9071                export_error(&format!("exports:\n  peer_files:\n    root: {root}\n"));
9072            assert_eq!(field, "exports.peer_files.root", "root: {root}");
9073            assert!(
9074                message.contains(EXPORT_ROOT_ACCEPTED_FORM),
9075                "root: {root}; got: {message}"
9076            );
9077        }
9078    }
9079
9080    #[test]
9081    fn peer_files_root_is_required() {
9082        let (field, message) = export_error("exports:\n  peer_files: {}\n");
9083        assert_eq!(field, "exports.peer_files.root");
9084        assert!(message.contains("is required"));
9085        assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
9086    }
9087
9088    #[test]
9089    fn peer_files_max_ttl_states_its_accepted_form() {
9090        for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
9091            let (field, message) = export_error(&format!(
9092                "exports:\n  peer_files:\n    root: out/\n    max_ttl: \"{declared}\"\n"
9093            ));
9094            assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
9095            assert!(
9096                message.contains(DURATION_ACCEPTED_FORM),
9097                "max_ttl: {declared}; got: {message}"
9098            );
9099        }
9100    }
9101
9102    /// A zero ceiling reads like a declared surface and refuses every mint. Better discovered at
9103    /// the line that wrote it than at the first `share-file` call.
9104    #[test]
9105    fn peer_files_max_ttl_rejects_zero() {
9106        let (field, message) =
9107            export_error("exports:\n  peer_files:\n    root: out/\n    max_ttl: 0\n");
9108        assert_eq!(field, "exports.peer_files.max_ttl");
9109        assert!(message.contains("greater than zero"));
9110    }
9111
9112    #[test]
9113    fn peer_files_max_bytes_states_its_accepted_form() {
9114        let (field, message) =
9115            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 10MB\n");
9116        assert_eq!(field, "exports.peer_files.max_bytes");
9117        assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
9118    }
9119
9120    #[test]
9121    fn peer_files_max_bytes_rejects_zero() {
9122        let (field, message) =
9123            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 0\n");
9124        assert_eq!(field, "exports.peer_files.max_bytes");
9125        assert!(message.contains("greater than zero"));
9126    }
9127
9128    // ── capabilities.peer_fetch ──────────────────────────────────────────────
9129
9130    #[test]
9131    fn peer_fetch_allow_carries_its_declared_destinations() {
9132        let manifest = exports_manifest(
9133            "capabilities:\n  peer_fetch:\n    allow:\n      - localhost:41234\n      \
9134             - reporting.internal\n      - https://gateway.example.com\n",
9135        )
9136        .expect("a peer_fetch block parses");
9137        assert_eq!(
9138            manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
9139            vec![
9140                "localhost:41234".to_string(),
9141                "reporting.internal".to_string(),
9142                "https://gateway.example.com".to_string(),
9143            ]
9144        );
9145    }
9146
9147    /// An empty list is a parse error rather than a silent deny: `allow: []` reads as a declared
9148    /// grant and would otherwise refuse every fetch with no line to point at.
9149    #[test]
9150    fn peer_fetch_allow_must_not_be_empty() {
9151        let (field, message) = peer_fetch_error("capabilities:\n  peer_fetch:\n    allow: []\n");
9152        assert_eq!(field, "capabilities.peer_fetch.allow");
9153        assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
9154    }
9155
9156    #[test]
9157    fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
9158        for entry in [
9159            "http://[not a host",
9160            "\"\"",
9161            "localhost:notaport",
9162            "http://example.com/path",
9163            "ftp://example.com",
9164        ] {
9165            let (field, message) = peer_fetch_error(&format!(
9166                "capabilities:\n  peer_fetch:\n    allow:\n      - {entry}\n"
9167            ));
9168            assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
9169            assert!(
9170                message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
9171                "entry: {entry}; got: {message}"
9172            );
9173        }
9174    }
9175
9176    /// `peer_fetch` is a separate authoriser and never an alias: declaring it leaves
9177    /// `network.allow` exactly as the manifest wrote it, and vice versa.
9178    #[test]
9179    fn peer_fetch_does_not_widen_network_allow() {
9180        let capabilities = exports_manifest(
9181            "capabilities:\n  network:\n    allow:\n      - api.example.com\n  \
9182             peer_fetch:\n    allow:\n      - localhost:41234\n",
9183        )
9184        .unwrap()
9185        .capabilities
9186        .unwrap();
9187        assert_eq!(
9188            capabilities.network.unwrap().allow,
9189            vec!["api.example.com".to_string()]
9190        );
9191        assert_eq!(
9192            capabilities.peer_fetch.unwrap().allow,
9193            vec!["localhost:41234".to_string()]
9194        );
9195    }
9196
9197    #[test]
9198    fn an_absent_peer_fetch_block_is_absent() {
9199        assert!(exports_manifest("capabilities: {}\n")
9200            .unwrap()
9201            .capabilities
9202            .unwrap()
9203            .peer_fetch
9204            .is_none());
9205    }
9206
9207    #[test]
9208    fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
9209        assert_eq!(parse_byte_size("0").unwrap(), 0);
9210        assert_eq!(parse_byte_size("4096").unwrap(), 4096);
9211        assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
9212        assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
9213        assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
9214        assert_eq!(parse_byte_size("  10Mi  ").unwrap(), 10 * 1024 * 1024);
9215    }
9216
9217    /// Decimal suffixes are refused rather than guessed at: a manifest meaning 10 000 000 and one
9218    /// meaning 10 485 760 must not both parse.
9219    #[test]
9220    fn parse_byte_size_rejects_every_other_spelling() {
9221        for input in [
9222            "", "  ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
9223            "1Ki1",
9224        ] {
9225            let error = parse_byte_size(input)
9226                .expect_err(&format!("'{input}' must not parse as a byte size"));
9227            assert!(
9228                error.contains(BYTE_SIZE_ACCEPTED_FORM),
9229                "'{input}' must be refused with the accepted form; got: {error}"
9230            );
9231        }
9232    }
9233
9234    #[test]
9235    fn parse_byte_size_refuses_to_overflow() {
9236        assert!(parse_byte_size("18446744073709551615Gi")
9237            .unwrap_err()
9238            .contains("overflows"));
9239    }
9240
9241    #[test]
9242    fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
9243        let manifest =
9244            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
9245                .expect("a minimal exports block parses");
9246        let files = manifest.exports.unwrap().files.unwrap();
9247        assert_eq!(files.root, "out/");
9248        assert_eq!(files.mode, ExportMode::ReadOnly);
9249        assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
9250        assert_eq!(files.max_bytes, 10_485_760);
9251    }
9252
9253    #[test]
9254    fn exports_files_accepts_both_byte_spellings() {
9255        for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
9256            let manifest = exports_manifest(&format!(
9257                "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: {declared}\n"
9258            ))
9259            .expect("a declared max_bytes parses");
9260            assert_eq!(
9261                manifest.exports.unwrap().files.unwrap().max_bytes,
9262                expected,
9263                "max_bytes: {declared}"
9264            );
9265        }
9266    }
9267
9268    /// An `exports:` block with no `files:` is not a resource plane. It parses, and the plane
9269    /// stays undeclared — which is the deny case, not a defaulted-open one.
9270    #[test]
9271    fn an_exports_block_without_files_declares_no_plane() {
9272        let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
9273        assert!(manifest.exports.unwrap().files.is_none());
9274    }
9275
9276    #[test]
9277    fn an_absent_exports_block_is_absent() {
9278        let manifest = exports_manifest("").expect("a manifest without exports parses");
9279        assert!(manifest.exports.is_none());
9280    }
9281
9282    #[test]
9283    fn exports_files_root_must_be_relative_and_inside_the_workdir() {
9284        for root in ["../out", "/etc", "out/../../etc", "''"] {
9285            let (field, message) = export_error(&format!(
9286                "exports:\n  files:\n    root: {root}\n    mode: read-only\n"
9287            ));
9288            assert_eq!(field, "exports.files.root", "root: {root}");
9289            assert!(
9290                message.contains("must be a relative path inside the workdir"),
9291                "root: {root}; got: {message}"
9292            );
9293        }
9294    }
9295
9296    #[test]
9297    fn exports_files_root_is_required() {
9298        let (field, message) = export_error("exports:\n  files:\n    mode: read-only\n");
9299        assert_eq!(field, "exports.files.root");
9300        assert_eq!(
9301            message,
9302            "is required and must be a relative path inside the workdir"
9303        );
9304    }
9305
9306    #[test]
9307    fn exports_files_mode_is_required_and_read_only() {
9308        let (field, message) = export_error("exports:\n  files:\n    root: out/\n");
9309        assert_eq!(field, "exports.files.mode");
9310        assert_eq!(message, "is required and must be 'read-only'");
9311
9312        let (field, message) =
9313            export_error("exports:\n  files:\n    root: out/\n    mode: read-write\n");
9314        assert_eq!(field, "exports.files.mode");
9315        assert_eq!(message, "'read-write' must be 'read-only'");
9316    }
9317
9318    #[test]
9319    fn exports_files_max_bytes_states_its_accepted_form() {
9320        let (field, message) = export_error(
9321            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 10MB\n",
9322        );
9323        assert_eq!(field, "exports.files.max_bytes");
9324        assert_eq!(
9325            message,
9326            "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
9327        );
9328    }
9329
9330    /// A zero ceiling would refuse every read while reading like a declared export, so it is
9331    /// refused where it is written rather than at the first request.
9332    #[test]
9333    fn exports_files_max_bytes_rejects_zero() {
9334        let (field, message) = export_error(
9335            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 0\n",
9336        );
9337        assert_eq!(field, "exports.files.max_bytes");
9338        assert!(message.contains("must be greater than zero"), "{message}");
9339    }
9340
9341    /// A leading `./` is a relative path like any other and is not an escape.
9342    #[test]
9343    fn exports_files_root_accepts_a_current_dir_prefix() {
9344        let manifest =
9345            exports_manifest("exports:\n  files:\n    root: ./out\n    mode: read-only\n")
9346                .expect("./out is a legal root");
9347        assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
9348    }
9349
9350    // ── Unrecognized manifest keys (W-SEC-019) ────────────────────────────────
9351
9352    fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
9353        RuntimeManifest::from_yaml_str(yaml)
9354            .expect("fixture must parse")
9355            .unknown_keys
9356            .into_iter()
9357            .map(|key| (key.key, key.block_path, key.nearest_known))
9358            .collect()
9359    }
9360
9361    /// The typo the whole warning exists for: a hyphen where the key takes an underscore, named
9362    /// with the block that held it and with the key it should have been.
9363    #[test]
9364    fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
9365        assert_eq!(
9366            unknown_keys_of(
9367                "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    read-only:\n      - tests\n"
9368            ),
9369            vec![(
9370                "read-only".to_string(),
9371                "capabilities.filesystem".to_string(),
9372                Some("read_only".to_string())
9373            )]
9374        );
9375    }
9376
9377    /// A manifest exercising every layer the walk descends through — capsule-wide capabilities,
9378    /// a per-artifact block, shell, network and an artifact entry — reports nothing. A warning
9379    /// that fires on correct input is noise, and noise is ignored.
9380    #[test]
9381    fn a_manifest_of_recognized_keys_reports_nothing() {
9382        assert!(unknown_keys_of(
9383            r#"
9384name: cap
9385version: 0.1.0
9386mur_version: "0.2.0"
9387artifacts:
9388  - name: notes-tool
9389    version: 0.1.0
9390    runtime: tool
9391    capabilities:
9392      filesystem:
9393        scope: notes
9394      network:
9395        allow:
9396          - example.com:443
9397capabilities:
9398  filesystem:
9399    read_only:
9400      - tests
9401      - bench/fixtures
9402    workdir_exec: false
9403  shell:
9404    allow:
9405      - git
9406      - python3
9407  network:
9408    allow:
9409      - example.com:443
9410    unix_sockets: false
9411"#
9412        )
9413        .is_empty());
9414    }
9415
9416    /// A per-artifact capability block reports under its artifact's index, so an operator is not
9417    /// sent to the capsule-wide block for a key they wrote on one entry.
9418    #[test]
9419    fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
9420        assert_eq!(
9421            unknown_keys_of(
9422                "name: cap\nversion: 0.1.0\nartifacts:\n  - name: t\n    version: 0.1.0\n    \
9423                 runtime: tool\n    capabilities:\n      shell:\n        allow:\n          - git\n        \
9424                 allwo:\n          - git\n"
9425            ),
9426            vec![(
9427                "allwo".to_string(),
9428                "artifacts[0].capabilities.shell".to_string(),
9429                Some("allow".to_string())
9430            )]
9431        );
9432    }
9433
9434    /// A top-level key with no near neighbour reports an empty path and no suggestion — the two
9435    /// facts the emitter needs to word it as a possibly-newer key rather than a misspelling.
9436    #[test]
9437    fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
9438        assert_eq!(
9439            unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
9440            vec![("quantum_teleport".to_string(), String::new(), None)]
9441        );
9442    }
9443
9444    /// The minimum publishable delegated sub-capsule — `runtime:` plus `execution:` — reports
9445    /// nothing. Every registrant of a delegating capsule must be published, so every one of them
9446    /// carries these keys; warning on them would make the diagnostic unreadable exactly where it
9447    /// is needed most.
9448    #[test]
9449    fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
9450        assert!(unknown_keys_of(
9451            "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
9452        )
9453        .is_empty());
9454    }
9455
9456    /// All four keys `mur build` and `mur publish` read beyond `name:` and `version:`, together.
9457    #[test]
9458    fn every_build_side_key_is_recognized() {
9459        assert!(unknown_keys_of(
9460            "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
9461             wasm\nrequires_files:\n  - tool.wasm\n  - assets/logo.png\n"
9462        )
9463        .is_empty());
9464    }
9465
9466    /// `W-SEC-019` still covers every key no command reads: one sitting beside the four
9467    /// build-side keys reports, and reports alone.
9468    #[test]
9469    fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
9470        assert_eq!(
9471            unknown_keys_of(
9472                "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
9473                 static\nrequires_files:\n  - capsule.wasm\nquantum_teleport: true\n"
9474            ),
9475            vec![("quantum_teleport".to_string(), String::new(), None)]
9476        );
9477    }
9478
9479    /// The four keys are recognized and then dropped: they add no refusal and no run-time meaning,
9480    /// so a manifest carrying them parses to what it parses to without them. A wrong-shaped value
9481    /// is build's business to reject, not the run path's.
9482    #[test]
9483    fn the_build_side_keys_change_nothing_the_run_path_reads() {
9484        let body = "artifacts:\n  - name: notes-tool\n    version: 0.1.0\n    runtime: tool\n\
9485                    capabilities:\n  filesystem:\n    read_only:\n      - tests\n\
9486                    inference:\n  transport: http\n  endpoint: http://127.0.0.1:8080\n  model: \
9487                    test-model\n  driver:\n    artifact: murmur-driver-anthropic\n";
9488        let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
9489            .expect("fixture must parse");
9490        let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
9491            "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n  \
9492             any: shape\nrequires_files: not-a-list\n{body}"
9493        ))
9494        .expect("a build-side key of any shape never refuses a manifest");
9495
9496        assert!(with_build_keys.unknown_keys.is_empty());
9497        assert!(
9498            !plain.artifacts.is_empty()
9499                && plain.capabilities.is_some()
9500                && plain.inference.is_some(),
9501            "the fixture must populate the blocks the comparison below rests on, or it \
9502             compares absence against absence"
9503        );
9504        assert_eq!(with_build_keys.name, plain.name);
9505        assert_eq!(with_build_keys.version, plain.version);
9506        assert_eq!(with_build_keys.artifacts, plain.artifacts);
9507        assert_eq!(with_build_keys.capabilities, plain.capabilities);
9508        assert_eq!(with_build_keys.inference, plain.inference);
9509    }
9510
9511    /// The overflow map captures the key without consuming the block: the rest of it still parses
9512    /// and still applies. A capsule with an unrecognized key launches.
9513    #[test]
9514    fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
9515        let manifest = RuntimeManifest::from_yaml_str(
9516            "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    scope: notes\n    \
9517             read-only:\n      - tests\n",
9518        )
9519        .expect("an unrecognized key never refuses a manifest");
9520        assert_eq!(
9521            manifest
9522                .capabilities
9523                .as_ref()
9524                .and_then(|caps| caps.filesystem.as_ref())
9525                .and_then(|fs| fs.scope.as_deref()),
9526            Some("notes")
9527        );
9528        assert_eq!(manifest.unknown_keys.len(), 1);
9529    }
9530
9531    /// Every block on the way down is walked, not just the capability sub-blocks.
9532    #[test]
9533    fn nested_blocks_outside_capabilities_are_walked_too() {
9534        let keys = unknown_keys_of(
9535            "name: cap\nversion: 0.1.0\ncontext:\n  max_tokns: 100\nexports:\n  files:\n    \
9536             root: out\n    mode: read-only\n    max_byts: 10\n",
9537        );
9538        assert_eq!(
9539            keys,
9540            vec![
9541                (
9542                    "max_tokns".to_string(),
9543                    "context".to_string(),
9544                    Some("max_tokens".to_string())
9545                ),
9546                (
9547                    "max_byts".to_string(),
9548                    "exports.files".to_string(),
9549                    Some("max_bytes".to_string())
9550                ),
9551            ]
9552        );
9553    }
9554
9555    /// Source-derived, so a `Raw*` struct added later cannot skip the overflow map, and a field
9556    /// added to one cannot skip its `KNOWN_KEYS`: the suggester would then never propose it.
9557    ///
9558    /// Reads this file's own text rather than a hand-written list for the same reason the
9559    /// `CapabilityPolicy` coverage test in `capsule-runtime` does — a list maintained by hand
9560    /// records what someone remembered, not what the code says.
9561    #[test]
9562    fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
9563        let source = include_str!("runtime_manifest.rs");
9564        let lines: Vec<&str> = source.lines().collect();
9565
9566        let mut checked = 0usize;
9567        for (start, line) in lines.iter().enumerate() {
9568            let Some(name) = line
9569                .strip_prefix("struct Raw")
9570                .and_then(|rest| rest.strip_suffix(" {"))
9571            else {
9572                continue;
9573            };
9574            let name = format!("Raw{name}");
9575            let end = (start + 1..lines.len())
9576                .find(|index| lines[*index] == "}")
9577                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
9578            let body = &lines[start + 1..end];
9579
9580            assert!(
9581                body.contains(&"    #[serde(flatten)]")
9582                    && body.contains(&"    unknown: UnknownKeys,"),
9583                "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
9584                 so a key it does not recognize would be dropped instead of reported"
9585            );
9586
9587            let declared = serde_field_names(body);
9588            let known = known_keys_of(source, &name);
9589            assert_eq!(
9590                declared, known,
9591                "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
9592                 suggester would propose the wrong set for that block"
9593            );
9594            checked += 1;
9595        }
9596        assert!(
9597            checked >= 30,
9598            "expected every Raw* struct to be scanned, found only {checked}"
9599        );
9600    }
9601
9602    /// Source-derived for the same reason its sibling above is: a `Raw*`-typed field added to a
9603    /// block and left unwalked reports none of the keys inside that block, and reports nothing
9604    /// about having done so. The scan is keyed on the *parent's field*, not on the child's type,
9605    /// because `inference.driver` and `inference.provider` are the same type — a check keyed on
9606    /// the type would call `provider` covered because `driver` is walked.
9607    #[test]
9608    fn every_raw_struct_descends_into_the_blocks_it_owns() {
9609        let source = include_str!("runtime_manifest.rs");
9610        let lines: Vec<&str> = source.lines().collect();
9611
9612        let mut structs = 0usize;
9613        let mut children = 0usize;
9614        for (start, line) in lines.iter().enumerate() {
9615            let Some(name) = line
9616                .strip_prefix("struct Raw")
9617                .and_then(|rest| rest.strip_suffix(" {"))
9618            else {
9619                continue;
9620            };
9621            let name = format!("Raw{name}");
9622            let end = (start + 1..lines.len())
9623                .find(|index| lines[*index] == "}")
9624                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
9625            let owned = block_typed_fields(&lines[start + 1..end]);
9626            structs += 1;
9627
9628            if owned.is_empty() {
9629                continue;
9630            }
9631            let block = raw_block_impl_of(source, &name);
9632            assert!(
9633                block.contains("fn walk_children"),
9634                "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
9635                 `walk_children`, so every key written inside {} would go unreported",
9636                if owned.len() == 1 { "" } else { "s" },
9637                owned.join(", "),
9638                if owned.len() == 1 { "it" } else { "them" }
9639            );
9640            for field in &owned {
9641                assert!(
9642                    mentions_field(&block, field),
9643                    "{name}'s `walk_children` never touches `self.{field}`, so every key written \
9644                     inside that block would go unreported — descend into it with \
9645                     `collect_block(.., &child_path(path, \"{field}\"), out)`"
9646                );
9647                children += 1;
9648            }
9649        }
9650        assert!(
9651            structs >= 30,
9652            "expected every Raw* struct to be scanned, found only {structs}"
9653        );
9654        assert!(
9655            children >= 31,
9656            "expected every nested block field to be scanned, found only {children}"
9657        );
9658    }
9659
9660    /// Whether `impl` reads `self.<field>` as that whole field, not as the head of a longer one:
9661    /// a plain substring test lets a struct owning both `foo` and `foo_bar` walk only `foo_bar`
9662    /// and still be called covered, which is the silent pass this scan exists to deny.
9663    fn mentions_field(impl_body: &str, field: &str) -> bool {
9664        let needle = format!("self.{field}");
9665        impl_body.match_indices(&needle).any(|(at, _)| {
9666            impl_body[at + needle.len()..]
9667                .chars()
9668                .next()
9669                .is_none_or(|next| !next.is_alphanumeric() && next != '_')
9670        })
9671    }
9672
9673    /// The fields of one struct body whose declared type names another `Raw*` type, in
9674    /// declaration order. Comment and attribute lines are skipped, so a doc comment that mentions
9675    /// a `Raw*` type by name is not read as a field.
9676    fn block_typed_fields(body: &[&str]) -> Vec<String> {
9677        let mut fields = Vec::new();
9678        for line in body {
9679            let trimmed = line.trim();
9680            if trimmed.starts_with("//") || trimmed.starts_with("#[") {
9681                continue;
9682            }
9683            let Some((field, declared)) = line
9684                .strip_prefix("    ")
9685                .and_then(|rest| rest.split_once(':'))
9686            else {
9687                continue;
9688            };
9689            if field.is_empty()
9690                || !field
9691                    .chars()
9692                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
9693            {
9694                continue;
9695            }
9696            if declared.contains("Raw") {
9697                fields.push(field.to_string());
9698            }
9699        }
9700        fields
9701    }
9702
9703    /// The text of `impl RawBlock for <name>`, cut at the next line that is exactly `}` at column
9704    /// 0 so one impl's body cannot be read as the next one's.
9705    fn raw_block_impl_of(source: &str, name: &str) -> String {
9706        let header = format!("impl RawBlock for {name} {{");
9707        let body = source
9708            .split_once(&header)
9709            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
9710            .1;
9711        body.split_once("\n}\n")
9712            .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
9713            .0
9714            .to_string()
9715    }
9716
9717    /// The walk's own result, taken straight off a deserialized `RawRuntimeManifest`.
9718    ///
9719    /// Deliberately not routed through [`RuntimeManifest::from_yaml_str`]: that validates, and a
9720    /// fixture that names every block at once would have to satisfy every semantic rule as well
9721    /// to reach the walk. What is under test here is which blocks are descended into, which the
9722    /// validator has no part in.
9723    fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
9724        let raw: RawRuntimeManifest = serde_yaml::from_str(yaml).expect("fixture must parse");
9725        collect_unknown_keys(&raw)
9726            .into_iter()
9727            .map(|key| (key.key, key.block_path))
9728            .collect()
9729    }
9730
9731    /// Every block the manifest type owns, each carrying one key no field of it claims, reported
9732    /// once and at its own dotted path. A block added after this one is held to the same rule by
9733    /// `every_raw_struct_descends_into_the_blocks_it_owns`; this is what "descended into" means.
9734    #[test]
9735    fn every_block_reports_its_own_unknown_key_at_its_own_path() {
9736        let keys = raw_unknown_keys(
9737            r#"
9738name: every-block
9739version: 0.1.0
9740probe_root: 1
9741artifacts:
9742  - name: t
9743    version: 0.1.0
9744    runtime: tool
9745    probe_artifact: 1
9746    capabilities:
9747      probe_artifact_capabilities: 1
9748      shell:
9749        probe_artifact_shell: 1
9750capabilities:
9751  probe_capabilities: 1
9752  network:
9753    probe_network: 1
9754  peer_fetch:
9755    probe_peer_fetch: 1
9756  filesystem:
9757    probe_filesystem: 1
9758  shell:
9759    probe_shell: 1
9760    interpreter_runtime:
9761      - binary: python3
9762        probe_interpreter_runtime: 1
9763        dirs:
9764          - path: /usr/lib/python3
9765            probe_interpreter_runtime_dir: 1
9766    staged_runtime:
9767      - binary: rg
9768        probe_staged_runtime: 1
9769  spawn:
9770    probe_spawn: 1
9771  env:
9772    probe_env: 1
9773  limits:
9774    probe_limits: 1
9775  resources:
9776    probe_resources: 1
9777  state:
9778    probe_state: 1
9779  task_io:
9780    probe_task_io: 1
9781  conversation:
9782    probe_conversation: 1
9783inference:
9784  probe_inference: 1
9785  driver:
9786    probe_driver: 1
9787  provider:
9788    probe_provider: 1
9789  compaction:
9790    probe_compaction: 1
9791context:
9792  probe_context: 1
9793observability:
9794  probe_observability: 1
9795  eval:
9796    probe_eval: 1
9797    scorers:
9798      - type: exact_match
9799        probe_scorer: 1
9800trace:
9801  probe_trace: 1
9802network:
9803  probe_network_config: 1
9804lifecycle:
9805  probe_lifecycle: 1
9806exports:
9807  probe_exports: 1
9808  files:
9809    probe_files: 1
9810  peer_files:
9811    probe_peer_files: 1
9812"#,
9813        );
9814
9815        let expected: Vec<(String, String)> = [
9816            ("probe_root", ""),
9817            ("probe_artifact", "artifacts[0]"),
9818            ("probe_artifact_capabilities", "artifacts[0].capabilities"),
9819            ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
9820            ("probe_capabilities", "capabilities"),
9821            ("probe_network", "capabilities.network"),
9822            ("probe_peer_fetch", "capabilities.peer_fetch"),
9823            ("probe_filesystem", "capabilities.filesystem"),
9824            ("probe_shell", "capabilities.shell"),
9825            (
9826                "probe_interpreter_runtime",
9827                "capabilities.shell.interpreter_runtime[0]",
9828            ),
9829            (
9830                "probe_interpreter_runtime_dir",
9831                "capabilities.shell.interpreter_runtime[0].dirs[0]",
9832            ),
9833            (
9834                "probe_staged_runtime",
9835                "capabilities.shell.staged_runtime[0]",
9836            ),
9837            ("probe_spawn", "capabilities.spawn"),
9838            ("probe_env", "capabilities.env"),
9839            ("probe_limits", "capabilities.limits"),
9840            ("probe_resources", "capabilities.resources"),
9841            ("probe_state", "capabilities.state"),
9842            ("probe_task_io", "capabilities.task_io"),
9843            ("probe_conversation", "capabilities.conversation"),
9844            ("probe_inference", "inference"),
9845            ("probe_driver", "inference.driver"),
9846            ("probe_provider", "inference.provider"),
9847            ("probe_compaction", "inference.compaction"),
9848            ("probe_context", "context"),
9849            ("probe_observability", "observability"),
9850            ("probe_eval", "observability.eval"),
9851            ("probe_scorer", "observability.eval.scorers[0]"),
9852            ("probe_trace", "trace"),
9853            ("probe_network_config", "network"),
9854            ("probe_lifecycle", "lifecycle"),
9855            ("probe_exports", "exports"),
9856            ("probe_files", "exports.files"),
9857            ("probe_peer_files", "exports.peer_files"),
9858        ]
9859        .into_iter()
9860        .map(|(key, path)| (key.to_string(), path.to_string()))
9861        .collect();
9862
9863        assert_eq!(keys, expected);
9864    }
9865
9866    /// The names serde matches on for one struct body: field names in declaration order, with a
9867    /// `#[serde(rename = "...")]` field under its renamed spelling, and the overflow map itself
9868    /// excluded because it claims no name of its own.
9869    fn serde_field_names(body: &[&str]) -> Vec<String> {
9870        let mut names = Vec::new();
9871        let mut rename: Option<String> = None;
9872        for line in body {
9873            let trimmed = line.trim();
9874            if trimmed.starts_with("#[") {
9875                if let Some(rest) = trimmed.split("rename = \"").nth(1) {
9876                    if !trimmed.contains("rename_all") {
9877                        rename = rest.split('"').next().map(str::to_string);
9878                    }
9879                }
9880                continue;
9881            }
9882            if trimmed.starts_with("//") {
9883                continue;
9884            }
9885            let Some(field) = line
9886                .strip_prefix("    ")
9887                .and_then(|rest| rest.split(':').next())
9888            else {
9889                continue;
9890            };
9891            if field.is_empty()
9892                || !field
9893                    .chars()
9894                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
9895            {
9896                continue;
9897            }
9898            if field == "unknown" {
9899                rename = None;
9900                continue;
9901            }
9902            names.push(rename.take().unwrap_or_else(|| field.to_string()));
9903        }
9904        names
9905    }
9906
9907    /// The `KNOWN_KEYS` array `impl RawBlock for <name>` declares, read out of the source text so
9908    /// the assertion compares two independent readings of the same file rather than one value
9909    /// against itself.
9910    fn known_keys_of(source: &str, name: &str) -> Vec<String> {
9911        let header = format!("impl RawBlock for {name} {{");
9912        let body = source
9913            .split_once(&header)
9914            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
9915            .1;
9916        let array = body
9917            .split_once("KNOWN_KEYS: &'static [&'static str] = &[")
9918            .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
9919            .1
9920            .split_once("];")
9921            .expect("KNOWN_KEYS array is unterminated")
9922            .0;
9923        array
9924            .split('"')
9925            .skip(1)
9926            .step_by(2)
9927            .map(str::to_string)
9928            .collect()
9929    }
9930}