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