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    /// What a controller holding this session's control token may change while it runs. `None`
556    /// — the block absent, `control: {}`, or both lists empty — means the capsule has no control
557    /// surface and mints no token.
558    pub control: Option<ControlConfig>,
559    /// Pins the mur runtime version required by this capsule.
560    /// Used by `mur deploy` to select the binary version to install on the VM,
561    /// and by `mur run` to warn on version mismatch.
562    /// If absent, the running mur binary's version is used.
563    pub mur_version: Option<String>,
564    /// Every key the manifest declared that this build does not recognize, captured by each
565    /// `Raw*` block's `#[serde(flatten)]` overflow map instead of dropped.
566    ///
567    /// Ordered by the walk rather than by the manifest's own line order: a containing block's own
568    /// keys precede the blocks nested inside it, and one block's keys are alphabetical.
569    ///
570    /// Empty for a manifest written entirely in keys this build knows. Never a reason to refuse a
571    /// manifest — it is reported as `W-SEC-019` by
572    /// [`crate::unknown_manifest_keys::warn_on_unknown_manifest_keys`] and nothing else reads it.
573    pub unknown_keys: Vec<UnknownManifestKey>,
574}
575
576impl RuntimeManifest {
577    pub fn effective_lifecycle(&self) -> LifecycleConfig {
578        self.lifecycle.clone().unwrap_or_default()
579    }
580}
581
582/// A setting of a running session that a controller may change over the control surface, when
583/// `control.settings` names it.
584///
585/// Each variant is one manifest key, spelled by [`Self::wire_name`] in `control.settings`, in the
586/// control surface's paths and bodies, and in the trace. What a value must be, and where the agent
587/// loop reads it, is the runtime's to decide per variant.
588#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
589pub enum ControllableSetting {
590    /// `inference.max_tokens`: the per-turn output cap the agent loop sends as `max_tokens`.
591    /// `transport: http` only, on the same terms as the manifest key.
592    InferenceMaxTokens,
593    /// `inference.driver`: which driver choice serves the agent loop's inference calls, by name —
594    /// [`PRIMARY_DRIVER_CHOICE`] or an `inference.alternates` entry's `name`. `transport: http`
595    /// only, and only on a capsule that declares at least one alternate.
596    InferenceDriver,
597}
598
599impl ControllableSetting {
600    /// Every setting a manifest may list, in the order refusals name them.
601    pub const ALL: &'static [ControllableSetting] =
602        &[Self::InferenceMaxTokens, Self::InferenceDriver];
603
604    /// Every setting `control.agent_settings` may list: the ones the agent may change about
605    /// itself through a runtime-provided tool.
606    pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
607
608    /// The manifest key this setting is, as written in `control.settings`.
609    pub fn wire_name(self) -> &'static str {
610        match self {
611            Self::InferenceMaxTokens => "inference.max_tokens",
612            Self::InferenceDriver => "inference.driver",
613        }
614    }
615
616    /// The setting `name` spells, or `None` for anything outside [`Self::ALL`].
617    pub fn from_wire_name(name: &str) -> Option<Self> {
618        Self::ALL
619            .iter()
620            .copied()
621            .find(|setting| setting.wire_name() == name)
622    }
623}
624
625/// The top-level `control:` block: what a controller may change on a running session.
626///
627/// Parsed only to a non-empty declaration; `control: {}` and two empty lists parse as no block.
628/// Nothing a controller sets is persisted, so this is also everything a restart starts from.
629#[derive(Debug, Clone, PartialEq, Eq, Default)]
630pub struct ControlConfig {
631    /// Settings a controller may change, in manifest order, each listed once.
632    pub settings: Vec<ControllableSetting>,
633    /// Credential names a controller supplies at run time, in manifest order, each listed once.
634    /// Every name backs some artifact's `gateway.api_key: ${NAME}`, and none backs the configured
635    /// inference driver's. Such a name is never looked up in the config or the environment.
636    pub secrets: Vec<String>,
637    /// Settings the agent may change about itself, in manifest order, each listed once. Only
638    /// [`ControllableSetting::AGENT_SETTABLE`] members. The grant is the existence of the
639    /// runtime-provided tool that changes the setting, and never a route to the control surface.
640    pub agent_settings: Vec<ControllableSetting>,
641}
642
643impl ControlConfig {
644    /// Whether `name` is a credential a controller supplies.
645    pub fn declares_secret(&self, name: &str) -> bool {
646        self.secrets.iter().any(|secret| secret == name)
647    }
648
649    /// Whether a controller may change `setting`.
650    pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
651        self.settings.contains(&setting)
652    }
653
654    /// Whether the agent may change `setting`.
655    pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
656        self.agent_settings.contains(&setting)
657    }
658}
659
660/// The top-level `network:` block, which configures the A2A door. Unrelated to
661/// `capabilities.network`, which is the guest's egress.
662#[derive(Debug, Clone, PartialEq, Eq)]
663pub struct NetworkConfig {
664    /// Internal port the capsule expects to listen on. Default 14159 when absent.
665    pub internal_port: Option<u16>,
666    /// `network.authentication`: how a caller of the door proves it may call. `None` is a public
667    /// door, which answers every caller that reaches the port.
668    pub authentication: Option<NetworkAuthentication>,
669}
670
671/// Every scope a door token can carry: the door's JSON-RPC methods that a token gates, by wire
672/// name, and `resources/files`, the operator resource plane. The runtime's method table is held
673/// to this list by a test, in both directions.
674pub const DOOR_SCOPES: &[&str] = &[
675    "message/send",
676    "message/stream",
677    "stream/watch",
678    "tasks/get",
679    "tasks/cancel",
680    "session/stop",
681    "resources/files",
682];
683
684/// The credential the runtime always mints for an authenticated door, holding every scope in
685/// [`DOOR_SCOPES`]. Reserved: no declared credential may take the name.
686pub const OPERATOR_CREDENTIAL: &str = "operator";
687
688/// Longest credential name `network.authentication.credentials` accepts.
689const MAX_CREDENTIAL_NAME_LEN: usize = 32;
690
691/// How a caller of the door authenticates. A required field with one value, so that an
692/// authenticated door is an explicit statement and a second scheme is an addition, not a default
693/// change.
694#[derive(Debug, Clone, Copy, PartialEq, Eq)]
695pub enum AuthenticationScheme {
696    /// `Authorization: Bearer <token>`, with a token the capsule's runtime mints at launch.
697    Bearer,
698}
699
700impl AuthenticationScheme {
701    /// Every scheme a manifest may name, in the order refusals list them.
702    pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
703
704    /// The scheme as written in `network.authentication.scheme`.
705    pub fn wire_name(self) -> &'static str {
706        match self {
707            Self::Bearer => "bearer",
708        }
709    }
710}
711
712/// One declared credential: a token the runtime mints beside the operator token, reaching only
713/// `scopes`.
714#[derive(Debug, Clone, PartialEq, Eq)]
715pub struct DoorCredential {
716    /// Matches `^[a-z][a-z0-9_-]{0,31}$` and is never [`OPERATOR_CREDENTIAL`].
717    pub name: String,
718    /// Non-empty, each entry in [`DOOR_SCOPES`] and listed once, in declared order.
719    pub scopes: Vec<String>,
720}
721
722/// `network.authentication`: the door refuses every caller that presents no token this session
723/// minted.
724#[derive(Debug, Clone, PartialEq, Eq)]
725pub struct NetworkAuthentication {
726    pub scheme: AuthenticationScheme,
727    /// The declared credentials, sorted by name. Empty when only the operator token is minted.
728    pub credentials: Vec<DoorCredential>,
729}
730
731// ── Exports ───────────────────────────────────────────────────────────────────
732
733/// Per-file read ceiling applied when `exports.files.max_bytes` is absent: 10Mi.
734pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
735
736/// What the operator discloses to processes *outside* the capsule, declared under the top-level
737/// `exports:` key.
738///
739/// A sibling of `capabilities:` rather than a member of it: a capability is something the guest
740/// holds and an export is a disclosure the operator makes. Declaring one
741/// gives the agent nothing it did not already have — it widens only the operator's reach inward,
742/// which is why it never enters the achieved-containment computation.
743#[derive(Debug, Clone, PartialEq, Eq)]
744pub struct Exports {
745    /// The declared read-only file surface. `None` — an `exports:` block with no `files:` —
746    /// means the resource plane is not declared and every request to it is denied.
747    pub files: Option<FileExport>,
748    /// The declared peer-handoff surface. A separate authoriser from [`Self::files`], over a
749    /// separate subtree: declaring one grants nothing about the other, and a capsule may declare
750    /// either, both or neither. `None` means the capsule mints no handles and its peer plane
751    /// answers `no_peer_plane`.
752    pub peer_files: Option<PeerFilesExport>,
753}
754
755/// The `exports.files` block: one subtree of the workdir, readable and nothing else.
756#[derive(Debug, Clone, PartialEq, Eq)]
757pub struct FileExport {
758    /// Subtree of the capsule's accessible workdir this export opens, verbatim as declared
759    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
760    /// directory belongs to the runtime, which is the only component that may decide a path is
761    /// inside the root.
762    ///
763    /// Not required to exist when the capsule launches — the agent may create it during a task.
764    pub root: String,
765    /// Required, with exactly one legal value. A required field with one value is what makes the
766    /// read-only posture an explicit operator statement rather than an unstated default, and
767    /// leaves a future write mode somewhere to be declared rather than somewhere to leak in.
768    pub mode: ExportMode,
769    /// Per-file read ceiling in bytes, defaulting to [`DEFAULT_EXPORT_MAX_BYTES`]. Never an
770    /// aggregate budget: each file is judged on its own size, and a subtree whose total exceeds
771    /// this is still listed in full.
772    pub max_bytes: u64,
773}
774
775/// Handle lifetime applied when `exports.peer_files.max_ttl` is absent and the capsule is
776/// ephemeral (`lifecycle.after_task: exit`): 1h.
777pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
778
779/// The largest `exports.peer_files.max_ttl` a *persistent* capsule
780/// (`lifecycle.after_task: sleep`) may declare: 15m.
781///
782/// An ephemeral capsule needs no ceiling because teardown destroys the minting key and with it
783/// every outstanding handle. A persistent one has withdrawn that bound, so the declared lifetime
784/// becomes the only one — and a handle is not a durability mechanism. Enforced by the runtime,
785/// not here, because the rule reads `lifecycle.after_task` and this parser sees one block at a
786/// time.
787pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
788
789/// Per-file read ceiling applied when `exports.peer_files.max_bytes` is absent: 10Mi.
790pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
791
792/// The `exports.peer_files` block: the one subtree a `share-file` handle may name.
793///
794/// Deliberately not a mode of [`FileExport`]. The operator plane addresses files by path and
795/// enumerates them; this plane has no `list` verb and no path addressing at all, and its audience
796/// is another capsule rather than the operator. Two authorisers over two subtrees, so opening one
797/// never widens the other.
798#[derive(Debug, Clone, PartialEq, Eq)]
799pub struct PeerFilesExport {
800    /// Subtree of the capsule's accessible workdir a handle may name, verbatim as declared
801    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
802    /// directory belongs to the runtime. Not required to exist when the capsule launches.
803    pub root: String,
804    /// The declared `max_ttl`, in seconds, or `None` when the manifest declared none.
805    ///
806    /// Kept undefaulted because the two ephemerality cases answer an absent value differently:
807    /// an ephemeral capsule falls back to [`DEFAULT_PEER_HANDLE_TTL_SECS`], and a persistent one
808    /// refuses to launch. Substituting the default here would erase the difference before
809    /// anything could act on it. See [`Self::effective_max_ttl_secs`].
810    pub max_ttl_secs: Option<u64>,
811    /// Per-file ceiling on a redeemed read, defaulting to [`DEFAULT_PEER_FILES_MAX_BYTES`].
812    /// Per file, never an aggregate budget — the same terms as [`FileExport::max_bytes`], and a
813    /// separate value from it.
814    pub max_bytes: u64,
815}
816
817impl PeerFilesExport {
818    /// The handle lifetime ceiling this export actually applies: the declared `max_ttl`, or the
819    /// ephemeral default when none was declared.
820    ///
821    /// Correct for a persistent capsule too, because one that declared no `max_ttl` never
822    /// launches — the runtime refuses it before a plane is built.
823    pub fn effective_max_ttl_secs(&self) -> u64 {
824        self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
825    }
826}
827
828/// The `capabilities.peer_fetch` block: which peers this capsule may redeem a handle against.
829///
830/// A sibling of [`NetworkCapabilities`] rather than a member of it. Fetching a peer's bytes lands
831/// a file in this capsule's own workdir, so it is an ingestion path and a prompt-injection
832/// surface, and it gets its own operator control. Declaring a destination here does not widen
833/// `capabilities.network.allow`, and a destination allowed there is not redeemable unless it is
834/// also named here.
835#[derive(Debug, Clone, PartialEq, Eq)]
836pub struct PeerFetchCapabilities {
837    /// Network destinations a handle may be redeemed against. Required and non-empty when the
838    /// block is present: an empty list is a parse error, never a silent deny. Same syntax as
839    /// `capabilities.network.allow`, and matched by the same runtime rule matcher.
840    pub allow: Vec<String>,
841}
842
843/// The accepted spelling of every duration in the manifest, stated once so the parser and the
844/// error it produces cannot drift apart.
845pub const DURATION_ACCEPTED_FORM: &str =
846    "must be a duration: an integer, optionally suffixed s/m/h/d";
847
848/// Parses `90`, `30s`, `15m`, `1h`, `14d` into a whole number of seconds.
849///
850/// A bare integer is seconds. Suffixes are lowercase and single-character; `5 minutes` and `30S`
851/// are rejected rather than guessed at, on the same terms as [`parse_byte_size`]. Returns the
852/// accepted-form sentence as its error so every call site reports the same rule.
853pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
854    let trimmed = input.trim();
855    let (digits, multiplier) = match trimmed.strip_suffix('s') {
856        Some(digits) => (digits, 1u64),
857        None => match trimmed.strip_suffix('m') {
858            Some(digits) => (digits, 60),
859            None => match trimmed.strip_suffix('h') {
860                Some(digits) => (digits, 3600),
861                None => match trimmed.strip_suffix('d') {
862                    Some(digits) => (digits, 86_400),
863                    None => (trimmed, 1),
864                },
865            },
866        },
867    };
868    // Not trimmed again: the whole input was trimmed above, so a space surviving here is *inside*
869    // the value (`5 minutes`, `30 m`), which is not a spelling this accepts.
870    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
871        return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
872    }
873    digits
874        .parse::<u64>()
875        .ok()
876        .and_then(|value| value.checked_mul(multiplier))
877        .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
878}
879
880#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
881pub enum ExportMode {
882    #[serde(rename = "read-only")]
883    ReadOnly,
884}
885
886impl ExportMode {
887    pub fn as_str(self) -> &'static str {
888        match self {
889            ExportMode::ReadOnly => "read-only",
890        }
891    }
892}
893
894impl std::fmt::Display for ExportMode {
895    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
896        f.write_str(self.as_str())
897    }
898}
899
900/// The accepted spelling of every byte count in the manifest, stated once so the parser and the
901/// error it produces cannot drift apart.
902pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
903
904/// Parses `4096`, `1Ki`, `10Mi`, `2Gi` into a byte count.
905///
906/// Binary suffixes only, and case-sensitively: `10MB` is rejected rather than guessed at, because
907/// a manifest that means 10 000 000 and a manifest that means 10 485 760 must not both parse.
908/// Returns the accepted-form sentence as its error so every call site reports the same rule.
909pub fn parse_byte_size(input: &str) -> Result<u64, String> {
910    let trimmed = input.trim();
911    let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
912        Some(digits) => (digits, 1024u64),
913        None => match trimmed.strip_suffix("Mi") {
914            Some(digits) => (digits, 1024 * 1024),
915            None => match trimmed.strip_suffix("Gi") {
916                Some(digits) => (digits, 1024 * 1024 * 1024),
917                None => (trimmed, 1),
918            },
919        },
920    };
921    // Deliberately not trimmed again: the whole input was trimmed above, so any space left here
922    // is *inside* the value (`10 Mi`), which is not a spelling this accepts.
923    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
924        return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
925    }
926    digits
927        .parse::<u64>()
928        .ok()
929        .and_then(|value| value.checked_mul(multiplier))
930        .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
931}
932
933#[derive(Debug, Clone, PartialEq, Eq)]
934pub struct RuntimeArtifact {
935    pub name: String,
936    pub version: String,
937    pub runtime: ArtifactRuntime,
938    /// Optional local source path. When set, the runtime resolves the artifact directly from
939    /// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
940    /// points at a `skill.md` file or at a directory containing one (case-insensitive).
941    /// Relative paths resolve against the directory containing `murmur.yaml`.
942    /// Only permitted when `local_source` is true.
943    pub source: Option<String>,
944    /// Whether this artifact may be resolved from a local `source:` path. Declared via
945    /// `local_source:` in the project manifest's artifact entry; when absent it defaults to
946    /// true for `runtime: skill` and false for every other role, which is exactly the role-based
947    /// gate this field replaced.
948    pub local_source: bool,
949    /// Whether this artifact's payload may be bound as the system prompt via
950    /// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
951    /// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
952    /// false for every other role.
953    pub prompt_payload: bool,
954    /// Per-artifact capability grant, declared via `capabilities:` on this entry in the
955    /// **capsule operator's own** manifest. Recognized on `runtime: hook`, `runtime: tool`,
956    /// and `runtime: driver` entries; a `runtime: skill` entry carrying the key is rejected
957    /// at parse time, because nothing would enforce it and a silently-ignored grant reads
958    /// like a scoped artifact.
959    ///
960    /// What `None` (the key absent) means depends on the role, deliberately:
961    /// - `hook`: full default-deny — no network and no preopened directory.
962    /// - `tool`/`driver`: the unchanged capsule-wide ceiling. A declared block *narrows*
963    ///   from that ceiling and can never widen past it.
964    ///
965    /// Deliberately never sourced from the artifact's own bundled `murmur.yaml` (see
966    /// [`parse_hook_config_from_yaml`], which parses that file and knows nothing about
967    /// capabilities) — an artifact pulled from a registry cannot self-grant.
968    ///
969    /// Reuses the whole [`Capabilities`] type for vocabulary consistency with the
970    /// capsule-wide block, but only `network` and `filesystem` are consumed by the runtime
971    /// per-artifact; the other sub-blocks are inert here.
972    pub capabilities: Option<Capabilities>,
973    /// What the runtime does when this hook's async job queue is full, declared via
974    /// `on_overflow:` on this entry. Defaults to [`HookOverflowPolicy::Drop`] when absent.
975    /// Accepted only on `runtime: hook` entries — every other role is rejected at parse
976    /// time, since nothing would ever consult it there.
977    pub on_overflow: HookOverflowPolicy,
978    /// Operator-authored configuration for this artifact alone, declared via `config:` on this
979    /// entry. Held as the raw YAML node: this crate accepts any mapping, and the runtime is what
980    /// lowers it to the JSON delivered as `MURMUR_ARTIFACT_CONFIG` and refuses a shape that
981    /// cannot travel that way.
982    ///
983    /// `None` is the key being absent, which means the variable is absent from the guest
984    /// environment. `Some(Value::Null)` is `config:` written with nothing under it — a
985    /// declaration that carries nothing, refused by the runtime rather than treated as absent.
986    ///
987    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
988    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
989    /// [`Self::capabilities`]. Operator-sourced only, and never read from the artifact's own
990    /// bundled `murmur.yaml` — an artifact pulled from a registry cannot configure itself.
991    ///
992    /// Plaintext in a file that is also an audit record: secrets belong in `${VAR}` references
993    /// and the credential-stripping path, not here.
994    pub config: Option<serde_yaml::Value>,
995    /// The third-party upstream this artifact reaches through the credential gateway, and the
996    /// key the runtime presents there on its behalf, declared via `gateway:` on this entry.
997    ///
998    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
999    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1000    /// [`Self::capabilities`]. A `runtime: driver` entry may carry it only when it is the
1001    /// configured `transport: http` driver, whose gateway is the metered inference gateway.
1002    ///
1003    /// Operator-sourced only, and never read from the artifact's own bundled `murmur.yaml`: the
1004    /// artifact says how the key is presented (`upstream_auth:`), never where it goes.
1005    pub gateway: Option<ArtifactGateway>,
1006}
1007
1008/// One artifact's credential gateway, as the operator declared it on the artifact's entry.
1009///
1010/// Debug never prints a literal key: [`ApiKeyReference`]'s own `Debug` redacts it.
1011#[derive(Debug, Clone, PartialEq, Eq)]
1012pub struct ArtifactGateway {
1013    /// Absolute `http`/`https` upstream URL with a host and no query or fragment. Plain `http`
1014    /// only to `localhost` or a loopback IP literal.
1015    pub endpoint: String,
1016    /// The key presented upstream. `None` for a keyless gateway, or when the manifest was parsed
1017    /// with [`RuntimeManifest::from_yaml_str_without_secrets`].
1018    pub api_key: Option<ApiKeyReference>,
1019    /// `keyless: true` as the operator wrote it: this upstream takes no key, and the gateway
1020    /// forwards with no credential header. Exclusive with `api_key`; kept as written in both
1021    /// secret modes. Never derived from the endpoint's address.
1022    pub keyless: bool,
1023}
1024
1025#[derive(Debug, Clone, PartialEq, Eq)]
1026pub struct InferenceDriver {
1027    pub artifact: String,
1028    pub config: Option<String>,
1029}
1030
1031/// The name of the driver choice `inference.driver.artifact` with `inference.model` forms. Reserved:
1032/// no `inference.alternates` entry may take it.
1033pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1034
1035/// One `inference.alternates` entry: a named pair of a declared driver and a model that the
1036/// agent loop may be switched to while the capsule runs.
1037///
1038/// Takes no `driver.config` of its own: `inference.driver.config` reaches every declared driver,
1039/// and per-driver settings are that driver's `artifacts[].config`.
1040#[derive(Debug, Clone, PartialEq, Eq)]
1041pub struct InferenceAlternate {
1042    /// `[a-z][a-z0-9-]{0,62}`, unique among the alternates, and never [`PRIMARY_DRIVER_CHOICE`].
1043    pub name: String,
1044    /// The model string the driver sends, never empty.
1045    pub model: String,
1046    /// `driver.artifact`: an `artifacts:` entry with `runtime: driver`, which carries its own
1047    /// `gateway:` unless it is the primary's own driver.
1048    pub driver: String,
1049}
1050
1051/// What a `gateway.api_key` names, as the manifest wrote it. Parsing never resolves it: the
1052/// runtime looks the value up at staging, where it knows where credentials are kept.
1053#[derive(Clone, PartialEq, Eq)]
1054pub enum ApiKeyReference {
1055    /// `${NAME}`: a credential name, looked up in the global config's `credentials:` map and
1056    /// then in the launching environment.
1057    Environment(String),
1058    /// Any other non-empty value, used verbatim as the key.
1059    Literal(String),
1060}
1061
1062impl std::fmt::Debug for ApiKeyReference {
1063    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1064        match self {
1065            Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1066            Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1067        }
1068    }
1069}
1070
1071#[derive(Debug, Clone, PartialEq, Eq)]
1072pub struct InferenceConfig {
1073    pub transport: String,
1074    pub model: String,
1075    /// WASM driver artifact. Always present: the inference driver under `transport: http`, the
1076    /// process driver under `transport: process`.
1077    pub driver: Option<InferenceDriver>,
1078    /// The executable to run instead of the one the process driver's `describe()` names. Absent
1079    /// for `transport: http`, and optional under `transport: process`, where the driver names its
1080    /// own binary.
1081    pub command: Option<String>,
1082    pub compaction: Option<CompactionConfig>,
1083    pub system_prompt: Option<String>,
1084    pub system_prompt_file: Option<String>,
1085    /// Name of a skill artifact declared in `artifacts:` whose `skill.md` is used as the system
1086    /// prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
1087    /// excluded from the callable tool inventory (it's already in the system prompt).
1088    pub system_prompt_artifact: Option<String>,
1089    /// Maximum LLM turns per capsule task. Defaults to 10 when absent in the manifest.
1090    /// Enforced as a hard ceiling by the runtime.
1091    pub max_turns: u32,
1092    /// Maximum output tokens the model may generate per turn (`max_tokens` in the driver wire
1093    /// payload). None means the manifest didn't set it; the runtime applies its own default.
1094    /// `transport: http` only — rejected at parse time under `transport: process`.
1095    ///
1096    /// Unrelated to [`ContextConfig::max_tokens`], which is the session-wide token budget that
1097    /// drives compaction. This one is a per-turn *output* cap.
1098    pub max_tokens: Option<u32>,
1099    /// `inference.max_session_tokens`: the most input plus output tokens this session may spend.
1100    /// `None` sets no session ceiling.
1101    ///
1102    /// Valid on both transports, measured differently on each: under `transport: http` it counts
1103    /// the runtime's own measurement of every driver call, and under `transport: process` the
1104    /// counts the harness's driver reports for each turn. Setting it against a process driver
1105    /// that reports no usage is refused at load.
1106    pub max_session_tokens: Option<u64>,
1107    /// `inference.tool_refresh`: when an artifact installed mid-session reaches the tool array
1108    /// the agent loop sends the model. Always [`ToolRefresh::Compaction`] under
1109    /// `transport: process`, where the key is refused at parse time and nothing reads it.
1110    pub tool_refresh: ToolRefresh,
1111    /// `inference.alternates`: the other driver choices the agent loop may be switched to, in
1112    /// manifest order. Empty under `transport: process`, where the key is refused at parse time,
1113    /// and for every capsule that declares none.
1114    pub alternates: Vec<InferenceAlternate>,
1115}
1116
1117/// `inference.tool_refresh`: the turn boundary at which the http agent loop rebuilds the tool
1118/// array it sends the model, once the session's installed set has changed.
1119///
1120/// The array comes first in every provider's cached prompt prefix, so rebuilding it is a full
1121/// prompt-cache miss on the call that carries it. The start of the next task rebuilds it under
1122/// either value.
1123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1124pub enum ToolRefresh {
1125    /// Rebuild before the first inference call after a compaction commits a replacement
1126    /// context, whose message history is already a full cache miss.
1127    #[default]
1128    Compaction,
1129    /// Rebuild before the next inference call after the install.
1130    Immediate,
1131}
1132
1133impl ToolRefresh {
1134    /// Every value, in the order refusals name them.
1135    pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1136
1137    /// The value as written in the manifest and in `trace.jsonl`.
1138    #[must_use]
1139    pub fn wire_name(self) -> &'static str {
1140        match self {
1141            Self::Compaction => "compaction",
1142            Self::Immediate => "immediate",
1143        }
1144    }
1145}
1146
1147#[derive(Debug, Clone, PartialEq)]
1148pub struct CompactionConfig {
1149    /// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
1150    pub threshold: Option<f32>,
1151    /// Model for compaction. None means use the primary inference model.
1152    pub model: Option<String>,
1153    /// System prompt for compaction. None means the compaction hook picks its own default.
1154    /// Mutually exclusive with `system_prompt_file`.
1155    pub system_prompt: Option<String>,
1156    /// Path to a local file whose contents are used as the compaction system prompt,
1157    /// resolved relative to the manifest directory when the session launches. Mutually
1158    /// exclusive with `system_prompt`.
1159    pub system_prompt_file: Option<String>,
1160    /// When true, every committed compaction appends one JSON line recording the
1161    /// replacement summary to `out/compaction-summaries.jsonl` in the session workdir.
1162    /// None (the key absent) is equivalent to false: nothing is written.
1163    pub dump_summaries: Option<bool>,
1164}
1165
1166// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
1167impl Eq for CompactionConfig {}
1168
1169/// `context.seed_budget` when the manifest leaves it out: a tenth of the window.
1170/// Small enough that a seed cannot crowd out the task it precedes, large enough that a
1171/// memory hook has room to say something.
1172pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1173
1174/// `context.seed_overflow_margin` when the manifest leaves it out. Slack above
1175/// `seed_budget` within which an over-budget seed is trimmed rather than summarized —
1176/// a few tokens over must not buy an inference call.
1177pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1178
1179#[derive(Debug, Clone, PartialEq)]
1180pub struct ContextConfig {
1181    /// Token budget for this session; None disables compaction.
1182    pub max_tokens: Option<u32>,
1183    /// Whether the runtime keeps a durable conversation record for this capsule. `true` unless
1184    /// the manifest says `record: off`; `false` turns the mechanism off entirely, creating
1185    /// nothing under `~/.murmur/conversations/`.
1186    pub record: bool,
1187    /// Directory segment under `~/.murmur/conversations/` this capsule's records live in.
1188    /// `None` means the capsule name. Validated as one path segment by the runtime, on the same
1189    /// terms as `capabilities.state.store`. Inert when [`Self::record`] is `false`.
1190    pub record_store: Option<String>,
1191    /// Fraction of [`Self::max_tokens`] an `on-task-start` hook's `seed-context` may
1192    /// occupy, in `0.0..=1.0`. Always populated; [`DEFAULT_SEED_BUDGET`] when the key is
1193    /// absent. Inert without `max_tokens`: a fraction of no ceiling is no ceiling, and a
1194    /// seed with no ceiling is rejected rather than committed unbounded.
1195    pub seed_budget: f32,
1196    /// Slack above the seed budget, as a fraction of it, within which an over-budget seed
1197    /// is trimmed from its front rather than handed to the compaction hook, in
1198    /// `0.0..=1.0`. Always populated; [`DEFAULT_SEED_OVERFLOW_MARGIN`] when the key is
1199    /// absent.
1200    pub seed_overflow_margin: f32,
1201    /// What bounds this capsule's conversation records. `None` — the `retain:` block was absent
1202    /// — keeps every record whole and forever. Inert when [`Self::record`] is `false`.
1203    pub retain: Option<ContextRetainConfig>,
1204}
1205
1206// Both fractions are validated to 0.0..=1.0 at parse time, so NaN is impossible.
1207impl Eq for ContextConfig {}
1208
1209#[derive(Debug, Clone, PartialEq, Eq)]
1210pub struct ObservabilityConfig {
1211    pub otel_endpoint: Option<String>,
1212    pub eval: Option<EvalConfig>,
1213}
1214
1215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1216pub struct TraceConfig {
1217    /// How much of each turn's driver request this session's trace keeps — resolved from
1218    /// `trace.capture`, or from the retired `trace.include_tool_output` alias, by
1219    /// [`crate::trace_capture::resolve_trace_capture`].
1220    pub capture: TraceCapture,
1221    /// What bounds the session directories under the workdir. `None` — the `retain:` block was
1222    /// absent — keeps every session forever; there is no default policy, because a mechanism
1223    /// that deletes an operator's traces unless they opt out is the one default-allow in a
1224    /// runtime that is default-deny everywhere else.
1225    pub retain: Option<TraceRetainConfig>,
1226}
1227
1228/// The `trace.retain` block: what bounds the set of session directories beside the running one.
1229///
1230/// Both keys are optional and ANDed — a session survives only if it is inside every limit
1231/// declared. An empty block is a parse error, not a no-op: omitting `retain:` is how a capsule
1232/// says "keep everything".
1233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1234pub struct TraceRetainConfig {
1235    /// Newest session directories to keep, counting the running session itself. `None` means
1236    /// the count is unbounded. Never `Some(0)`: truncating the set to nothing would delete the
1237    /// running session's own trace, so it is refused at parse time.
1238    pub max_sessions: Option<u32>,
1239    /// Age beyond which a session directory is removed, in seconds, measured from the
1240    /// millisecond timestamp inside its own uuid-v7 `ses_` id. `None` means no age limit.
1241    pub max_age_secs: Option<u64>,
1242}
1243
1244/// The `context.retain` block: what bounds this capsule's conversation records.
1245///
1246/// Both keys are optional and ANDed. `max_messages` truncates the front of the record the
1247/// launch opens; `max_age_secs` removes a context directory this capsule owns and has not
1248/// written to inside the window.
1249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1250pub struct ContextRetainConfig {
1251    /// Messages to keep at the tail of a record. `None` means the record grows without bound.
1252    /// Never `Some(0)`: truncating a record to nothing is `mur conversation rm`, not retention.
1253    pub max_messages: Option<u32>,
1254    /// Age beyond which a record this capsule owns is removed, in seconds, measured from the
1255    /// last write to its `conversation.jsonl`. `None` means no age limit.
1256    pub max_age_secs: Option<u64>,
1257}
1258
1259#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1260pub struct EvalConfig {
1261    #[serde(skip_serializing_if = "Option::is_none")]
1262    pub dataset_id: Option<String>,
1263    pub scorers: Vec<ScorerConfig>,
1264}
1265
1266#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1267#[serde(tag = "type", rename_all = "snake_case")]
1268pub enum ScorerConfig {
1269    ExitOk { name: String },
1270    MaxTurns { name: String, max: u32 },
1271    MaxTokens { name: String, max: u64 },
1272    ToolSequence { name: String, expected: Vec<String> },
1273    LlmJudge { name: String },
1274}
1275
1276#[derive(Debug, Clone, PartialEq, Eq)]
1277pub struct NetworkCapabilities {
1278    pub allow: Vec<String>,
1279    /// Whether the capsule's shell subprocess tree may create `AF_UNIX` sockets. `false` unless
1280    /// the manifest says otherwise: a local daemon socket — `/var/run/docker.sock` above all —
1281    /// is an unmediated path to host root, and the `allow` list above only governs IP
1282    /// destinations, so nothing else in this block constrains it. Declaring `true` is the
1283    /// deliberate, auditable widening for a capsule that genuinely needs a local daemon socket;
1284    /// it is capsule-wide and coarse (a domain, not a per-path allowlist). `AF_NETLINK` and
1285    /// `AF_PACKET` have no corresponding key and are always denied.
1286    pub unix_sockets: bool,
1287}
1288
1289#[derive(Debug, Clone, PartialEq, Eq)]
1290pub struct FilesystemCapabilities {
1291    pub scope: Option<String>,
1292    /// Whether anything written *into* the session workdir may be executed. `false` unless the
1293    /// manifest says otherwise, and that default is the whole point: with it, the workdir's own
1294    /// Landlock `PathBeneath` rule withholds the `Execute` right, so the kernel — evaluating the
1295    /// resolved path itself, with no userspace round trip — refuses to exec a binary the capsule
1296    /// produced, whatever it is named. That is what makes `capabilities.shell.allow` a complete
1297    /// and sound statement rather than a name-matching convention.
1298    ///
1299    /// `true` takes the `Execute` right back for compile-and-run workflows (a capsule that
1300    /// `gcc`/`cargo build`s inside its workdir and then runs the result). The cost is not
1301    /// negotiable and is not hidden: anything the capsule writes into the workdir can then run
1302    /// regardless of `shell.allow`, so the allowlist stops being an enforceable property of that
1303    /// capsule. A capsule declaring it can therefore never achieve
1304    /// [`ContainmentClass::Scoped`] — see `capsule_runtime::containment` — and pairing it with
1305    /// `capabilities.containment: scoped` is refused at launch.
1306    pub workdir_exec: bool,
1307    /// Workdir-relative subtrees the capsule may read but must not write, in the same vocabulary
1308    /// [`Self::scope`] uses. Empty when the key is absent, which is the whole workdir writable —
1309    /// the behaviour of every capsule that declares nothing here.
1310    ///
1311    /// Entries are trimmed and empty ones dropped at parse; path *shape* is judged by the
1312    /// runtime (`capsule_runtime::protected_paths`), exactly as `scope`'s shape is.
1313    pub read_only: Vec<String>,
1314}
1315
1316/// The `capabilities.task_io` block on a `runtime: hook` artifact entry — the operator's
1317/// grant of the `murmur:task-io/read` host import to that one hook. Recognized nowhere else:
1318/// on any other role, and in the capsule-wide block, it is rejected at parse time rather than
1319/// accepted and silently inert.
1320#[derive(Debug, Clone, PartialEq, Eq)]
1321pub struct TaskIoCapabilities {
1322    /// Whether this hook may read the in-scope task's input and result text. Never inferred:
1323    /// a `task_io:` block that omits `read:` is rejected, on the same terms as
1324    /// `capabilities.shell.interpreter_runtime[].dirs[].list_dir`.
1325    pub read: bool,
1326}
1327
1328/// The `capabilities.conversation` block on a `runtime: hook` artifact entry — the operator's
1329/// grant of the `murmur:conversation/read` host import to that one hook. Rejected at parse time
1330/// on any other artifact role; accepted but inert in the capsule-wide block, which warns
1331/// `W-SEC-016` at staging.
1332#[derive(Debug, Clone, PartialEq, Eq)]
1333pub struct ConversationCapabilities {
1334    /// Whether this hook may read the capsule's durable conversation record. Never inferred: a
1335    /// `conversation:` block that omits `read:` is rejected, exactly as `task_io:` is.
1336    pub read: bool,
1337}
1338
1339/// The `capabilities.plan` block — the operator's grant of the runtime-provided `submit-plan`
1340/// tool to the capsule's agent loop. Read from the capsule-wide block only: an artifact entry
1341/// declaring it grants nothing, because a plan is submitted by the agent and not by an artifact.
1342#[derive(Debug, Clone, PartialEq, Eq)]
1343pub struct PlanCapabilities {
1344    /// Whether this capsule's model may submit a plan for the runtime to schedule. Never
1345    /// inferred: a `plan:` block that omits `submit:` is rejected, exactly as `task_io:` is.
1346    pub submit: bool,
1347}
1348
1349/// The `capabilities.install` block — the operator's grant to pull artifacts into a running
1350/// session through `murmur:artifact-manager/manage.pull`. Read from the capsule-wide block only.
1351/// Each entry is an exact artifact name, a `<prefix>*` or `*`, as
1352/// [`crate::install_pattern::validate`] accepts it, kept verbatim in declaration order. At least
1353/// one list is non-empty: omitting the block, not declaring an empty one, is how a capsule grants
1354/// nothing.
1355#[derive(Debug, Clone, PartialEq, Eq)]
1356pub struct InstallCapabilities {
1357    /// Entries admitting skill artifacts (`skill.md`, no executable payload).
1358    pub skill: Vec<String>,
1359    /// Entries admitting tool artifacts, WASM or native alike.
1360    pub tool: Vec<String>,
1361}
1362
1363#[derive(Debug, Clone, PartialEq, Eq)]
1364pub struct ShellCapabilities {
1365    pub allow: Vec<String>,
1366    pub strip_env: Option<Vec<String>>,
1367    pub baseline_env: Option<Vec<String>>,
1368    /// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
1369    /// scope to the exact host directories its import machinery needs *outside* the workdir
1370    /// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
1371    /// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
1372    /// This can only ever name specific directories with an explicit per-directory
1373    /// enumerability flag — it has no field that expands a whole install prefix.
1374    pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1375    /// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
1376    /// into a `sealed` capsule's composed root. Empty unless the manifest declares
1377    /// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
1378    /// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
1379    /// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
1380    pub staged_runtime: Vec<StagedRuntimeGrant>,
1381}
1382
1383/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
1384/// exact host directories outside the workdir its import machinery must reach. Declaring one
1385/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
1386/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
1387#[derive(Debug, Clone, PartialEq, Eq)]
1388pub struct InterpreterRuntimeGrant {
1389    /// A binary that MUST already appear in this same block's `allow`. This mechanism can only
1390    /// narrow filesystem access alongside an exec grant that already exists — it never itself
1391    /// grants exec.
1392    pub binary: String,
1393    /// The host directories to grant. Never empty (a grant with no directories is rejected at
1394    /// parse time).
1395    pub dirs: Vec<InterpreterRuntimeDir>,
1396}
1397
1398/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
1399#[derive(Debug, Clone, PartialEq, Eq)]
1400pub struct InterpreterRuntimeDir {
1401    /// An absolute host path (must start with `/`) outside the workdir.
1402    pub path: String,
1403    /// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
1404    /// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
1405    /// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
1406    /// directory itself cannot be listed. Never inferred: the author must write it explicitly.
1407    pub list_dir: bool,
1408}
1409
1410/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
1411/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
1412/// identifying which build that tree is.
1413///
1414/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
1415/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
1416/// happens to keep it — which couples the capsule to one host's directory layout and is why it
1417/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
1418/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
1419/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
1420/// is no composed root to stage into below that — and it is refused alongside an
1421/// `interpreter_runtime` grant for the same binary.
1422///
1423/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
1424/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
1425/// does not resolve, discover or version-sniff it.
1426#[derive(Debug, Clone, PartialEq, Eq)]
1427pub struct StagedRuntimeGrant {
1428    /// A binary that MUST already appear in this same block's `allow`. Like
1429    /// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
1430    /// where the runtime behind an existing exec grant comes from.
1431    pub binary: String,
1432    /// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
1433    /// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
1434    /// turn recorded keep resolving.
1435    pub source_path: String,
1436    /// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
1437    /// inferred from the tree's contents: it exists so a human can compare the declared pin across
1438    /// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
1439    /// an opaque string and does not parse it.
1440    pub pin: String,
1441}
1442
1443#[derive(Debug, Clone, PartialEq, Eq)]
1444pub struct SpawnCapabilities {
1445    pub allow: Vec<String>,
1446}
1447
1448/// Host environment variables a WASM guest may observe, from `capabilities.env`.
1449///
1450/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
1451/// `capabilities.shell.allow` this is not rejected at parse time.
1452#[derive(Debug, Clone, PartialEq, Eq, Default)]
1453pub struct EnvCapabilities {
1454    pub allow: Vec<String>,
1455}
1456
1457/// Per-guest execution limits from `capabilities.limits`.
1458///
1459/// Every field is `Option` because this type carries only what the manifest actually
1460/// declared — an omitted field stays `None` here and the runtime substitutes its own
1461/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
1462/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
1463/// instead of duplicating them into the manifest parser.
1464#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1465pub struct ResourceLimits {
1466    /// Cap on a guest's linear-memory growth, in bytes.
1467    pub memory_bytes: Option<usize>,
1468    /// Cap on a guest's table growth, in elements.
1469    pub table_elements: Option<usize>,
1470    /// Cap on the number of instances a single store may create.
1471    pub instances: Option<usize>,
1472    /// Wall-clock budget for a single guest invocation, in seconds.
1473    pub deadline_seconds: Option<u64>,
1474}
1475
1476/// Host-process (OS-level) resource bounds from `capabilities.resources`.
1477///
1478/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
1479/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every *native
1480/// subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
1481/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
1482/// that cannot escape containment can still wedge its host by forking, allocating, or writing
1483/// without bound; this is the block that stops that.
1484///
1485/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
1486/// what the manifest actually declared, and the runtime substitutes its own default for
1487/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
1488/// or an omitted block — means defaults, never "unlimited".
1489#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1490pub struct ResourceCapabilities {
1491    /// `RLIMIT_NPROC` hard ceiling on each spawned subprocess.
1492    pub max_processes: Option<u64>,
1493    /// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
1494    pub max_open_files: Option<u64>,
1495    /// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
1496    pub max_file_size_bytes: Option<u64>,
1497    /// `RLIMIT_CPU` hard ceiling, in CPU-seconds, on each spawned subprocess.
1498    pub cpu_seconds: Option<u64>,
1499    /// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
1500    /// subprocess.
1501    pub memory_bytes: Option<u64>,
1502    /// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
1503    pub cgroup_memory_bytes: Option<u64>,
1504    /// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
1505    pub cgroup_pids_max: Option<u64>,
1506    /// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
1507    pub cgroup_cpu_percent: Option<u32>,
1508    /// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
1509    /// best-effort (the backing device cannot always be resolved).
1510    pub cgroup_io_bytes_per_sec: Option<u64>,
1511    /// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
1512    pub workdir_max_bytes: Option<u64>,
1513}
1514
1515/// Durable, capsule-scoped state for one tool, driver or hook: a host directory outside every
1516/// session workdir, preopened into the guest as `state/`.
1517///
1518/// Absent is deny — no declaration means no second preopen and no directory anywhere. The store
1519/// is keyed by capsule rather than by workdir precisely so it survives a launch that gets a fresh
1520/// session workdir, and so two capsules launched in the same directory never share one.
1521#[derive(Debug, Clone, PartialEq, Eq, Default)]
1522pub struct StateCapabilities {
1523    /// Directory name under `~/.murmur/state/`. `None` means "use the capsule name", which is
1524    /// what the overwhelming majority of declarations want.
1525    ///
1526    /// Read from the capsule operator's own manifest entry and never from the artifact's bundled
1527    /// manifest, so a registry-pulled tool cannot claim a store that already exists. The *shape*
1528    /// of the name is not checked here — a store name is a runtime concern, refused as
1529    /// `E-CAP-009`, exactly as `capabilities.filesystem.scope`'s shape is refused as `E-CAP-002`.
1530    pub store: Option<String>,
1531}
1532
1533// ── Containment class ─────────────────────────────────────────────────────────
1534
1535/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
1536/// by the operator and satisfied (or not) by the host's kernel.
1537///
1538/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
1539/// must stay listed weakest-first. Combining floors from several sources is `max`, never
1540/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
1541///
1542/// The class an operator *declares* is a requirement. The class a host actually *achieves*
1543/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
1544/// this declaration.
1545#[derive(
1546    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1547)]
1548#[serde(rename_all = "snake_case")]
1549pub enum ContainmentClass {
1550    /// No kernel mechanism required. Capability declarations are honored by the runtime's
1551    /// own dispatch layer and by convention; anything the host cannot mediate is warned
1552    /// about, not refused. Every host satisfies this, including macOS. The default.
1553    #[default]
1554    Advisory,
1555    /// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
1556    /// filesystem: every path outside the workdir must be an explicit manifest grant.
1557    /// Requires Linux 5.13+ with a usable Landlock ABI.
1558    Scoped,
1559    /// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
1560    /// root, so the host filesystem is not merely mediated but absent. Achievable on an
1561    /// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
1562    /// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
1563    /// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
1564    /// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
1565    /// See `docs/content/reference/sealed-containment-manual-verification.md`.
1566    Sealed,
1567}
1568
1569impl ContainmentClass {
1570    /// The lowercase wire name, identical in the manifest, the workspace config, the
1571    /// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
1572    pub fn as_str(self) -> &'static str {
1573        match self {
1574            Self::Advisory => "advisory",
1575            Self::Scoped => "scoped",
1576            Self::Sealed => "sealed",
1577        }
1578    }
1579
1580    /// Every value, weakest first — the single source for the "must be one of: …" text.
1581    pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1582}
1583
1584impl std::fmt::Display for ContainmentClass {
1585    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1586        f.write_str(self.as_str())
1587    }
1588}
1589
1590/// A string that is not one of the three containment class names.
1591#[derive(Debug, Clone, PartialEq, Eq)]
1592pub struct ParseContainmentClassError {
1593    pub value: String,
1594}
1595
1596impl std::fmt::Display for ParseContainmentClassError {
1597    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1598        write!(
1599            f,
1600            "must be one of: advisory, scoped, sealed; got '{}'",
1601            self.value
1602        )
1603    }
1604}
1605
1606impl std::error::Error for ParseContainmentClassError {}
1607
1608impl std::str::FromStr for ContainmentClass {
1609    type Err = ParseContainmentClassError;
1610
1611    fn from_str(value: &str) -> Result<Self, Self::Err> {
1612        match value.trim() {
1613            "advisory" => Ok(Self::Advisory),
1614            "scoped" => Ok(Self::Scoped),
1615            "sealed" => Ok(Self::Sealed),
1616            other => Err(ParseContainmentClassError {
1617                value: other.to_string(),
1618            }),
1619        }
1620    }
1621}
1622
1623/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
1624/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
1625///
1626/// Monotonic by construction — an absent source does not participate, and no present source
1627/// can lower what another raised. Argument order carries no precedence.
1628pub fn effective_containment_floor(
1629    workspace_default: Option<ContainmentClass>,
1630    manifest_declared: Option<ContainmentClass>,
1631    cli_flag: Option<ContainmentClass>,
1632) -> ContainmentClass {
1633    [workspace_default, manifest_declared, cli_flag]
1634        .into_iter()
1635        .flatten()
1636        .max()
1637        .unwrap_or_default()
1638}
1639
1640#[derive(Debug, Clone, PartialEq, Eq)]
1641pub struct Capabilities {
1642    pub network: Option<NetworkCapabilities>,
1643    /// Which peers this capsule may redeem a peer-file handle against. `None` means no
1644    /// `fetch-peer-file` tool exists and nothing can be fetched — absent is deny.
1645    pub peer_fetch: Option<PeerFetchCapabilities>,
1646    pub filesystem: Option<FilesystemCapabilities>,
1647    pub shell: Option<ShellCapabilities>,
1648    pub spawn: Option<SpawnCapabilities>,
1649    pub env: Option<EnvCapabilities>,
1650    pub limits: Option<ResourceLimits>,
1651    pub resources: Option<ResourceCapabilities>,
1652    /// Durable state store for this artifact. `None` — the overwhelmingly common case — is deny:
1653    /// no `state/` preopen, and no directory created anywhere. See [`StateCapabilities`].
1654    pub state: Option<StateCapabilities>,
1655    /// Per-hook grant of the `murmur:task-io/read` host import. Only ever `Some` on a
1656    /// `runtime: hook` artifact entry — see [`TaskIoCapabilities`].
1657    pub task_io: Option<TaskIoCapabilities>,
1658    /// Per-hook grant of the `murmur:conversation/read` host import — see
1659    /// [`ConversationCapabilities`].
1660    pub conversation: Option<ConversationCapabilities>,
1661    /// Capsule-wide grant of the runtime-provided `submit-plan` tool. `None` is deny: the tool's
1662    /// synthetic manifest is not written, so it is absent from the model's inventory entirely.
1663    pub plan: Option<PlanCapabilities>,
1664    /// Capsule-wide grant to pull skills and tools at runtime. `None` is deny: every
1665    /// `manage.pull` is refused. See [`InstallCapabilities`].
1666    pub install: Option<InstallCapabilities>,
1667    /// Minimum containment class this capsule declares. `None` (the overwhelmingly common
1668    /// case) means the capsule states no requirement and inherits whatever the workspace
1669    /// config or `--containment` asks for, defaulting to `advisory`.
1670    pub containment: Option<ContainmentClass>,
1671}
1672
1673#[derive(Debug, Error)]
1674pub enum RuntimeManifestError {
1675    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1676    NotFound(String),
1677    #[error("{0}")]
1678    YamlSyntax(String),
1679    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1680    MissingField { field: String },
1681    #[error(
1682        "{}: invalid artifact declaration at index {index}: {message}",
1683        MANIFEST_FILENAME
1684    )]
1685    InvalidArtifact { index: usize, message: String },
1686    #[error(
1687        "{}: invalid inference config for '{field}': {message}",
1688        MANIFEST_FILENAME
1689    )]
1690    InvalidInferenceConfig { field: String, message: String },
1691    #[error(
1692        "{}: invalid capability config for '{field}': {message}",
1693        MANIFEST_FILENAME
1694    )]
1695    InvalidCapabilities { field: String, message: String },
1696    #[error(
1697        "{}: invalid exports config for '{field}': {message}",
1698        MANIFEST_FILENAME
1699    )]
1700    InvalidExports { field: String, message: String },
1701    #[error(
1702        "{}: invalid control config for '{field}': {message}",
1703        MANIFEST_FILENAME
1704    )]
1705    InvalidControl { field: String, message: String },
1706    #[error(
1707        "{}: invalid network config for '{field}': {message}",
1708        MANIFEST_FILENAME
1709    )]
1710    InvalidNetworkConfig { field: String, message: String },
1711    #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
1712    InvalidTraceConfig { field: String, message: String },
1713    /// A `gateway:` names an upstream but neither binds a non-blank `api_key` nor declares
1714    /// `keyless: true`. Decided on what the manifest wrote, so it fires identically with and
1715    /// without secret resolution.
1716    #[error(
1717        "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
1718         gateway.endpoint '{endpoint}' but binds no credential",
1719        MANIFEST_FILENAME
1720    )]
1721    GatewayWithoutCredential {
1722        index: usize,
1723        name: String,
1724        endpoint: String,
1725    },
1726    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
1727    Io {
1728        path: String,
1729        #[source]
1730        source: std::io::Error,
1731    },
1732}
1733
1734#[derive(Debug, Deserialize)]
1735struct RawRuntimeManifest {
1736    name: Option<String>,
1737    version: Option<String>,
1738    #[serde(default)]
1739    artifacts: Vec<RawArtifact>,
1740    #[serde(default)]
1741    capabilities: Option<RawCapabilities>,
1742    #[serde(default)]
1743    inference: Option<RawInferenceConfig>,
1744    #[serde(default)]
1745    context: Option<RawContextConfig>,
1746    #[serde(default)]
1747    observability: Option<RawObservabilityConfig>,
1748    #[serde(default)]
1749    trace: Option<RawTraceConfig>,
1750    #[serde(default)]
1751    network: Option<RawNetworkConfig>,
1752    #[serde(default)]
1753    lifecycle: Option<RawLifecycleConfig>,
1754    #[serde(default)]
1755    exports: Option<RawExports>,
1756    #[serde(default)]
1757    control: Option<RawControl>,
1758    #[serde(default)]
1759    mur_version: Option<String>,
1760    /// Captured only to refuse it. `config:` is delivered to one artifact through that artifact's
1761    /// own grant, so a capsule-wide block reaches nothing; without this field it would be one of
1762    /// the unrecognized top-level keys this manifest silently ignores.
1763    #[serde(default, deserialize_with = "deserialize_present")]
1764    config: Option<serde_yaml::Value>,
1765    /// Read by `mur build` and `mur publish` off this same file to select the packaging type.
1766    /// Declared here so a manifest that is both runnable and publishable does not report its own
1767    /// build-side keys as unrecognized; the run path takes no meaning from it and never inspects
1768    /// the value, so any shape parses.
1769    #[serde(default)]
1770    #[allow(dead_code)]
1771    runtime: Option<serde_yaml::Value>,
1772    /// Read by `mur build` and `mur publish`: it separates a native tool from a wasm one, both in
1773    /// the derived packaging type and in `mur publish`'s platform tagging. Ignored on the run
1774    /// path, and untyped for the same reason as `runtime:` above.
1775    #[serde(default)]
1776    #[allow(dead_code)]
1777    implementation: Option<serde_yaml::Value>,
1778    /// Read by `mur build` and `mur publish` as the declared packaging type, which overrides the
1779    /// one derived from `runtime:` and `implementation:`. Ignored on the run path, and untyped for
1780    /// the same reason as `runtime:` above.
1781    #[serde(default)]
1782    #[allow(dead_code)]
1783    execution: Option<serde_yaml::Value>,
1784    /// Read by `mur build` as the list of companion files to pack beside the manifest. Ignored on
1785    /// the run path, and untyped for the same reason as `runtime:` above.
1786    #[serde(default)]
1787    #[allow(dead_code)]
1788    requires_files: Option<serde_yaml::Value>,
1789    #[serde(flatten)]
1790    unknown: UnknownKeys,
1791}
1792
1793#[derive(Debug, Deserialize)]
1794struct RawControl {
1795    /// Untyped, so a value that is not a list of strings is refused as an
1796    /// [`RuntimeManifestError::InvalidControl`] naming the field rather than as a serde type error
1797    /// naming a line number.
1798    #[serde(default)]
1799    settings: Option<serde_yaml::Value>,
1800    #[serde(default)]
1801    secrets: Option<serde_yaml::Value>,
1802    #[serde(default)]
1803    agent_settings: Option<serde_yaml::Value>,
1804    #[serde(flatten)]
1805    unknown: UnknownKeys,
1806}
1807
1808#[derive(Debug, Deserialize)]
1809struct RawExports {
1810    #[serde(default)]
1811    files: Option<RawFileExport>,
1812    #[serde(default)]
1813    peer_files: Option<RawPeerFilesExport>,
1814    #[serde(flatten)]
1815    unknown: UnknownKeys,
1816}
1817
1818#[derive(Debug, Deserialize)]
1819struct RawPeerFilesExport {
1820    #[serde(default)]
1821    root: Option<String>,
1822    /// Untyped for the same reason as [`RawFileExport::max_bytes`]: `30m` is a YAML string and a
1823    /// bare `1800` is a YAML integer, and a wrong-shaped value has to reach the parser to be
1824    /// reported as an [`RuntimeManifestError::InvalidExports`] naming the field.
1825    #[serde(default)]
1826    max_ttl: Option<serde_yaml::Value>,
1827    #[serde(default)]
1828    max_bytes: Option<serde_yaml::Value>,
1829    #[serde(flatten)]
1830    unknown: UnknownKeys,
1831}
1832
1833#[derive(Debug, Deserialize)]
1834struct RawFileExport {
1835    #[serde(default)]
1836    root: Option<String>,
1837    #[serde(default)]
1838    mode: Option<String>,
1839    /// Deliberately untyped: `max_bytes` accepts both a bare integer and a suffixed string, and
1840    /// a wrong-shaped value has to reach [`parse_exports`] to be reported as an
1841    /// [`RuntimeManifestError::InvalidExports`] naming the field rather than as a serde type
1842    /// error naming a line number.
1843    #[serde(default)]
1844    max_bytes: Option<serde_yaml::Value>,
1845    #[serde(flatten)]
1846    unknown: UnknownKeys,
1847}
1848
1849#[derive(Debug, Deserialize)]
1850struct RawLifecycleConfig {
1851    #[serde(default)]
1852    task_acceptance: Option<TaskAcceptance>,
1853    #[serde(default)]
1854    after_task: Option<AfterTask>,
1855    #[serde(default)]
1856    queue_depth: Option<usize>,
1857    #[serde(default)]
1858    input_timeout_secs: Option<u64>,
1859    #[serde(default)]
1860    conversation: Option<ConversationMode>,
1861    #[serde(default)]
1862    max_task_reopens: Option<u32>,
1863    #[serde(default)]
1864    shell_grace_secs: Option<u64>,
1865    #[serde(default)]
1866    delegation_deadline_secs: Option<u64>,
1867    #[serde(flatten)]
1868    unknown: UnknownKeys,
1869}
1870
1871#[derive(Debug, Deserialize)]
1872struct RawNetworkConfig {
1873    internal_port: Option<u16>,
1874    #[serde(default)]
1875    authentication: Option<RawNetworkAuthentication>,
1876    #[serde(flatten)]
1877    unknown: UnknownKeys,
1878}
1879
1880#[derive(Debug, Deserialize)]
1881struct RawNetworkAuthentication {
1882    #[serde(default)]
1883    scheme: Option<String>,
1884    /// Keyed by credential name. A `BTreeMap`, so the lowered credentials come out sorted by name
1885    /// whatever order the manifest wrote them in.
1886    #[serde(default)]
1887    credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
1888    #[serde(flatten)]
1889    unknown: UnknownKeys,
1890}
1891
1892#[derive(Debug, Deserialize)]
1893struct RawDoorCredential {
1894    /// Untyped, so a value that is not a list of strings is refused as an
1895    /// [`RuntimeManifestError::InvalidNetworkConfig`] naming the field rather than as a serde type
1896    /// error naming a line number.
1897    #[serde(default)]
1898    scopes: Option<serde_yaml::Value>,
1899    #[serde(flatten)]
1900    unknown: UnknownKeys,
1901}
1902
1903#[derive(Debug, Deserialize)]
1904struct RawContextConfig {
1905    max_tokens: Option<u32>,
1906    /// `on` | `off`. Kept as a raw `String` so an unrecognized value reports as an invalid
1907    /// value for this key rather than as a serde variant error against the whole block.
1908    #[serde(default)]
1909    record: Option<String>,
1910    #[serde(default)]
1911    record_store: Option<String>,
1912    #[serde(default)]
1913    seed_budget: Option<f32>,
1914    #[serde(default)]
1915    seed_overflow_margin: Option<f32>,
1916    /// Untyped so an empty block, a null block and an unknown key inside it are each reported
1917    /// as an invalid `context.retain` rather than as a serde message about a struct field.
1918    #[serde(default, deserialize_with = "present_yaml_value")]
1919    retain: Option<serde_yaml::Value>,
1920    #[serde(flatten)]
1921    unknown: UnknownKeys,
1922}
1923
1924#[derive(Debug, Deserialize)]
1925struct RawObservabilityConfig {
1926    #[serde(default)]
1927    otel_endpoint: Option<String>,
1928    #[serde(default)]
1929    eval: Option<RawEvalConfig>,
1930    #[serde(flatten)]
1931    unknown: UnknownKeys,
1932}
1933
1934#[derive(Debug, Deserialize)]
1935struct RawTraceConfig {
1936    /// Untyped so an unparseable mode reaches [`resolve_trace_capture`] and is reported as a
1937    /// [`RuntimeManifestError::InvalidTraceConfig`] naming the field and the accepted values,
1938    /// rather than as a serde message about an unknown variant.
1939    #[serde(default)]
1940    capture: Option<String>,
1941    /// The retired alias. `None` means the key was absent, which is what separates a manifest
1942    /// that opted out from one that never mentioned it — see [`resolve_trace_capture`].
1943    #[serde(default)]
1944    include_tool_output: Option<bool>,
1945    /// Untyped for the same reason as `context.retain` — see [`RawContextConfig::retain`].
1946    #[serde(default, deserialize_with = "present_yaml_value")]
1947    retain: Option<serde_yaml::Value>,
1948    #[serde(flatten)]
1949    unknown: UnknownKeys,
1950}
1951
1952#[derive(Debug, Deserialize)]
1953struct RawEvalConfig {
1954    #[serde(default)]
1955    dataset_id: Option<String>,
1956    #[serde(default)]
1957    scorers: Vec<RawScorerConfig>,
1958    #[serde(flatten)]
1959    unknown: UnknownKeys,
1960}
1961
1962#[derive(Debug, Deserialize)]
1963struct RawScorerConfig {
1964    #[serde(rename = "type")]
1965    scorer_type: String,
1966    name: Option<String>,
1967    max: Option<u64>,
1968    expected: Option<Vec<String>>,
1969    #[serde(flatten)]
1970    unknown: UnknownKeys,
1971}
1972
1973#[derive(Debug, Deserialize)]
1974struct RawArtifact {
1975    name: Option<String>,
1976    version: Option<String>,
1977    runtime: Option<String>,
1978    #[serde(default)]
1979    source: Option<String>,
1980    #[serde(default)]
1981    local_source: Option<bool>,
1982    #[serde(default)]
1983    prompt_payload: Option<bool>,
1984    #[serde(default)]
1985    capabilities: Option<RawCapabilities>,
1986    #[serde(default)]
1987    on_overflow: Option<String>,
1988    /// Untyped, and deserialized through [`deserialize_present`] rather than plain `Option`, so
1989    /// `config:` written with nothing under it arrives as `Some(Value::Null)` instead of
1990    /// collapsing into the absent case. The two mean different things: absent grants no variable,
1991    /// while an empty declaration is a written statement that carries nothing and is refused.
1992    #[serde(default, deserialize_with = "deserialize_present")]
1993    config: Option<serde_yaml::Value>,
1994    /// `Some(None)` is `gateway:` written with nothing under it, refused as a block with no
1995    /// endpoint rather than collapsed into the absent case.
1996    #[serde(default, deserialize_with = "deserialize_present")]
1997    gateway: Option<Option<RawArtifactGateway>>,
1998    #[serde(flatten)]
1999    unknown: UnknownKeys,
2000}
2001
2002#[derive(Debug, Deserialize)]
2003struct RawArtifactGateway {
2004    #[serde(default)]
2005    endpoint: Option<String>,
2006    /// `Some(None)` is `api_key:` written as YAML null: present for the contradiction with
2007    /// `keyless: true`, and no credential for the missing-credential refusal.
2008    #[serde(default, deserialize_with = "deserialize_present")]
2009    api_key: Option<Option<String>>,
2010    /// Untyped, so a non-boolean is refused as an invalid declaration naming the field.
2011    #[serde(default)]
2012    keyless: Option<serde_yaml::Value>,
2013    #[serde(flatten)]
2014    unknown: UnknownKeys,
2015}
2016
2017/// Deserialize a present key into `Some(value)` even when its value is YAML null, leaving `None`
2018/// to mean the key was absent and `#[serde(default)]` supplied it.
2019///
2020/// serde's own `Option` impl maps a null value to `None`, which erases exactly the distinction
2021/// `config:` rests on.
2022fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2023where
2024    T: Deserialize<'de>,
2025    D: serde::Deserializer<'de>,
2026{
2027    T::deserialize(deserializer).map(Some)
2028}
2029
2030#[derive(Debug, Deserialize)]
2031struct RawCapabilities {
2032    #[serde(default)]
2033    network: Option<RawNetworkCapabilities>,
2034    #[serde(default)]
2035    peer_fetch: Option<RawPeerFetchCapabilities>,
2036    #[serde(default)]
2037    filesystem: Option<RawFilesystemCapabilities>,
2038    #[serde(default)]
2039    shell: Option<RawShellCapabilities>,
2040    #[serde(default)]
2041    spawn: Option<RawSpawnCapabilities>,
2042    #[serde(default)]
2043    env: Option<RawEnvCapabilities>,
2044    #[serde(default)]
2045    limits: Option<RawResourceLimits>,
2046    #[serde(default)]
2047    resources: Option<RawResourceCapabilities>,
2048    #[serde(default)]
2049    state: Option<RawStateCapabilities>,
2050    #[serde(default)]
2051    task_io: Option<RawTaskIoCapabilities>,
2052    #[serde(default)]
2053    conversation: Option<RawConversationCapabilities>,
2054    #[serde(default)]
2055    plan: Option<RawPlanCapabilities>,
2056    #[serde(default)]
2057    install: Option<RawInstallCapabilities>,
2058    /// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
2059    /// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
2060    /// "unknown variant" error attributed to the whole `capabilities:` block.
2061    #[serde(default)]
2062    containment: Option<String>,
2063    #[serde(flatten)]
2064    unknown: UnknownKeys,
2065}
2066
2067#[derive(Debug, Deserialize)]
2068struct RawTaskIoCapabilities {
2069    // `Option` so an omitted `read` is distinguishable from an explicit `false`: the omission
2070    // is rejected outright rather than defaulted, because a capability is never inferred.
2071    #[serde(default)]
2072    read: Option<bool>,
2073    #[serde(flatten)]
2074    unknown: UnknownKeys,
2075}
2076
2077#[derive(Debug, Deserialize)]
2078struct RawConversationCapabilities {
2079    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2080    // than defaulted.
2081    #[serde(default)]
2082    read: Option<bool>,
2083    #[serde(flatten)]
2084    unknown: UnknownKeys,
2085}
2086
2087#[derive(Debug, Deserialize)]
2088struct RawPlanCapabilities {
2089    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2090    // than defaulted.
2091    #[serde(default)]
2092    submit: Option<bool>,
2093    #[serde(flatten)]
2094    unknown: UnknownKeys,
2095}
2096
2097#[derive(Debug, Deserialize)]
2098struct RawInstallCapabilities {
2099    #[serde(default)]
2100    skill: Vec<String>,
2101    #[serde(default)]
2102    tool: Vec<String>,
2103    #[serde(flatten)]
2104    unknown: UnknownKeys,
2105}
2106
2107#[derive(Debug, Deserialize)]
2108struct RawSpawnCapabilities {
2109    #[serde(default)]
2110    allow: Vec<String>,
2111    #[serde(flatten)]
2112    unknown: UnknownKeys,
2113}
2114
2115#[derive(Debug, Deserialize)]
2116struct RawEnvCapabilities {
2117    #[serde(default)]
2118    allow: Vec<String>,
2119    #[serde(flatten)]
2120    unknown: UnknownKeys,
2121}
2122
2123#[derive(Debug, Deserialize)]
2124struct RawResourceLimits {
2125    #[serde(default)]
2126    memory_bytes: Option<usize>,
2127    #[serde(default)]
2128    table_elements: Option<usize>,
2129    #[serde(default)]
2130    instances: Option<usize>,
2131    #[serde(default)]
2132    deadline_seconds: Option<u64>,
2133    #[serde(flatten)]
2134    unknown: UnknownKeys,
2135}
2136
2137#[derive(Debug, Deserialize)]
2138struct RawStateCapabilities {
2139    #[serde(default)]
2140    store: Option<String>,
2141    #[serde(flatten)]
2142    unknown: UnknownKeys,
2143}
2144
2145#[derive(Debug, Deserialize)]
2146struct RawResourceCapabilities {
2147    #[serde(default)]
2148    max_processes: Option<u64>,
2149    #[serde(default)]
2150    max_open_files: Option<u64>,
2151    #[serde(default)]
2152    max_file_size_bytes: Option<u64>,
2153    #[serde(default)]
2154    cpu_seconds: Option<u64>,
2155    #[serde(default)]
2156    memory_bytes: Option<u64>,
2157    #[serde(default)]
2158    cgroup_memory_bytes: Option<u64>,
2159    #[serde(default)]
2160    cgroup_pids_max: Option<u64>,
2161    #[serde(default)]
2162    cgroup_cpu_percent: Option<u32>,
2163    #[serde(default)]
2164    cgroup_io_bytes_per_sec: Option<u64>,
2165    #[serde(default)]
2166    workdir_max_bytes: Option<u64>,
2167    #[serde(flatten)]
2168    unknown: UnknownKeys,
2169}
2170
2171#[derive(Debug, Deserialize)]
2172struct RawNetworkCapabilities {
2173    #[serde(default)]
2174    allow: Vec<String>,
2175    #[serde(default)]
2176    unix_sockets: bool,
2177    #[serde(flatten)]
2178    unknown: UnknownKeys,
2179}
2180
2181#[derive(Debug, Deserialize)]
2182struct RawPeerFetchCapabilities {
2183    #[serde(default)]
2184    allow: Vec<String>,
2185    #[serde(flatten)]
2186    unknown: UnknownKeys,
2187}
2188
2189#[derive(Debug, Deserialize)]
2190struct RawFilesystemCapabilities {
2191    #[serde(default)]
2192    scope: Option<String>,
2193    /// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
2194    /// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
2195    /// "The key is absent" and "the key says `false`" are the same declaration.
2196    #[serde(default)]
2197    workdir_exec: bool,
2198    /// Always a list after parsing, never an `Option`: an absent key and an empty list are the
2199    /// same declaration — nothing is protected.
2200    #[serde(default)]
2201    read_only: Vec<String>,
2202    #[serde(flatten)]
2203    unknown: UnknownKeys,
2204}
2205
2206#[derive(Debug, Deserialize)]
2207struct RawShellCapabilities {
2208    #[serde(default)]
2209    allow: Vec<String>,
2210    #[serde(default)]
2211    strip_env: Option<Vec<String>>,
2212    #[serde(default)]
2213    baseline_env: Option<Vec<String>>,
2214    #[serde(default)]
2215    interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2216    #[serde(default)]
2217    staged_runtime: Vec<RawStagedRuntimeGrant>,
2218    #[serde(flatten)]
2219    unknown: UnknownKeys,
2220}
2221
2222#[derive(Debug, Deserialize)]
2223struct RawInterpreterRuntimeGrant {
2224    #[serde(default)]
2225    binary: Option<String>,
2226    #[serde(default)]
2227    dirs: Vec<RawInterpreterRuntimeDir>,
2228    #[serde(flatten)]
2229    unknown: UnknownKeys,
2230}
2231
2232#[derive(Debug, Deserialize)]
2233struct RawInterpreterRuntimeDir {
2234    #[serde(default)]
2235    path: Option<String>,
2236    // `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
2237    // omission is rejected outright rather than defaulted, because enumerability is never
2238    // inferred.
2239    #[serde(default)]
2240    list_dir: Option<bool>,
2241    #[serde(flatten)]
2242    unknown: UnknownKeys,
2243}
2244
2245#[derive(Debug, Deserialize)]
2246struct RawStagedRuntimeGrant {
2247    #[serde(default)]
2248    binary: Option<String>,
2249    #[serde(default)]
2250    source_path: Option<String>,
2251    // `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
2252    // field-naming message, rather than defaulting to an empty string that would silently mean
2253    // "unpinned" — the one thing this field exists to make impossible.
2254    #[serde(default)]
2255    pin: Option<String>,
2256    #[serde(flatten)]
2257    unknown: UnknownKeys,
2258}
2259
2260#[derive(Debug, Deserialize)]
2261struct RawInferenceConfig {
2262    transport: Option<String>,
2263    /// Removed: the driver's upstream is `gateway.endpoint` on its artifact entry. Deserialized
2264    /// only so `parse_inference` can refuse it by name, naming where it moved.
2265    #[serde(default, deserialize_with = "deserialize_present")]
2266    endpoint: Option<serde_yaml::Value>,
2267    model: Option<String>,
2268    /// Removed: the driver's key is `gateway.api_key` on its artifact entry. Deserialized only so
2269    /// `parse_inference` can refuse it by name, naming where it moved.
2270    #[serde(default, deserialize_with = "deserialize_present")]
2271    api_key: Option<serde_yaml::Value>,
2272    #[serde(default)]
2273    command: Option<String>,
2274    #[serde(default)]
2275    system_prompt: Option<String>,
2276    #[serde(default)]
2277    system_prompt_file: Option<String>,
2278    #[serde(default)]
2279    system_prompt_artifact: Option<String>,
2280    #[serde(default)]
2281    driver: Option<RawInferenceDriver>,
2282    #[serde(default)]
2283    provider: Option<RawInferenceDriver>,
2284    #[serde(default)]
2285    compaction: Option<RawCompactionConfig>,
2286    #[serde(default)]
2287    max_turns: Option<u32>,
2288    /// The `inference` spelling of the reopen budget is not a live field. It is deserialized
2289    /// only so `parse_inference` can refuse it; without it serde would ignore the key.
2290    #[serde(default)]
2291    max_task_reopens: Option<u32>,
2292    #[serde(default)]
2293    max_tokens: Option<u32>,
2294    #[serde(default)]
2295    max_session_tokens: Option<u64>,
2296    #[serde(default)]
2297    tool_refresh: Option<String>,
2298    #[serde(default)]
2299    alternates: Option<Vec<RawInferenceAlternate>>,
2300    #[serde(flatten)]
2301    unknown: UnknownKeys,
2302}
2303
2304#[derive(Debug, Deserialize)]
2305struct RawInferenceAlternate {
2306    #[serde(default)]
2307    name: Option<String>,
2308    #[serde(default)]
2309    model: Option<String>,
2310    #[serde(default)]
2311    driver: Option<RawInferenceDriver>,
2312    #[serde(flatten)]
2313    unknown: UnknownKeys,
2314}
2315
2316#[derive(Debug, Deserialize)]
2317struct RawCompactionConfig {
2318    threshold: Option<f32>,
2319    model: Option<String>,
2320    system_prompt: Option<String>,
2321    system_prompt_file: Option<String>,
2322    dump_summaries: Option<bool>,
2323    #[serde(flatten)]
2324    unknown: UnknownKeys,
2325}
2326
2327#[derive(Debug, Deserialize)]
2328struct RawInferenceDriver {
2329    artifact: Option<String>,
2330    #[serde(default)]
2331    config: Option<serde_yaml::Value>,
2332    #[serde(flatten)]
2333    unknown: UnknownKeys,
2334}
2335
2336/// The keys one `Raw*` block carried that no field of it claims.
2337///
2338/// A `BTreeMap` so the capture order is the manifest's key order sorted, which keeps the
2339/// `W-SEC-019` lines for one manifest identical on every run. The values are discarded: a key this
2340/// build does not recognize is reported by name and by containing block, and printing whatever an
2341/// operator wrote under it would put manifest contents into a diagnostic that has no use for them.
2342type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2343
2344/// A `Raw*` deserialization block that captures the keys this build does not recognize.
2345///
2346/// Implemented for every `Raw*` struct in this file, which is asserted structurally by
2347/// `every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names` rather than left to
2348/// review: a manifest key added without its block learning about it is exactly the defect
2349/// `W-SEC-019` exists to report.
2350trait RawBlock {
2351    /// Every key serde accepts on this block, in declaration order and under the name serde
2352    /// matches (so a `#[serde(rename)]` field appears under its renamed spelling). This is the
2353    /// candidate set [`crate::unknown_manifest_keys::nearest_known_key`] suggests from.
2354    const KNOWN_KEYS: &'static [&'static str];
2355
2356    fn unknown_keys(&self) -> &UnknownKeys;
2357
2358    /// Descends into the blocks this one owns, in declaration order, so the reported paths read
2359    /// down the manifest the way the document does.
2360    ///
2361    /// Defaulted to a no-op, which is correct for a block that owns no other block. A block that
2362    /// does own one must override this: `every_raw_struct_descends_into_the_blocks_it_owns` reads
2363    /// this file's own text and fails, naming the struct and the field, if it does not — an
2364    /// unwalked block reports none of its keys, silently.
2365    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2366        let _ = (path, out);
2367    }
2368}
2369
2370impl RawBlock for RawRuntimeManifest {
2371    const KNOWN_KEYS: &'static [&'static str] = &[
2372        "name",
2373        "version",
2374        "artifacts",
2375        "capabilities",
2376        "inference",
2377        "context",
2378        "observability",
2379        "trace",
2380        "network",
2381        "lifecycle",
2382        "exports",
2383        "control",
2384        "mur_version",
2385        "config",
2386        "runtime",
2387        "implementation",
2388        "execution",
2389        "requires_files",
2390    ];
2391    fn unknown_keys(&self) -> &UnknownKeys {
2392        &self.unknown
2393    }
2394
2395    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2396        let artifacts = child_path(path, "artifacts");
2397        for (index, artifact) in self.artifacts.iter().enumerate() {
2398            collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2399        }
2400        if let Some(capabilities) = &self.capabilities {
2401            collect_block(capabilities, &child_path(path, "capabilities"), out);
2402        }
2403        if let Some(inference) = &self.inference {
2404            collect_block(inference, &child_path(path, "inference"), out);
2405        }
2406        if let Some(context) = &self.context {
2407            collect_block(context, &child_path(path, "context"), out);
2408        }
2409        if let Some(observability) = &self.observability {
2410            collect_block(observability, &child_path(path, "observability"), out);
2411        }
2412        if let Some(trace) = &self.trace {
2413            collect_block(trace, &child_path(path, "trace"), out);
2414        }
2415        if let Some(network) = &self.network {
2416            collect_block(network, &child_path(path, "network"), out);
2417        }
2418        if let Some(lifecycle) = &self.lifecycle {
2419            collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2420        }
2421        if let Some(exports) = &self.exports {
2422            collect_block(exports, &child_path(path, "exports"), out);
2423        }
2424        if let Some(control) = &self.control {
2425            collect_block(control, &child_path(path, "control"), out);
2426        }
2427    }
2428}
2429
2430impl RawBlock for RawControl {
2431    const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2432    fn unknown_keys(&self) -> &UnknownKeys {
2433        &self.unknown
2434    }
2435}
2436
2437impl RawBlock for RawExports {
2438    const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files"];
2439    fn unknown_keys(&self) -> &UnknownKeys {
2440        &self.unknown
2441    }
2442
2443    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2444        if let Some(files) = &self.files {
2445            collect_block(files, &child_path(path, "files"), out);
2446        }
2447        if let Some(peer_files) = &self.peer_files {
2448            collect_block(peer_files, &child_path(path, "peer_files"), out);
2449        }
2450    }
2451}
2452
2453impl RawBlock for RawPeerFilesExport {
2454    const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2455    fn unknown_keys(&self) -> &UnknownKeys {
2456        &self.unknown
2457    }
2458}
2459
2460impl RawBlock for RawFileExport {
2461    const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2462    fn unknown_keys(&self) -> &UnknownKeys {
2463        &self.unknown
2464    }
2465}
2466
2467impl RawBlock for RawLifecycleConfig {
2468    const KNOWN_KEYS: &'static [&'static str] = &[
2469        "task_acceptance",
2470        "after_task",
2471        "queue_depth",
2472        "input_timeout_secs",
2473        "conversation",
2474        "max_task_reopens",
2475        "shell_grace_secs",
2476        "delegation_deadline_secs",
2477    ];
2478    fn unknown_keys(&self) -> &UnknownKeys {
2479        &self.unknown
2480    }
2481}
2482
2483impl RawBlock for RawNetworkConfig {
2484    const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
2485    fn unknown_keys(&self) -> &UnknownKeys {
2486        &self.unknown
2487    }
2488
2489    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2490        if let Some(authentication) = &self.authentication {
2491            collect_block(authentication, &child_path(path, "authentication"), out);
2492        }
2493    }
2494}
2495
2496impl RawBlock for RawNetworkAuthentication {
2497    const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
2498    fn unknown_keys(&self) -> &UnknownKeys {
2499        &self.unknown
2500    }
2501
2502    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2503        let credentials_path = child_path(path, "credentials");
2504        for (name, credential) in self.credentials.iter().flatten() {
2505            collect_block(credential, &child_path(&credentials_path, name), out);
2506        }
2507    }
2508}
2509
2510impl RawBlock for RawDoorCredential {
2511    const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
2512    fn unknown_keys(&self) -> &UnknownKeys {
2513        &self.unknown
2514    }
2515}
2516
2517impl RawBlock for RawContextConfig {
2518    const KNOWN_KEYS: &'static [&'static str] = &[
2519        "max_tokens",
2520        "record",
2521        "record_store",
2522        "seed_budget",
2523        "seed_overflow_margin",
2524        "retain",
2525    ];
2526    fn unknown_keys(&self) -> &UnknownKeys {
2527        &self.unknown
2528    }
2529}
2530
2531impl RawBlock for RawObservabilityConfig {
2532    const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
2533    fn unknown_keys(&self) -> &UnknownKeys {
2534        &self.unknown
2535    }
2536
2537    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2538        if let Some(eval) = &self.eval {
2539            collect_block(eval, &child_path(path, "eval"), out);
2540        }
2541    }
2542}
2543
2544impl RawBlock for RawTraceConfig {
2545    const KNOWN_KEYS: &'static [&'static str] = &["capture", "include_tool_output", "retain"];
2546    fn unknown_keys(&self) -> &UnknownKeys {
2547        &self.unknown
2548    }
2549}
2550
2551impl RawBlock for RawEvalConfig {
2552    const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
2553    fn unknown_keys(&self) -> &UnknownKeys {
2554        &self.unknown
2555    }
2556
2557    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2558        let scorers = child_path(path, "scorers");
2559        for (index, scorer) in self.scorers.iter().enumerate() {
2560            collect_block(scorer, &format!("{scorers}[{index}]"), out);
2561        }
2562    }
2563}
2564
2565impl RawBlock for RawScorerConfig {
2566    const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
2567    fn unknown_keys(&self) -> &UnknownKeys {
2568        &self.unknown
2569    }
2570}
2571
2572impl RawBlock for RawArtifact {
2573    const KNOWN_KEYS: &'static [&'static str] = &[
2574        "name",
2575        "version",
2576        "runtime",
2577        "source",
2578        "local_source",
2579        "prompt_payload",
2580        "capabilities",
2581        "on_overflow",
2582        "config",
2583        "gateway",
2584    ];
2585    fn unknown_keys(&self) -> &UnknownKeys {
2586        &self.unknown
2587    }
2588
2589    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2590        if let Some(capabilities) = &self.capabilities {
2591            collect_block(capabilities, &child_path(path, "capabilities"), out);
2592        }
2593        if let Some(Some(gateway)) = &self.gateway {
2594            collect_block(gateway, &child_path(path, "gateway"), out);
2595        }
2596    }
2597}
2598
2599impl RawBlock for RawArtifactGateway {
2600    const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
2601    fn unknown_keys(&self) -> &UnknownKeys {
2602        &self.unknown
2603    }
2604}
2605
2606impl RawBlock for RawCapabilities {
2607    const KNOWN_KEYS: &'static [&'static str] = &[
2608        "network",
2609        "peer_fetch",
2610        "filesystem",
2611        "shell",
2612        "spawn",
2613        "env",
2614        "limits",
2615        "resources",
2616        "state",
2617        "task_io",
2618        "conversation",
2619        "plan",
2620        "install",
2621        "containment",
2622    ];
2623    fn unknown_keys(&self) -> &UnknownKeys {
2624        &self.unknown
2625    }
2626
2627    /// Reached from both [`RawRuntimeManifest`] and [`RawArtifact`], so the capsule-wide block and
2628    /// a per-artifact one report through identical code and differ only in the `path` they are
2629    /// handed — `capabilities.shell` against `artifacts[0].capabilities.shell`.
2630    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2631        if let Some(network) = &self.network {
2632            collect_block(network, &child_path(path, "network"), out);
2633        }
2634        if let Some(peer_fetch) = &self.peer_fetch {
2635            collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
2636        }
2637        if let Some(filesystem) = &self.filesystem {
2638            collect_block(filesystem, &child_path(path, "filesystem"), out);
2639        }
2640        if let Some(shell) = &self.shell {
2641            collect_block(shell, &child_path(path, "shell"), out);
2642        }
2643        if let Some(spawn) = &self.spawn {
2644            collect_block(spawn, &child_path(path, "spawn"), out);
2645        }
2646        if let Some(env) = &self.env {
2647            collect_block(env, &child_path(path, "env"), out);
2648        }
2649        if let Some(limits) = &self.limits {
2650            collect_block(limits, &child_path(path, "limits"), out);
2651        }
2652        if let Some(resources) = &self.resources {
2653            collect_block(resources, &child_path(path, "resources"), out);
2654        }
2655        if let Some(state) = &self.state {
2656            collect_block(state, &child_path(path, "state"), out);
2657        }
2658        if let Some(task_io) = &self.task_io {
2659            collect_block(task_io, &child_path(path, "task_io"), out);
2660        }
2661        if let Some(conversation) = &self.conversation {
2662            collect_block(conversation, &child_path(path, "conversation"), out);
2663        }
2664        if let Some(plan) = &self.plan {
2665            collect_block(plan, &child_path(path, "plan"), out);
2666        }
2667        if let Some(install) = &self.install {
2668            collect_block(install, &child_path(path, "install"), out);
2669        }
2670    }
2671}
2672
2673impl RawBlock for RawTaskIoCapabilities {
2674    const KNOWN_KEYS: &'static [&'static str] = &["read"];
2675    fn unknown_keys(&self) -> &UnknownKeys {
2676        &self.unknown
2677    }
2678}
2679
2680impl RawBlock for RawConversationCapabilities {
2681    const KNOWN_KEYS: &'static [&'static str] = &["read"];
2682    fn unknown_keys(&self) -> &UnknownKeys {
2683        &self.unknown
2684    }
2685}
2686
2687impl RawBlock for RawPlanCapabilities {
2688    const KNOWN_KEYS: &'static [&'static str] = &["submit"];
2689    fn unknown_keys(&self) -> &UnknownKeys {
2690        &self.unknown
2691    }
2692}
2693
2694impl RawBlock for RawInstallCapabilities {
2695    const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool"];
2696    fn unknown_keys(&self) -> &UnknownKeys {
2697        &self.unknown
2698    }
2699}
2700
2701impl RawBlock for RawSpawnCapabilities {
2702    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2703    fn unknown_keys(&self) -> &UnknownKeys {
2704        &self.unknown
2705    }
2706}
2707
2708impl RawBlock for RawEnvCapabilities {
2709    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2710    fn unknown_keys(&self) -> &UnknownKeys {
2711        &self.unknown
2712    }
2713}
2714
2715impl RawBlock for RawResourceLimits {
2716    const KNOWN_KEYS: &'static [&'static str] = &[
2717        "memory_bytes",
2718        "table_elements",
2719        "instances",
2720        "deadline_seconds",
2721    ];
2722    fn unknown_keys(&self) -> &UnknownKeys {
2723        &self.unknown
2724    }
2725}
2726
2727impl RawBlock for RawStateCapabilities {
2728    const KNOWN_KEYS: &'static [&'static str] = &["store"];
2729    fn unknown_keys(&self) -> &UnknownKeys {
2730        &self.unknown
2731    }
2732}
2733
2734impl RawBlock for RawResourceCapabilities {
2735    const KNOWN_KEYS: &'static [&'static str] = &[
2736        "max_processes",
2737        "max_open_files",
2738        "max_file_size_bytes",
2739        "cpu_seconds",
2740        "memory_bytes",
2741        "cgroup_memory_bytes",
2742        "cgroup_pids_max",
2743        "cgroup_cpu_percent",
2744        "cgroup_io_bytes_per_sec",
2745        "workdir_max_bytes",
2746    ];
2747    fn unknown_keys(&self) -> &UnknownKeys {
2748        &self.unknown
2749    }
2750}
2751
2752impl RawBlock for RawNetworkCapabilities {
2753    const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
2754    fn unknown_keys(&self) -> &UnknownKeys {
2755        &self.unknown
2756    }
2757}
2758
2759impl RawBlock for RawPeerFetchCapabilities {
2760    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2761    fn unknown_keys(&self) -> &UnknownKeys {
2762        &self.unknown
2763    }
2764}
2765
2766impl RawBlock for RawFilesystemCapabilities {
2767    const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only"];
2768    fn unknown_keys(&self) -> &UnknownKeys {
2769        &self.unknown
2770    }
2771}
2772
2773impl RawBlock for RawShellCapabilities {
2774    const KNOWN_KEYS: &'static [&'static str] = &[
2775        "allow",
2776        "strip_env",
2777        "baseline_env",
2778        "interpreter_runtime",
2779        "staged_runtime",
2780    ];
2781    fn unknown_keys(&self) -> &UnknownKeys {
2782        &self.unknown
2783    }
2784
2785    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2786        let interpreter_runtime = child_path(path, "interpreter_runtime");
2787        for (index, grant) in self.interpreter_runtime.iter().enumerate() {
2788            collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
2789        }
2790        let staged_runtime = child_path(path, "staged_runtime");
2791        for (index, grant) in self.staged_runtime.iter().enumerate() {
2792            collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
2793        }
2794    }
2795}
2796
2797impl RawBlock for RawInterpreterRuntimeGrant {
2798    const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
2799    fn unknown_keys(&self) -> &UnknownKeys {
2800        &self.unknown
2801    }
2802
2803    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2804        let dirs = child_path(path, "dirs");
2805        for (index, dir) in self.dirs.iter().enumerate() {
2806            collect_block(dir, &format!("{dirs}[{index}]"), out);
2807        }
2808    }
2809}
2810
2811impl RawBlock for RawInterpreterRuntimeDir {
2812    const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
2813    fn unknown_keys(&self) -> &UnknownKeys {
2814        &self.unknown
2815    }
2816}
2817
2818impl RawBlock for RawStagedRuntimeGrant {
2819    const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
2820    fn unknown_keys(&self) -> &UnknownKeys {
2821        &self.unknown
2822    }
2823}
2824
2825impl RawBlock for RawInferenceConfig {
2826    const KNOWN_KEYS: &'static [&'static str] = &[
2827        "transport",
2828        "endpoint",
2829        "model",
2830        "api_key",
2831        "command",
2832        "system_prompt",
2833        "system_prompt_file",
2834        "system_prompt_artifact",
2835        "driver",
2836        "provider",
2837        "compaction",
2838        "max_turns",
2839        "max_task_reopens",
2840        "max_tokens",
2841        "max_session_tokens",
2842        "tool_refresh",
2843        "alternates",
2844    ];
2845    fn unknown_keys(&self) -> &UnknownKeys {
2846        &self.unknown
2847    }
2848
2849    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2850        if let Some(driver) = &self.driver {
2851            collect_block(driver, &child_path(path, "driver"), out);
2852        }
2853        if let Some(provider) = &self.provider {
2854            collect_block(provider, &child_path(path, "provider"), out);
2855        }
2856        if let Some(compaction) = &self.compaction {
2857            collect_block(compaction, &child_path(path, "compaction"), out);
2858        }
2859        if let Some(alternates) = &self.alternates {
2860            let alternates_path = child_path(path, "alternates");
2861            for (index, alternate) in alternates.iter().enumerate() {
2862                collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
2863            }
2864        }
2865    }
2866}
2867
2868impl RawBlock for RawInferenceAlternate {
2869    const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
2870    fn unknown_keys(&self) -> &UnknownKeys {
2871        &self.unknown
2872    }
2873
2874    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2875        if let Some(driver) = &self.driver {
2876            collect_block(driver, &child_path(path, "driver"), out);
2877        }
2878    }
2879}
2880
2881impl RawBlock for RawCompactionConfig {
2882    const KNOWN_KEYS: &'static [&'static str] = &[
2883        "threshold",
2884        "model",
2885        "system_prompt",
2886        "system_prompt_file",
2887        "dump_summaries",
2888    ];
2889    fn unknown_keys(&self) -> &UnknownKeys {
2890        &self.unknown
2891    }
2892}
2893
2894impl RawBlock for RawInferenceDriver {
2895    const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
2896    fn unknown_keys(&self) -> &UnknownKeys {
2897        &self.unknown
2898    }
2899}
2900
2901/// Walks a parsed raw manifest and reports every key no block of it claimed, each block's own
2902/// keys before the blocks it owns, in the order the blocks appear in the manifest's own type.
2903///
2904/// Called once, immediately after `serde_yaml` returns and before any field is moved out, so the
2905/// result covers the whole document rather than the parts validation happens to reach. Reporting
2906/// only; nothing here can refuse a manifest.
2907fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
2908    let mut out = Vec::new();
2909    collect_block(raw, "", &mut out);
2910    out
2911}
2912
2913/// One block's captured keys, each paired with the nearest key that block does recognize, then
2914/// the same for every block it owns.
2915///
2916/// `path` is empty for the manifest root, which is what
2917/// [`crate::unknown_manifest_keys::UnknownManifestKey`] renders as "at the top level".
2918fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
2919    for key in block.unknown_keys().keys() {
2920        out.push(UnknownManifestKey {
2921            key: key.clone(),
2922            block_path: path.to_string(),
2923            nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
2924        });
2925    }
2926    block.walk_children(path, out);
2927}
2928
2929/// The dotted path of `field` inside the block at `parent`, built in one place so no
2930/// [`RawBlock::walk_children`] has to reproduce the rule that the manifest root has no prefix.
2931/// An indexed child appends its own `[index]` to the result.
2932fn child_path(parent: &str, field: &str) -> String {
2933    if parent.is_empty() {
2934        return field.to_string();
2935    }
2936    format!("{parent}.{field}")
2937}
2938
2939/// Read a manifest file's text, mapping a missing file to
2940/// [`RuntimeManifestError::NotFound`] and any other read failure to
2941/// [`RuntimeManifestError::Io`].
2942///
2943/// For a caller that needs the manifest text as well as the parsed manifest — `mur doctor` reads
2944/// the references a manifest makes out of the same bytes it parses, so the file is opened once.
2945pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
2946    fs::read_to_string(path).map_err(|source| {
2947        if source.kind() == std::io::ErrorKind::NotFound {
2948            return RuntimeManifestError::NotFound(path.display().to_string());
2949        }
2950
2951        RuntimeManifestError::Io {
2952            path: path.display().to_string(),
2953            source,
2954        }
2955    })
2956}
2957
2958#[must_use = "validated runtime manifest is required before run"]
2959pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
2960    let content = read_runtime_manifest_text(path)?;
2961
2962    RuntimeManifest::from_yaml_str(&content)
2963}
2964
2965/// One `${VAR}` reference a manifest makes, and the field that makes it.
2966#[derive(Debug, Clone, PartialEq, Eq)]
2967pub struct ReferencedEnvVariable {
2968    /// The manifest field holding the reference, e.g. `artifacts.murmur-driver-anthropic.gateway.api_key`.
2969    pub field: String,
2970    /// The variable the reference names.
2971    pub variable: String,
2972}
2973
2974impl ReferencedEnvVariable {
2975    /// Whether the reference is a gateway's `api_key` — a credential the runtime also answers
2976    /// from the global config's `credentials:` map, not only from the environment.
2977    pub fn is_gateway_credential(&self) -> bool {
2978        self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
2979    }
2980}
2981
2982/// The narrow view of a manifest this scan needs: the fields whose value may be an `${ENV_REF}`,
2983/// and nothing else. Every other key is ignored, so a manifest this build would refuse for an
2984/// unrelated reason still yields its references.
2985///
2986/// Deliberately not one of the `Raw*` structs: those carry the unknown-key overflow map that
2987/// makes `warn_on_unknown_manifest_keys` complete, and this one is a second, partial reader of a
2988/// document that reader has already covered.
2989#[derive(Deserialize)]
2990struct ReferencedManifestFields {
2991    #[serde(default)]
2992    artifacts: Vec<ReferencedArtifactFields>,
2993}
2994
2995#[derive(Deserialize)]
2996struct ReferencedArtifactFields {
2997    #[serde(default)]
2998    name: Option<String>,
2999    #[serde(default)]
3000    gateway: Option<ReferencedGatewayFields>,
3001}
3002
3003#[derive(Deserialize)]
3004struct ReferencedGatewayFields {
3005    #[serde(default)]
3006    api_key: Option<String>,
3007}
3008
3009/// Every environment variable `manifest_yaml` references through an `${ENV_REF}`, sorted by
3010/// variable name and deduplicated.
3011///
3012/// Never fails and never reads the environment: unparseable YAML, an absent field and a literal
3013/// (non-reference) value all yield an empty list. What counts as a reference is decided by the
3014/// same `parse_env_reference` the resolving parse applies, so a name reported here is exactly a
3015/// name [`RuntimeManifest::from_yaml_str`] would have looked up.
3016pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3017    let Ok(raw) = serde_yaml::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3018        return Vec::new();
3019    };
3020
3021    let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3022    for artifact in raw.artifacts {
3023        let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3024            continue;
3025        };
3026        if let Some(variable) = parse_env_reference(api_key.trim()) {
3027            let name = artifact.name.unwrap_or_default();
3028            referenced.push(ReferencedEnvVariable {
3029                field: format!("artifacts.{}.gateway.api_key", name.trim()),
3030                variable: variable.to_string(),
3031            });
3032        }
3033    }
3034
3035    referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3036    referenced.dedup();
3037    referenced
3038}
3039
3040/// Whether a parse keeps what the manifest says about a secret it references.
3041///
3042/// The two [`RuntimeManifest`] constructors differ by this value and nothing else, so validation
3043/// cannot drift between a manifest `mur run` accepts and one a referee accepts. Neither reads the
3044/// process environment.
3045#[derive(Clone, Copy, PartialEq, Eq)]
3046enum SecretResolution {
3047    /// Every `gateway.api_key` is kept as an [`ApiKeyReference`], unresolved.
3048    Resolve,
3049    /// Every `gateway.api_key` is left unread. Each parsed [`ArtifactGateway::api_key`] is `None`
3050    /// whatever the manifest wrote.
3051    Skip,
3052}
3053
3054impl RuntimeManifest {
3055    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3056        Self::parse(input, SecretResolution::Resolve)
3057    }
3058
3059    /// Parse a manifest without resolving any secret it references.
3060    ///
3061    /// Validation is identical to [`RuntimeManifest::from_yaml_str`] on every block, `artifacts`
3062    /// included, with one difference: every resulting [`ArtifactGateway::api_key`] is `None`,
3063    /// whether the manifest wrote an `${ENV_REF}` or a literal key.
3064    ///
3065    /// For a caller that reads a foreign capsule's manifest for its capability policy alone —
3066    /// `mur-roost` refereeing a delegation — and is entitled to none of the credentials that
3067    /// manifest names.
3068    pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3069        Self::parse(input, SecretResolution::Skip)
3070    }
3071
3072    fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3073        let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
3074            if let Some(location) = err.location() {
3075                RuntimeManifestError::YamlSyntax(format!(
3076                    "{}: YAML syntax error at line {}, column {}: {}",
3077                    MANIFEST_FILENAME,
3078                    location.line(),
3079                    location.column(),
3080                    err
3081                ))
3082            } else {
3083                RuntimeManifestError::YamlSyntax(format!(
3084                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3085                ))
3086            }
3087        })?;
3088
3089        // Collected before any field is moved out of `raw`, so the walk sees the whole document
3090        // rather than the part validation happens to reach before its first refusal.
3091        let unknown_keys = collect_unknown_keys(&raw);
3092
3093        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3094            RuntimeManifestError::MissingField {
3095                field: "name".to_string(),
3096            }
3097        })?;
3098
3099        let version = raw
3100            .version
3101            .filter(|s| !s.trim().is_empty())
3102            .ok_or_else(|| RuntimeManifestError::MissingField {
3103                field: "version".to_string(),
3104            })?;
3105
3106        // Read off the raw entries because a parse that skips secrets keeps no `ApiKeyReference`,
3107        // and `control.secrets` is checked against what the manifest wrote either way.
3108        let gateway_references = raw_gateway_references(&raw.artifacts);
3109
3110        let artifacts = raw
3111            .artifacts
3112            .into_iter()
3113            .enumerate()
3114            .map(|(index, artifact)| {
3115                let name = artifact
3116                    .name
3117                    .filter(|s| !s.trim().is_empty())
3118                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3119                        index,
3120                        message: "missing required field 'name'".to_string(),
3121                    })?;
3122
3123                let source = artifact.source.filter(|s| !s.trim().is_empty());
3124
3125                let runtime = match artifact.runtime.as_deref() {
3126                    None | Some("tool") => ArtifactRuntime::Tool,
3127                    Some("wasm") | Some("native") => {
3128                        return Err(RuntimeManifestError::InvalidArtifact {
3129                            index,
3130                            message: format!(
3131                                "use 'runtime: tool'; implementation is declared in the \
3132                                 artifact's own manifest (got '{}')",
3133                                artifact.runtime.as_deref().unwrap_or("")
3134                            ),
3135                        });
3136                    }
3137                    Some("driver") => ArtifactRuntime::Driver,
3138                    Some("hook") => ArtifactRuntime::Hook,
3139                    Some("skill") => ArtifactRuntime::Skill,
3140                    Some(other) => {
3141                        return Err(RuntimeManifestError::InvalidArtifact {
3142                            index,
3143                            message: format!(
3144                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3145                            ),
3146                        });
3147                    }
3148                };
3149
3150                // Capabilities are keyed on declared properties, not on the role. When the
3151                // property is absent we derive it from the role, which reproduces the previous
3152                // skill-only gate exactly and leaves every existing manifest unchanged.
3153                let local_source = artifact
3154                    .local_source
3155                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3156                let prompt_payload = artifact
3157                    .prompt_payload
3158                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3159
3160                // `source:` requires the artifact to declare `local_source: true`.
3161                if source.is_some() && !local_source {
3162                    return Err(RuntimeManifestError::InvalidArtifact {
3163                        index,
3164                        message: format!(
3165                            "artifact '{name}' declares 'source:' but does not declare \
3166                             'local_source: true' (runtime: {})",
3167                            runtime.as_str()
3168                        ),
3169                    });
3170                }
3171
3172                // When `source:` is present, `version:` is optional — substitute "local".
3173                // If both are set, `version:` is ignored and a warning is printed to stderr.
3174                let version = if source.is_some() {
3175                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3176                        eprintln!(
3177                            "warning: artifact '{name}' declares both 'source:' and \
3178                             'version: {explicit}'; version is ignored for local-source skills \
3179                             (using 'local')"
3180                        );
3181                    }
3182                    "local".to_string()
3183                } else {
3184                    artifact
3185                        .version
3186                        .filter(|s| !s.trim().is_empty())
3187                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3188                            index,
3189                            message: "missing required field 'version'".to_string(),
3190                        })?
3191                };
3192
3193                // Per-artifact `capabilities:` is enforced on every role that actually
3194                // executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
3195                // below the capsule ceiling). A `skill` has no execution surface, so the
3196                // block would be silently ignored there; that reads like a scoped artifact
3197                // when it is not, hence a parse error rather than a no-op.
3198                let capabilities = match artifact.capabilities {
3199                    None => None,
3200                    Some(raw_caps) => {
3201                        // The install grant is held by the session, not by one artifact: a
3202                        // per-artifact block would reach no pull at all.
3203                        if raw_caps.install.is_some() {
3204                            return Err(RuntimeManifestError::InvalidArtifact {
3205                                index,
3206                                message: format!(
3207                                    "artifact '{name}' declares 'capabilities.install:'; the \
3208                                     grant is capsule-wide and is only recognized in the \
3209                                     top-level capabilities block"
3210                                ),
3211                            });
3212                        }
3213                        if runtime == ArtifactRuntime::Skill {
3214                            return Err(RuntimeManifestError::InvalidArtifact {
3215                                index,
3216                                message: format!(
3217                                    "artifact '{name}' declares per-artifact 'capabilities:' but \
3218                                     has 'runtime: {}'; per-artifact capabilities are only \
3219                                     recognized on 'runtime: hook', 'runtime: tool', and \
3220                                     'runtime: driver' entries — use the capsule-wide top-level \
3221                                     'capabilities:' block instead",
3222                                    runtime.as_str()
3223                                ),
3224                            });
3225                        }
3226                        // `task_io` grants the `murmur:task-io/read` host import, which only
3227                        // hook components can be handed. On a tool or driver entry nothing
3228                        // would enforce it, and a silently-inert grant reads like a scoped
3229                        // artifact — the same reason `on_overflow:` below is a parse error off
3230                        // a hook.
3231                        if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3232                            return Err(RuntimeManifestError::InvalidArtifact {
3233                                index,
3234                                message: format!(
3235                                    "artifact '{name}' declares 'capabilities.task_io:' but has \
3236                                     'runtime: {}'; the key grants the murmur:task-io/read host \
3237                                     import and is only recognized on 'runtime: hook' entries",
3238                                    runtime.as_str()
3239                                ),
3240                            });
3241                        }
3242                        // `conversation` grants the `murmur:conversation/read` host import, on the
3243                        // same terms as `task_io` above: only a hook component's world can import
3244                        // it, so on any other role the grant would be silently inert.
3245                        if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3246                            return Err(RuntimeManifestError::InvalidArtifact {
3247                                index,
3248                                message: format!(
3249                                    "artifact '{name}' declares 'capabilities.conversation:' but \
3250                                     has 'runtime: {}'; the key grants the \
3251                                     murmur:conversation/read host import and is only recognized \
3252                                     on 'runtime: hook' entries",
3253                                    runtime.as_str()
3254                                ),
3255                            });
3256                        }
3257                        parse_capabilities(Some(raw_caps))?
3258                    }
3259                };
3260
3261                // `on_overflow:` governs an async hook's job queue, which only exists for
3262                // `runtime: hook`. On any other role it would be silently ignored, so it is
3263                // a parse error for the same reason per-artifact `capabilities:` is on a
3264                // skill. It stays legal on a *blocking* hook entry (simply inert): the
3265                // execution mode lives in the artifact's own manifest and is unknown here.
3266                let on_overflow = match artifact.on_overflow.as_deref() {
3267                    None => HookOverflowPolicy::default(),
3268                    Some(raw) => {
3269                        if runtime != ArtifactRuntime::Hook {
3270                            return Err(RuntimeManifestError::InvalidArtifact {
3271                                index,
3272                                message: format!(
3273                                    "artifact '{name}' declares 'on_overflow:' but has \
3274                                     'runtime: {}'; the key governs an async hook's job queue \
3275                                     and is only recognized on 'runtime: hook' entries",
3276                                    runtime.as_str()
3277                                ),
3278                            });
3279                        }
3280                        match raw {
3281                            "drop" => HookOverflowPolicy::Drop,
3282                            "block" => HookOverflowPolicy::Block,
3283                            other => {
3284                                return Err(RuntimeManifestError::InvalidArtifact {
3285                                    index,
3286                                    message: format!(
3287                                        "artifact '{name}' declares unknown on_overflow \
3288                                         '{other}'; expected: drop, block"
3289                                    ),
3290                                });
3291                            }
3292                        }
3293                    }
3294                };
3295
3296                // `config:` reaches an artifact through that artifact's own grant, and a skill
3297                // holds no grant and runs no component — nothing would ever deliver it. Refused
3298                // rather than ignored, for the reason the two gates above are: a key that reads
3299                // like configuration and is silently dropped is worse than one that is refused.
3300                // The block's *shape* is the runtime's business, not this parser's; only the
3301                // role is decided here.
3302                if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3303                    return Err(RuntimeManifestError::InvalidArtifact {
3304                        index,
3305                        message: format!(
3306                            "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3307                             is recognized only on 'runtime: hook', 'runtime: tool', and \
3308                             'runtime: driver' entries",
3309                            runtime.as_str()
3310                        ),
3311                    });
3312                }
3313
3314                let gateway = match artifact.gateway {
3315                    None => None,
3316                    Some(raw_gateway) => {
3317                        if runtime == ArtifactRuntime::Skill {
3318                            return Err(RuntimeManifestError::InvalidArtifact {
3319                                index,
3320                                message: format!(
3321                                    "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
3322                                     the key is recognized only on 'runtime: hook', \
3323                                     'runtime: tool', and 'runtime: driver' entries",
3324                                    runtime.as_str()
3325                                ),
3326                            });
3327                        }
3328                        Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
3329                    }
3330                };
3331
3332                Ok(RuntimeArtifact {
3333                    name,
3334                    version,
3335                    runtime,
3336                    source,
3337                    local_source,
3338                    prompt_payload,
3339                    capabilities,
3340                    on_overflow,
3341                    config: artifact.config,
3342                    gateway,
3343                })
3344            })
3345            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
3346
3347        // The capsule-wide block reaches capsule, tool and driver components, none of which
3348        // can be handed the murmur:task-io/read import. Rejected rather than ignored, on the
3349        // same terms as declaring it on a non-hook artifact entry.
3350        if raw
3351            .capabilities
3352            .as_ref()
3353            .is_some_and(|caps| caps.task_io.is_some())
3354        {
3355            return Err(RuntimeManifestError::InvalidCapabilities {
3356                field: "capabilities.task_io".to_string(),
3357                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3358                          capsule-wide capabilities block — the grant is per-hook"
3359                    .to_string(),
3360            });
3361        }
3362        // Config is delivered on one artifact's own grant, so there is no capsule-wide form of
3363        // it: the capsule's guest holds no artifact grant, and a top-level block would reach no
3364        // component at all. Refused rather than ignored, on the same terms as the capsule-wide
3365        // `task_io` block above.
3366        if raw.config.is_some() {
3367            return Err(RuntimeManifestError::InvalidCapabilities {
3368                field: "config".to_string(),
3369                message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
3370                          'runtime: driver' entry that reads it — there is no capsule-wide \
3371                          config block"
3372                    .to_string(),
3373            });
3374        }
3375        let capabilities = parse_capabilities(raw.capabilities)?;
3376        let inference = parse_inference(raw.inference)?;
3377        validate_driver_gateways(&artifacts, inference.as_ref())?;
3378        validate_driver_choices(&artifacts, inference.as_ref())?;
3379        validate_process_driver_entry(&artifacts, inference.as_ref())?;
3380
3381        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
3382        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
3383        if let Some(ref sp_art) = inference
3384            .as_ref()
3385            .and_then(|i| i.system_prompt_artifact.clone())
3386        {
3387            let matching = artifacts.iter().find(|a| &a.name == sp_art);
3388            match matching {
3389                None => {
3390                    return Err(RuntimeManifestError::InvalidInferenceConfig {
3391                        field: "inference.system_prompt_artifact".to_string(),
3392                        message: format!("artifact '{sp_art}' is not declared in artifacts:"),
3393                    });
3394                }
3395                Some(a) if !a.prompt_payload => {
3396                    return Err(RuntimeManifestError::InvalidInferenceConfig {
3397                        field: "inference.system_prompt_artifact".to_string(),
3398                        message: format!(
3399                            "artifact '{sp_art}' (runtime: {}) does not declare \
3400                             'prompt_payload: true', which system_prompt_artifact requires",
3401                            a.runtime.as_str()
3402                        ),
3403                    });
3404                }
3405                Some(_) => {} // valid
3406            }
3407        }
3408        let context = parse_context(raw.context)?;
3409        let observability = parse_observability(raw.observability);
3410        let trace = raw
3411            .trace
3412            .map(|raw_trace| {
3413                let capture = resolve_trace_capture(
3414                    raw_trace.capture.as_deref(),
3415                    raw_trace.include_tool_output,
3416                )?;
3417                let retain = parse_trace_retain(raw_trace.retain)?;
3418                Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
3419            })
3420            .transpose()?;
3421        let network = raw
3422            .network
3423            .map(|n| {
3424                Ok::<_, RuntimeManifestError>(NetworkConfig {
3425                    internal_port: n.internal_port,
3426                    authentication: parse_network_authentication(n.authentication)?,
3427                })
3428            })
3429            .transpose()?;
3430        refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
3431        let lifecycle = raw.lifecycle.map(|raw_lc| {
3432            let defaults = LifecycleConfig::default();
3433            LifecycleConfig {
3434                task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
3435                after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
3436                queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
3437                input_timeout_secs: raw_lc.input_timeout_secs,
3438                conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
3439                max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
3440                shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
3441                delegation_deadline_secs: raw_lc
3442                    .delegation_deadline_secs
3443                    .unwrap_or(defaults.delegation_deadline_secs),
3444            }
3445        });
3446
3447        let exports = parse_exports(raw.exports)?;
3448        let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
3449        validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
3450
3451        Ok(Self {
3452            name,
3453            version,
3454            artifacts,
3455            capabilities,
3456            inference,
3457            context,
3458            observability,
3459            trace,
3460            network,
3461            lifecycle,
3462            exports,
3463            control,
3464            mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
3465            unknown_keys,
3466        })
3467    }
3468}
3469
3470/// Every artifact entry whose `gateway.api_key` is written as `${NAME}`, as `(artifact, NAME)`.
3471fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
3472    artifacts
3473        .iter()
3474        .filter_map(|artifact| {
3475            let name = artifact.name.as_deref()?.trim();
3476            let written = artifact
3477                .gateway
3478                .as_ref()?
3479                .as_ref()?
3480                .api_key
3481                .as_ref()?
3482                .as_ref()?;
3483            let variable = parse_env_reference(written.trim())?;
3484            Some((name.to_string(), variable.to_string()))
3485        })
3486        .collect()
3487}
3488
3489/// Lowers the top-level `control:` block. A block that declares nothing is no block.
3490///
3491/// `gateway_references` is every `(artifact, NAME)` pair an entry's `gateway.api_key: ${NAME}`
3492/// wrote. A secret must back one of them, and must not back the configured `transport: http`
3493/// driver's: that credential is resolved at staging and keys every turn, so a session launched
3494/// without it could make no inference call at all.
3495fn parse_control(
3496    raw: Option<RawControl>,
3497    gateway_references: &[(String, String)],
3498    inference: Option<&InferenceConfig>,
3499) -> Result<Option<ControlConfig>, RuntimeManifestError> {
3500    let Some(raw) = raw else {
3501        return Ok(None);
3502    };
3503    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
3504        field: field.to_string(),
3505        message,
3506    };
3507    let settings_written = control_string_list("control.settings", raw.settings)?;
3508    let secrets_written = control_string_list("control.secrets", raw.secrets)?;
3509    let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
3510    if settings_written.is_empty()
3511        && secrets_written.is_empty()
3512        && agent_settings_written.is_empty()
3513    {
3514        return Ok(None);
3515    }
3516    let Some(inference) = inference else {
3517        return Err(refuse(
3518            "control",
3519            "is declared on a capsule with no inference: block; the control surface is served on \
3520             an agent capsule's A2A listener, and a script capsule opens none"
3521                .to_string(),
3522        ));
3523    };
3524
3525    let mut settings = Vec::new();
3526    for entry in settings_written {
3527        let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
3528            let controllable: Vec<&str> = ControllableSetting::ALL
3529                .iter()
3530                .map(|setting| setting.wire_name())
3531                .collect();
3532            return Err(refuse(
3533                "control.settings",
3534                format!(
3535                    "'{entry}' is not a controllable setting; the controllable settings are: {}",
3536                    controllable.join(", ")
3537                ),
3538            ));
3539        };
3540        if settings.contains(&setting) {
3541            return Err(refuse(
3542                "control.settings",
3543                format!("'{entry}' is listed more than once"),
3544            ));
3545        }
3546        match setting {
3547            ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
3548                return Err(refuse(
3549                    "control.settings",
3550                    format!(
3551                        "'{entry}' is controllable only under inference.transport: http; a \
3552                         transport: {} harness sets its own output cap",
3553                        inference.transport
3554                    ),
3555                ));
3556            }
3557            ControllableSetting::InferenceMaxTokens => {}
3558            ControllableSetting::InferenceDriver => {
3559                check_driver_setting(inference).map_err(|message| {
3560                    refuse("control.settings", format!("'{entry}' {message}"))
3561                })?;
3562            }
3563        }
3564        settings.push(setting);
3565    }
3566
3567    let mut agent_settings = Vec::new();
3568    for entry in agent_settings_written {
3569        let Some(setting) = ControllableSetting::from_wire_name(&entry)
3570            .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
3571        else {
3572            let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
3573                .iter()
3574                .map(|setting| setting.wire_name())
3575                .collect();
3576            return Err(refuse(
3577                "control.agent_settings",
3578                format!(
3579                    "'{entry}' is not a setting the agent may change; control.agent_settings \
3580                     accepts only: {}",
3581                    settable.join(", ")
3582                ),
3583            ));
3584        };
3585        if agent_settings.contains(&setting) {
3586            return Err(refuse(
3587                "control.agent_settings",
3588                format!("'{entry}' is listed more than once"),
3589            ));
3590        }
3591        check_driver_setting(inference)
3592            .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
3593        agent_settings.push(setting);
3594    }
3595
3596    let inference_driver = (inference.transport == "http")
3597        .then(|| {
3598            inference
3599                .driver
3600                .as_ref()
3601                .map(|driver| driver.artifact.as_str())
3602        })
3603        .flatten();
3604    let mut secrets: Vec<String> = Vec::new();
3605    for entry in secrets_written {
3606        if !is_valid_env_variable(&entry) {
3607            return Err(refuse(
3608                "control.secrets",
3609                format!(
3610                    "'{entry}' is not a credential name; a name is uppercase letters, digits and \
3611                     underscores, and does not start with a digit"
3612                ),
3613            ));
3614        }
3615        if secrets.contains(&entry) {
3616            return Err(refuse(
3617                "control.secrets",
3618                format!("'{entry}' is listed more than once"),
3619            ));
3620        }
3621        let backing: Vec<&str> = gateway_references
3622            .iter()
3623            .filter(|(_, variable)| *variable == entry)
3624            .map(|(artifact, _)| artifact.as_str())
3625            .collect();
3626        if backing.is_empty() {
3627            return Err(refuse(
3628                "control.secrets",
3629                format!(
3630                    "'{entry}' is not referenced by any artifact's gateway.api_key as \
3631                     ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
3632                ),
3633            ));
3634        }
3635        if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
3636            return Err(refuse(
3637                "control.secrets",
3638                format!(
3639                    "'{entry}' keys the configured inference driver's gateway \
3640                     (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
3641                     at launch and cannot be supplied by a controller"
3642                ),
3643            ));
3644        }
3645        secrets.push(entry);
3646    }
3647
3648    Ok(Some(ControlConfig {
3649        settings,
3650        secrets,
3651        agent_settings,
3652    }))
3653}
3654
3655/// Why `inference.driver` cannot be listed for `inference`, or `Ok` when it can: only under
3656/// `transport: http`, and only with at least one alternate to select.
3657fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
3658    if inference.transport != "http" {
3659        return Err(format!(
3660            "is controllable only under inference.transport: http; a transport: {} harness owns \
3661             the model it runs",
3662            inference.transport
3663        ));
3664    }
3665    if inference.alternates.is_empty() {
3666        return Err(
3667            "selects among inference.alternates, and this capsule declares none; declare an \
3668             alternate to switch to"
3669                .to_string(),
3670        );
3671    }
3672    Ok(())
3673}
3674
3675/// `value` as a list of strings, trimmed, for `field`. Absent and YAML null are the empty list.
3676fn control_string_list(
3677    field: &str,
3678    value: Option<serde_yaml::Value>,
3679) -> Result<Vec<String>, RuntimeManifestError> {
3680    let refuse = || RuntimeManifestError::InvalidControl {
3681        field: field.to_string(),
3682        message: "must be a list of names".to_string(),
3683    };
3684    let items = match value {
3685        None | Some(serde_yaml::Value::Null) => return Ok(Vec::new()),
3686        Some(serde_yaml::Value::Sequence(items)) => items,
3687        Some(_) => return Err(refuse()),
3688    };
3689    items
3690        .into_iter()
3691        .map(|item| match item {
3692            serde_yaml::Value::String(name) => Ok(name.trim().to_string()),
3693            _ => Err(refuse()),
3694        })
3695        .collect()
3696}
3697
3698/// Lowers `network.authentication`. An absent or null block is a public door.
3699fn parse_network_authentication(
3700    raw: Option<RawNetworkAuthentication>,
3701) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
3702    let Some(raw) = raw else {
3703        return Ok(None);
3704    };
3705    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
3706        field: field.to_string(),
3707        message,
3708    };
3709    let schemes: Vec<&str> = AuthenticationScheme::ALL
3710        .iter()
3711        .map(|scheme| scheme.wire_name())
3712        .collect();
3713    let scheme = match raw.scheme.as_deref().map(str::trim) {
3714        None | Some("") => {
3715            return Err(refuse(
3716                "network.authentication.scheme",
3717                format!("is required; the schemes are: {}", schemes.join(", ")),
3718            ))
3719        }
3720        Some(written) => AuthenticationScheme::ALL
3721            .iter()
3722            .copied()
3723            .find(|scheme| scheme.wire_name() == written)
3724            .ok_or_else(|| {
3725                refuse(
3726                    "network.authentication.scheme",
3727                    format!(
3728                        "'{written}' is not a door authentication scheme; the schemes are: {}",
3729                        schemes.join(", ")
3730                    ),
3731                )
3732            })?,
3733    };
3734
3735    let mut credentials = Vec::new();
3736    for (name, credential) in raw.credentials.unwrap_or_default() {
3737        if name == OPERATOR_CREDENTIAL {
3738            return Err(refuse(
3739                "network.authentication.credentials",
3740                format!(
3741                    "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
3742                     token, which holds every scope"
3743                ),
3744            ));
3745        }
3746        if !is_valid_credential_name(&name) {
3747            return Err(refuse(
3748                "network.authentication.credentials",
3749                format!(
3750                    "'{name}' is not a credential name; a name is a lowercase letter followed by \
3751                     at most {} lowercase letters, digits, '_' or '-'",
3752                    MAX_CREDENTIAL_NAME_LEN - 1
3753                ),
3754            ));
3755        }
3756        let field = format!("network.authentication.credentials.{name}.scopes");
3757        let scopes = door_scope_list(&field, credential.scopes)?;
3758        credentials.push(DoorCredential { name, scopes });
3759    }
3760
3761    Ok(Some(NetworkAuthentication {
3762        scheme,
3763        credentials,
3764    }))
3765}
3766
3767/// `^[a-z][a-z0-9_-]{0,31}$`.
3768fn is_valid_credential_name(name: &str) -> bool {
3769    let mut chars = name.chars();
3770    chars.next().is_some_and(|first| first.is_ascii_lowercase())
3771        && name.len() <= MAX_CREDENTIAL_NAME_LEN
3772        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
3773}
3774
3775/// One credential's `scopes`: required, non-empty, each in [`DOOR_SCOPES`] and listed once.
3776fn door_scope_list(
3777    field: &str,
3778    value: Option<serde_yaml::Value>,
3779) -> Result<Vec<String>, RuntimeManifestError> {
3780    let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
3781        field: field.to_string(),
3782        message,
3783    };
3784    let items = match value {
3785        None | Some(serde_yaml::Value::Null) => {
3786            return Err(refuse(
3787                "is required: a credential reaches only the scopes it lists".to_string(),
3788            ))
3789        }
3790        Some(serde_yaml::Value::Sequence(items)) => items,
3791        Some(_) => return Err(refuse("must be a list of scopes".to_string())),
3792    };
3793    if items.is_empty() {
3794        return Err(refuse(
3795            "lists no scope; a credential must reach at least one".to_string(),
3796        ));
3797    }
3798    let mut scopes: Vec<String> = Vec::new();
3799    for item in items {
3800        let serde_yaml::Value::String(scope) = item else {
3801            return Err(refuse("must be a list of scopes".to_string()));
3802        };
3803        let scope = scope.trim().to_string();
3804        if !DOOR_SCOPES.contains(&scope.as_str()) {
3805            return Err(refuse(format!(
3806                "'{scope}' is not a door scope; the scopes are: {}",
3807                DOOR_SCOPES.join(", ")
3808            )));
3809        }
3810        if scopes.contains(&scope) {
3811            return Err(refuse(format!("'{scope}' is listed more than once")));
3812        }
3813        scopes.push(scope);
3814    }
3815    Ok(scopes)
3816}
3817
3818/// Refuses `network.authentication` on a capsule that declares `capabilities.spawn.allow`.
3819///
3820/// A delegated child posts its completion to its parent's door, and nothing issues a child a token
3821/// for that door, so an authenticated parent would refuse every completion it asked for.
3822fn refuse_authenticated_delegating_parent(
3823    network: Option<&NetworkConfig>,
3824    capabilities: Option<&Capabilities>,
3825) -> Result<(), RuntimeManifestError> {
3826    let authenticated = network.is_some_and(|network| network.authentication.is_some());
3827    let delegates = capabilities
3828        .and_then(|capabilities| capabilities.spawn.as_ref())
3829        .is_some_and(|spawn| !spawn.allow.is_empty());
3830    if authenticated && delegates {
3831        return Err(RuntimeManifestError::InvalidNetworkConfig {
3832            field: "network.authentication".to_string(),
3833            message: "cannot be declared together with capabilities.spawn.allow: a delegated \
3834                      child posts its outcome to this door, and a delegated child cannot yet be \
3835                      issued a token for this door"
3836                .to_string(),
3837        });
3838    }
3839    Ok(())
3840}
3841
3842/// Lowers the top-level `exports:` block, rejecting anything the runtime would otherwise have to
3843/// decide about later.
3844///
3845/// The `root` rules are checked here and never again by a caller: a gateway that "cleans up" a
3846/// path is a gateway that has taken over the containment boundary, so the only legal answer to a
3847/// root naming somewhere outside the workdir is a refusal.
3848fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
3849    let Some(raw_exports) = raw else {
3850        return Ok(None);
3851    };
3852    let files = raw_exports.files.map(parse_file_export).transpose()?;
3853    let peer_files = raw_exports
3854        .peer_files
3855        .map(parse_peer_files_export)
3856        .transpose()?;
3857    Ok(Some(Exports { files, peer_files }))
3858}
3859
3860/// Lowers `exports.peer_files`, on exactly the same terms as [`parse_file_export`]: a root that
3861/// could name somewhere outside the workdir is refused here and never repaired later.
3862///
3863/// `max_ttl` is left as the operator declared it, including absent. Whether absent is legal
3864/// depends on `lifecycle.after_task`, which is a different block of the same manifest and a
3865/// different kind of question — one about what survives teardown rather than about whether a
3866/// value is well-formed. The runtime asks it, at launch, and refuses with `E-CAP-008`.
3867fn parse_peer_files_export(
3868    raw: RawPeerFilesExport,
3869) -> Result<PeerFilesExport, RuntimeManifestError> {
3870    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
3871        field: field.to_string(),
3872        message,
3873    };
3874
3875    let root = raw
3876        .root
3877        .map(|root| root.trim().to_string())
3878        .filter(|root| !root.is_empty())
3879        .ok_or_else(|| {
3880            invalid(
3881                "exports.peer_files.root",
3882                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
3883            )
3884        })?;
3885    check_export_root_shape("exports.peer_files.root", &root)?;
3886
3887    let max_ttl_secs = match raw.max_ttl {
3888        None => None,
3889        Some(value) => {
3890            let text = scalar_text(&value).ok_or_else(|| {
3891                invalid(
3892                    "exports.peer_files.max_ttl",
3893                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
3894                )
3895            })?;
3896            let parsed = parse_duration_secs(&text)
3897                .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
3898            if parsed == 0 {
3899                return Err(invalid(
3900                    "exports.peer_files.max_ttl",
3901                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
3902                ));
3903            }
3904            Some(parsed)
3905        }
3906    };
3907
3908    let max_bytes = match raw.max_bytes {
3909        None => DEFAULT_PEER_FILES_MAX_BYTES,
3910        Some(value) => {
3911            let text = scalar_text(&value).ok_or_else(|| {
3912                invalid(
3913                    "exports.peer_files.max_bytes",
3914                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
3915                )
3916            })?;
3917            let parsed = parse_byte_size(&text)
3918                .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
3919            if parsed == 0 {
3920                return Err(invalid(
3921                    "exports.peer_files.max_bytes",
3922                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
3923                ));
3924            }
3925            parsed
3926        }
3927    };
3928
3929    Ok(PeerFilesExport {
3930        root,
3931        max_ttl_secs,
3932        max_bytes,
3933    })
3934}
3935
3936/// A YAML scalar as the text the byte-size and duration parsers expect, or `None` for a mapping
3937/// or a sequence — a shape neither parser could report on without quoting the whole node.
3938/// `Some` for a key that is present, whatever its value — including an explicit null.
3939///
3940/// The plain `Option<serde_yaml::Value>` a `#[serde(default)]` field gets collapses `retain:`
3941/// with nothing under it into the same `None` an absent key produces, and those two say opposite
3942/// things: absent means "keep everything", and empty is refused.
3943fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_yaml::Value>, D::Error>
3944where
3945    D: serde::Deserializer<'de>,
3946{
3947    serde_yaml::Value::deserialize(deserializer).map(Some)
3948}
3949
3950fn scalar_text(value: &serde_yaml::Value) -> Option<String> {
3951    match value {
3952        serde_yaml::Value::String(text) => Some(text.clone()),
3953        serde_yaml::Value::Number(number) => Some(number.to_string()),
3954        _ => None,
3955    }
3956}
3957
3958/// The shared `root` shape rule: relative, no `..`, no absolute prefix. Both export blocks
3959/// resolve their root against the same accessible workdir, so both refuse the same shapes with
3960/// the same sentence.
3961fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
3962    for component in Path::new(root).components() {
3963        match component {
3964            std::path::Component::Normal(_) | std::path::Component::CurDir => {}
3965            std::path::Component::ParentDir => {
3966                return Err(RuntimeManifestError::InvalidExports {
3967                    field: field.to_string(),
3968                    message: format!(
3969                        "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
3970                    ),
3971                });
3972            }
3973            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
3974                return Err(RuntimeManifestError::InvalidExports {
3975                    field: field.to_string(),
3976                    message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
3977                });
3978            }
3979        }
3980    }
3981    Ok(())
3982}
3983
3984fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
3985    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
3986        field: field.to_string(),
3987        message,
3988    };
3989
3990    let root = raw
3991        .root
3992        .map(|root| root.trim().to_string())
3993        .filter(|root| !root.is_empty())
3994        .ok_or_else(|| {
3995            invalid(
3996                "exports.files.root",
3997                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
3998            )
3999        })?;
4000    check_export_root_shape("exports.files.root", &root)?;
4001
4002    let mode = match raw.mode.as_deref().map(str::trim) {
4003        None | Some("") => {
4004            return Err(invalid(
4005                "exports.files.mode",
4006                "is required and must be 'read-only'".to_string(),
4007            ));
4008        }
4009        Some("read-only") => ExportMode::ReadOnly,
4010        Some(other) => {
4011            return Err(invalid(
4012                "exports.files.mode",
4013                format!("'{other}' must be 'read-only'"),
4014            ));
4015        }
4016    };
4017
4018    let max_bytes = match raw.max_bytes {
4019        None => DEFAULT_EXPORT_MAX_BYTES,
4020        Some(value) => {
4021            let Some(text) = scalar_text(&value) else {
4022                return Err(invalid(
4023                    "exports.files.max_bytes",
4024                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4025                ));
4026            };
4027            let parsed = parse_byte_size(&text)
4028                .map_err(|message| invalid("exports.files.max_bytes", message))?;
4029            if parsed == 0 {
4030                return Err(invalid(
4031                    "exports.files.max_bytes",
4032                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4033                ));
4034            }
4035            parsed
4036        }
4037    };
4038
4039    Ok(FileExport {
4040        root,
4041        mode,
4042        max_bytes,
4043    })
4044}
4045
4046/// The accepted spelling of `exports.files.root`, stated once so every rejection says the same
4047/// thing about what a legal root looks like.
4048const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4049
4050/// The accepted spelling of `capabilities.peer_fetch.allow`, stated once.
4051pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4052
4053/// Structural check on one network destination: an `http`/`https` URL with no path, query or
4054/// fragment, or a bare `host[:port]`.
4055///
4056/// Deliberately shape-only. The authoritative matcher is the runtime's network-rule parser, which
4057/// this crate cannot see; what this catches is the class of entry that could never become a rule
4058/// at all, at the line that wrote it rather than at launch.
4059fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4060    let trimmed = entry.trim();
4061    if trimmed.is_empty() {
4062        return Err(format!(
4063            "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4064        ));
4065    }
4066
4067    if trimmed.contains("://") {
4068        let url = Url::parse(trimmed).map_err(|error| {
4069            format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4070        })?;
4071        let scheme = url.scheme().to_ascii_lowercase();
4072        if scheme != "http" && scheme != "https" {
4073            return Err(format!(
4074                "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4075                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4076            ));
4077        }
4078        if url.host_str().is_none_or(str::is_empty) {
4079            return Err(format!(
4080                "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4081            ));
4082        }
4083        if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4084            return Err(format!(
4085                "'{entry}' must not include a path, query or fragment; \
4086                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4087            ));
4088        }
4089        return Ok(());
4090    }
4091
4092    if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4093        return Err(format!(
4094            "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4095        ));
4096    }
4097    // Split from the right so an IPv6 literal's own colons are not mistaken for a port separator.
4098    if let Some((host, port)) = trimmed.rsplit_once(':') {
4099        if !host.ends_with(']') {
4100            if host.is_empty() {
4101                return Err(format!(
4102                    "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4103                ));
4104            }
4105            if port.parse::<u16>().is_err() {
4106                return Err(format!(
4107                    "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4108                ));
4109            }
4110        }
4111    }
4112    Ok(())
4113}
4114
4115fn parse_capabilities(
4116    raw: Option<RawCapabilities>,
4117) -> Result<Option<Capabilities>, RuntimeManifestError> {
4118    let Some(raw_caps) = raw else {
4119        return Ok(None);
4120    };
4121
4122    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4123        allow: raw_network.allow,
4124        unix_sockets: raw_network.unix_sockets,
4125    });
4126
4127    let peer_fetch = raw_caps
4128        .peer_fetch
4129        .map(|raw_peer_fetch| {
4130            if raw_peer_fetch.allow.is_empty() {
4131                return Err(RuntimeManifestError::InvalidCapabilities {
4132                    field: "capabilities.peer_fetch.allow".to_string(),
4133                    message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4134                });
4135            }
4136            for entry in &raw_peer_fetch.allow {
4137                check_network_destination_shape(entry).map_err(|message| {
4138                    RuntimeManifestError::InvalidCapabilities {
4139                        field: "capabilities.peer_fetch.allow".to_string(),
4140                        message,
4141                    }
4142                })?;
4143            }
4144            Ok(PeerFetchCapabilities {
4145                allow: raw_peer_fetch.allow,
4146            })
4147        })
4148        .transpose()?;
4149
4150    let filesystem = raw_caps
4151        .filesystem
4152        .map(|raw_filesystem| FilesystemCapabilities {
4153            scope: raw_filesystem.scope.and_then(|scope| {
4154                let trimmed = scope.trim();
4155                (!trimmed.is_empty()).then(|| trimmed.to_string())
4156            }),
4157            // Copied straight across: a bool has no empty/whitespace case to normalise, and there
4158            // is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
4159            // refusal when `capabilities.containment: scoped` is also declared) belongs to the
4160            // runtime's containment layer, not to manifest parsing.
4161            workdir_exec: raw_filesystem.workdir_exec,
4162            // Normalised on exactly the terms `scope` above is: trimmed, and an entry left empty
4163            // by that trim is dropped rather than carried as a rule covering nothing. Shape is
4164            // not judged here either — an absolute or escaping entry is the runtime's refusal.
4165            read_only: raw_filesystem
4166                .read_only
4167                .into_iter()
4168                .filter_map(|entry| {
4169                    let trimmed = entry.trim();
4170                    (!trimmed.is_empty()).then(|| trimmed.to_string())
4171                })
4172                .collect(),
4173        });
4174
4175    let shell = raw_caps
4176        .shell
4177        .map(|raw_shell| {
4178            if raw_shell.allow.is_empty() {
4179                return Err(RuntimeManifestError::InvalidCapabilities {
4180                    field: "capabilities.shell.allow".to_string(),
4181                    message: "must contain at least one binary".to_string(),
4182                });
4183            }
4184
4185            let interpreter_runtime =
4186                parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
4187            // Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
4188            // already-validated grant list to test against.
4189            let staged_runtime = parse_staged_runtime(
4190                &raw_shell.allow,
4191                &interpreter_runtime,
4192                raw_shell.staged_runtime,
4193            )?;
4194
4195            Ok(ShellCapabilities {
4196                allow: raw_shell.allow,
4197                strip_env: raw_shell.strip_env,
4198                baseline_env: raw_shell.baseline_env,
4199                interpreter_runtime,
4200                staged_runtime,
4201            })
4202        })
4203        .transpose()?;
4204
4205    let spawn = raw_caps.spawn.map(|raw_spawn| SpawnCapabilities {
4206        allow: raw_spawn.allow,
4207    });
4208
4209    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
4210        allow: raw_env.allow,
4211    });
4212
4213    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
4214
4215    let resources = raw_caps
4216        .resources
4217        .map(parse_resource_capabilities)
4218        .transpose()?;
4219
4220    let state = raw_caps.state.map(parse_state_capabilities);
4221
4222    let task_io = raw_caps
4223        .task_io
4224        .map(|raw_task_io| {
4225            raw_task_io
4226                .read
4227                .map(|read| TaskIoCapabilities { read })
4228                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4229                    field: "capabilities.task_io.read".to_string(),
4230                    message: "must be set explicitly to true or false — a capability is never \
4231                              inferred"
4232                        .to_string(),
4233                })
4234        })
4235        .transpose()?;
4236
4237    let conversation = raw_caps
4238        .conversation
4239        .map(|raw_conversation| {
4240            raw_conversation
4241                .read
4242                .map(|read| ConversationCapabilities { read })
4243                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4244                    field: "capabilities.conversation.read".to_string(),
4245                    message: "must be set explicitly to true or false — a capability is never \
4246                              inferred"
4247                        .to_string(),
4248                })
4249        })
4250        .transpose()?;
4251
4252    let plan = raw_caps
4253        .plan
4254        .map(|raw_plan| {
4255            raw_plan
4256                .submit
4257                .map(|submit| PlanCapabilities { submit })
4258                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4259                    field: "capabilities.plan.submit".to_string(),
4260                    message: "must be set explicitly to true or false — a capability is never \
4261                              inferred"
4262                        .to_string(),
4263                })
4264        })
4265        .transpose()?;
4266
4267    let install = raw_caps.install.map(parse_install).transpose()?;
4268
4269    let containment = raw_caps
4270        .containment
4271        .as_deref()
4272        .map(str::trim)
4273        .filter(|value| !value.is_empty())
4274        .map(|value| {
4275            value.parse::<ContainmentClass>().map_err(|error| {
4276                RuntimeManifestError::InvalidCapabilities {
4277                    field: "capabilities.containment".to_string(),
4278                    message: error.to_string(),
4279                }
4280            })
4281        })
4282        .transpose()?;
4283
4284    Ok(Some(Capabilities {
4285        network,
4286        peer_fetch,
4287        filesystem,
4288        shell,
4289        spawn,
4290        env,
4291        limits,
4292        resources,
4293        state,
4294        task_io,
4295        conversation,
4296        plan,
4297        install,
4298        containment,
4299    }))
4300}
4301
4302/// Lower `capabilities.install`, refusing a block that names nothing and any entry outside
4303/// [`crate::install_pattern`]'s grammar.
4304fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
4305    if raw.skill.is_empty() && raw.tool.is_empty() {
4306        return Err(RuntimeManifestError::InvalidCapabilities {
4307            field: "capabilities.install".to_string(),
4308            message: "must name at least one 'skill' or 'tool' entry — a capability is never \
4309                      inferred; omit the block to grant no install at all"
4310                .to_string(),
4311        });
4312    }
4313    for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
4314        for entry in entries {
4315            crate::install_pattern::validate(entry).map_err(|reason| {
4316                RuntimeManifestError::InvalidCapabilities {
4317                    field: format!("capabilities.install.{key}"),
4318                    message: format!(
4319                        "entry {entry:?} is invalid ({reason}); expected {}",
4320                        crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
4321                    ),
4322                }
4323            })?;
4324        }
4325    }
4326    Ok(InstallCapabilities {
4327        skill: raw.skill,
4328        tool: raw.tool,
4329    })
4330}
4331
4332/// Lower `capabilities.state`. Infallible: the only field is a name, and a name's *shape* is a
4333/// runtime question (does it resolve to one directory under `~/.murmur/state/`?) rather than a
4334/// manifest-parse one — `capsule_runtime::state_store::validate_store_name` answers it and refuses
4335/// with `E-CAP-009`.
4336///
4337/// Surrounding whitespace is trimmed but an emptied value is *kept*, unlike
4338/// `capabilities.filesystem.scope`, which normalises empty to absent. `store: ""` is a written
4339/// declaration that names nothing, and silently defaulting it to the capsule name would hand a
4340/// store to someone who asked for a different one; it reaches the runtime and is refused there.
4341fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
4342    StateCapabilities {
4343        store: raw.store.map(|store| store.trim().to_string()),
4344    }
4345}
4346
4347/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
4348/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
4349/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
4350/// expand a whole install prefix: each grant names specific directories, and each directory
4351/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
4352fn parse_interpreter_runtime(
4353    allow: &[String],
4354    raw_grants: Vec<RawInterpreterRuntimeGrant>,
4355) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
4356    let mut grants = Vec::with_capacity(raw_grants.len());
4357    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4358        let base = format!("capabilities.shell.interpreter_runtime[{index}]");
4359
4360        let binary = raw_grant
4361            .binary
4362            .filter(|b| !b.trim().is_empty())
4363            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4364                field: format!("{base}.binary"),
4365                message: "must name a binary".to_string(),
4366            })?;
4367
4368        // The whole point of this mechanism: it narrows filesystem access alongside an exec
4369        // grant that already exists. It can never itself grant exec, so the binary must already
4370        // be allowlisted in this same block's `allow`.
4371        if !allow.contains(&binary) {
4372            return Err(RuntimeManifestError::InvalidCapabilities {
4373                field: format!("{base}.binary"),
4374                message: format!(
4375                    "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
4376                     only narrow filesystem access for an already-allowlisted binary, never \
4377                     grant exec"
4378                ),
4379            });
4380        }
4381
4382        if raw_grant.dirs.is_empty() {
4383            return Err(RuntimeManifestError::InvalidCapabilities {
4384                field: format!("{base}.dirs"),
4385                message: "must name at least one directory".to_string(),
4386            });
4387        }
4388
4389        let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
4390        for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
4391            let dir_base = format!("{base}.dirs[{dir_index}]");
4392
4393            let path = raw_dir
4394                .path
4395                .filter(|p| !p.trim().is_empty())
4396                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4397                    field: format!("{dir_base}.path"),
4398                    message: "must name a host directory".to_string(),
4399                })?;
4400            if !path.starts_with('/') {
4401                return Err(RuntimeManifestError::InvalidCapabilities {
4402                    field: format!("{dir_base}.path"),
4403                    message: format!(
4404                        "'{path}' must be an absolute host path (start with '/') — these are \
4405                         filesystem paths outside the workdir"
4406                    ),
4407                });
4408            }
4409
4410            let list_dir =
4411                raw_dir
4412                    .list_dir
4413                    .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4414                        field: format!("{dir_base}.list_dir"),
4415                        message: format!(
4416                            "'{path}' must set list_dir explicitly to true or false — \
4417                         enumerability is never inferred"
4418                        ),
4419                    })?;
4420
4421            dirs.push(InterpreterRuntimeDir { path, list_dir });
4422        }
4423
4424        grants.push(InterpreterRuntimeGrant { binary, dirs });
4425    }
4426
4427    Ok(grants)
4428}
4429
4430/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
4431/// and its already-validated `interpreter_runtime` grants. Every rejection is a
4432/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
4433/// [`parse_interpreter_runtime`] and the rest of this file.
4434///
4435/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
4436/// loudly wrong here:
4437///
4438///   1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
4439///   2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
4440///      and declaring both means the author expects a host-widening grant that a composed root
4441///      makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
4442///   3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
4443///      launch host and not relative to anything the runtime knows.
4444///   4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
4445///      so an absent one defeats the field's only purpose.
4446///
4447/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
4448/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
4449/// will never run it (`mur build` on a laptop for a Linux fleet).
4450fn parse_staged_runtime(
4451    allow: &[String],
4452    interpreter_runtime: &[InterpreterRuntimeGrant],
4453    raw_grants: Vec<RawStagedRuntimeGrant>,
4454) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
4455    let mut grants = Vec::with_capacity(raw_grants.len());
4456    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4457        let base = format!("capabilities.shell.staged_runtime[{index}]");
4458
4459        let binary = raw_grant
4460            .binary
4461            .filter(|b| !b.trim().is_empty())
4462            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4463                field: format!("{base}.binary"),
4464                message: "must name a binary".to_string(),
4465            })?;
4466
4467        if !allow.contains(&binary) {
4468            return Err(RuntimeManifestError::InvalidCapabilities {
4469                field: format!("{base}.binary"),
4470                message: format!(
4471                    "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
4472                     stage the runtime tree behind an already-allowlisted binary, never grant exec"
4473                ),
4474            });
4475        }
4476
4477        if interpreter_runtime
4478            .iter()
4479            .any(|grant| grant.binary == binary)
4480        {
4481            return Err(RuntimeManifestError::InvalidCapabilities {
4482                field: format!("{base}.binary"),
4483                message: format!(
4484                    "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
4485                     are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
4486                     tree into the capsule's own root, which is what makes widening the capsule's \
4487                     host filesystem scope unnecessary rather than something to add to it. Remove \
4488                     the interpreter_runtime grant for '{binary}'"
4489                ),
4490            });
4491        }
4492
4493        let source_path = raw_grant
4494            .source_path
4495            .filter(|p| !p.trim().is_empty())
4496            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4497                field: format!("{base}.source_path"),
4498                message: "must name the host directory holding the pinned runtime tree".to_string(),
4499            })?;
4500        if !source_path.starts_with('/') {
4501            return Err(RuntimeManifestError::InvalidCapabilities {
4502                field: format!("{base}.source_path"),
4503                message: format!(
4504                    "'{source_path}' must be an absolute host path (start with '/') — \
4505                     staged-runtime sources are host filesystem paths outside the workdir"
4506                ),
4507            });
4508        }
4509
4510        let pin = raw_grant
4511            .pin
4512            .map(|p| p.trim().to_string())
4513            .filter(|p| !p.is_empty())
4514            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4515                field: format!("{base}.pin"),
4516                message: format!(
4517                    "'{source_path}' must declare a non-empty pin identifying which build this \
4518                     tree is — it is never inferred, because it is the value a human compares \
4519                     across two hosts to confirm the same runtime shipped to both"
4520                ),
4521            })?;
4522
4523        grants.push(StagedRuntimeGrant {
4524            binary,
4525            source_path,
4526            pin,
4527        });
4528    }
4529
4530    Ok(grants)
4531}
4532
4533/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
4534/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
4535/// catching it here means `mur run` fails at parse time with a field name rather than
4536/// surfacing much later as an opaque wasm trap.
4537fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
4538    let reject_zero_usize =
4539        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
4540            match value {
4541                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
4542                    field: format!("capabilities.limits.{field}"),
4543                    message: "must be greater than zero".to_string(),
4544                }),
4545                _ => Ok(()),
4546            }
4547        };
4548
4549    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
4550    reject_zero_usize(raw.table_elements, "table_elements")?;
4551    reject_zero_usize(raw.instances, "instances")?;
4552    if raw.deadline_seconds == Some(0) {
4553        return Err(RuntimeManifestError::InvalidCapabilities {
4554            field: "capabilities.limits.deadline_seconds".to_string(),
4555            message: "must be greater than zero".to_string(),
4556        });
4557    }
4558
4559    Ok(ResourceLimits {
4560        memory_bytes: raw.memory_bytes,
4561        table_elements: raw.table_elements,
4562        instances: raw.instances,
4563        deadline_seconds: raw.deadline_seconds,
4564    })
4565}
4566
4567/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
4568/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
4569/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
4570/// never what an author means, and letting it through would turn a typo into a subprocess that
4571/// cannot fork, open a file, or run for a single CPU-second.
4572fn parse_resource_capabilities(
4573    raw: RawResourceCapabilities,
4574) -> Result<ResourceCapabilities, RuntimeManifestError> {
4575    let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
4576        match value {
4577            Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
4578                field: format!("capabilities.resources.{field}"),
4579                message: "must be greater than zero".to_string(),
4580            }),
4581            _ => Ok(()),
4582        }
4583    };
4584
4585    reject_zero(raw.max_processes, "max_processes")?;
4586    reject_zero(raw.max_open_files, "max_open_files")?;
4587    reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
4588    reject_zero(raw.cpu_seconds, "cpu_seconds")?;
4589    reject_zero(raw.memory_bytes, "memory_bytes")?;
4590    reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
4591    reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
4592    reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
4593    reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
4594    reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
4595
4596    Ok(ResourceCapabilities {
4597        max_processes: raw.max_processes,
4598        max_open_files: raw.max_open_files,
4599        max_file_size_bytes: raw.max_file_size_bytes,
4600        cpu_seconds: raw.cpu_seconds,
4601        memory_bytes: raw.memory_bytes,
4602        cgroup_memory_bytes: raw.cgroup_memory_bytes,
4603        cgroup_pids_max: raw.cgroup_pids_max,
4604        cgroup_cpu_percent: raw.cgroup_cpu_percent,
4605        cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
4606        workdir_max_bytes: raw.workdir_max_bytes,
4607    })
4608}
4609
4610/// Why a `transport: process` manifest with no `inference.driver` is refused. Names one driver
4611/// artifact by way of example: this is the one message a reader reaches with nothing to go on, and
4612/// an abstract instruction to "name a process driver" leaves them no way to find one.
4613const PROCESS_DRIVER_REQUIRED: &str =
4614    "missing required field; a transport: process capsule must name the process driver that \
4615     knows how to drive its harness. inference.command no longer selects a harness — it only \
4616     overrides the binary the driver names. Add inference.driver.artifact naming a process \
4617     driver, for example murmur-driver-claude-code";
4618
4619fn parse_inference(
4620    raw: Option<RawInferenceConfig>,
4621) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
4622    let Some(raw) = raw else {
4623        return Ok(None);
4624    };
4625
4626    let transport = raw
4627        .transport
4628        .map(|s| s.trim().to_string())
4629        .filter(|s| !s.is_empty())
4630        .unwrap_or_else(|| "http".to_string());
4631
4632    let compaction = parse_compaction(raw.compaction)?;
4633    let system_prompt = optional_trimmed_string(raw.system_prompt);
4634    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
4635    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
4636
4637    let prompt_sources_set = [
4638        system_prompt.is_some(),
4639        system_prompt_file.is_some(),
4640        system_prompt_artifact.is_some(),
4641    ]
4642    .iter()
4643    .filter(|&&b| b)
4644    .count();
4645    if prompt_sources_set > 1 {
4646        return Err(RuntimeManifestError::InvalidInferenceConfig {
4647            field: "inference.system_prompt".to_string(),
4648            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
4649                      may be set"
4650                .to_string(),
4651        });
4652    }
4653
4654    let max_turns = match raw.max_turns {
4655        None => 10,
4656        Some(0) => {
4657            return Err(RuntimeManifestError::InvalidInferenceConfig {
4658                field: "inference.max_turns".to_string(),
4659                message: "must be greater than 0".to_string(),
4660            });
4661        }
4662        Some(n) => n,
4663    };
4664
4665    // The reopen budget is a lifecycle knob, read from `lifecycle.max_task_reopens`. Nothing
4666    // here consumes the `inference` spelling, so it would be silently inert — reject it rather
4667    // than let it look effective.
4668    if raw.max_task_reopens.is_some() {
4669        return Err(RuntimeManifestError::InvalidInferenceConfig {
4670            field: "inference.max_task_reopens".to_string(),
4671            message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
4672                .to_string(),
4673        });
4674    }
4675
4676    // Left to the unknown-key pass, either field would only draw a warning and the capsule would
4677    // launch with no upstream or credential bound, so each is refused by name, pointing at the
4678    // driver entry's `gateway:` block. Nothing translates it.
4679    for (present, removed, moved_to) in [
4680        (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
4681        (raw.api_key.is_some(), "api_key", "gateway.api_key"),
4682    ] {
4683        if !present {
4684            continue;
4685        }
4686        let entry = raw
4687            .driver
4688            .as_ref()
4689            .or(raw.provider.as_ref())
4690            .and_then(|driver| driver.artifact.as_deref())
4691            .map(str::trim)
4692            .filter(|name| !name.is_empty())
4693            .map(|name| format!("on the artifacts: entry '{name}'"))
4694            .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
4695        return Err(RuntimeManifestError::InvalidInferenceConfig {
4696            field: format!("inference.{removed}"),
4697            message: format!("was removed; set {moved_to} {entry} instead"),
4698        });
4699    }
4700
4701    match transport.as_str() {
4702        "http" => {
4703            let model = required_inference_field(raw.model, "model")?;
4704
4705            let driver_raw = raw.driver.or(raw.provider).ok_or_else(|| {
4706                RuntimeManifestError::InvalidInferenceConfig {
4707                    field: "inference.driver".to_string(),
4708                    message: "missing required field".to_string(),
4709                }
4710            })?;
4711            let driver = parse_inference_driver(driver_raw)?;
4712
4713            // Advisory only: catch an authoring typo at parse time rather than as a confusing
4714            // provider 400. Large values are deliberately NOT clamped — a ceiling here would
4715            // block a model whose real limit is higher than anything we could hard-code.
4716            if raw.max_tokens == Some(0) {
4717                return Err(RuntimeManifestError::InvalidInferenceConfig {
4718                    field: "inference.max_tokens".to_string(),
4719                    message: "must be greater than 0".to_string(),
4720                });
4721            }
4722            if raw.max_session_tokens == Some(0) {
4723                return Err(RuntimeManifestError::InvalidInferenceConfig {
4724                    field: "inference.max_session_tokens".to_string(),
4725                    message: "must be greater than 0".to_string(),
4726                });
4727            }
4728            let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
4729            let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
4730
4731            Ok(Some(InferenceConfig {
4732                transport,
4733                model,
4734                driver: Some(driver),
4735                command: None,
4736                compaction,
4737                system_prompt,
4738                system_prompt_file,
4739                system_prompt_artifact,
4740                max_turns,
4741                max_tokens: raw.max_tokens,
4742                max_session_tokens: raw.max_session_tokens,
4743                tool_refresh,
4744                alternates,
4745            }))
4746        }
4747        "process" => {
4748            if raw.provider.is_some() {
4749                return Err(RuntimeManifestError::InvalidInferenceConfig {
4750                    field: "inference.provider.artifact".to_string(),
4751                    message: "is not valid with transport: process; name the process driver \
4752                              under inference.driver"
4753                        .to_string(),
4754                });
4755            }
4756            // The CLI subprocess path never builds a driver payload, so this value would be
4757            // silently inert here — reject it rather than let it look effective.
4758            if raw.max_tokens.is_some() {
4759                return Err(RuntimeManifestError::InvalidInferenceConfig {
4760                    field: "inference.max_tokens".to_string(),
4761                    message: "is not valid with transport: process".to_string(),
4762                });
4763            }
4764            // The harness owns its tool list and runs its own compaction, so neither trigger has a
4765            // turn boundary here to act on.
4766            if raw.tool_refresh.is_some() {
4767                return Err(RuntimeManifestError::InvalidInferenceConfig {
4768                    field: "inference.tool_refresh".to_string(),
4769                    message: "is not valid with transport: process".to_string(),
4770                });
4771            }
4772            // The harness owns the conversation and the model it runs, so there is no agent-loop
4773            // call for a switch to reroute.
4774            if raw.alternates.is_some() {
4775                return Err(RuntimeManifestError::InvalidInferenceConfig {
4776                    field: "inference.alternates".to_string(),
4777                    message: "is not valid with transport: process; the harness owns the \
4778                              conversation and the model it runs"
4779                        .to_string(),
4780                });
4781            }
4782
4783            // Valid here: the harness's own reported token counts are what this transport's
4784            // turns are metered against, and a driver that reports none is refused at load
4785            // rather than run for ever beneath a ceiling nothing could reach.
4786            if raw.max_session_tokens == Some(0) {
4787                return Err(RuntimeManifestError::InvalidInferenceConfig {
4788                    field: "inference.max_session_tokens".to_string(),
4789                    message: "must be greater than 0".to_string(),
4790                });
4791            }
4792
4793            let driver = raw.driver.map(parse_inference_driver).transpose()?;
4794            let command = optional_trimmed_string(raw.command);
4795            // The driver is what knows how to drive a harness CLI: its flags, its output format,
4796            // its version probe. `command` only says which executable to run instead of the one
4797            // the driver names, so it selects nothing on its own.
4798            if driver.is_none() {
4799                return Err(RuntimeManifestError::InvalidInferenceConfig {
4800                    field: "inference.driver.artifact".to_string(),
4801                    message: PROCESS_DRIVER_REQUIRED.to_string(),
4802                });
4803            }
4804            // model is OPTIONAL for transport: process — an empty string leaves the choice of
4805            // model to the harness, which has an account default of its own. The driver is handed
4806            // `none` rather than an empty string, so it never passes a model flag with no value.
4807            let model = optional_trimmed_string(raw.model).unwrap_or_default();
4808
4809            Ok(Some(InferenceConfig {
4810                transport,
4811                model,
4812                driver,
4813                command,
4814                compaction,
4815                system_prompt,
4816                system_prompt_file,
4817                system_prompt_artifact,
4818                max_turns,
4819                max_tokens: None,
4820                max_session_tokens: raw.max_session_tokens,
4821                tool_refresh: ToolRefresh::default(),
4822                alternates: Vec::new(),
4823            }))
4824        }
4825        other => Err(RuntimeManifestError::InvalidInferenceConfig {
4826            field: "inference.transport".to_string(),
4827            message: format!("unknown value '{other}'"),
4828        }),
4829    }
4830}
4831
4832/// Whether `name` is an alternate's name: a lowercase letter, then up to 62 lowercase letters,
4833/// digits and hyphens.
4834fn is_valid_driver_choice_name(name: &str) -> bool {
4835    let mut chars = name.chars();
4836    chars.next().is_some_and(|first| first.is_ascii_lowercase())
4837        && name.len() <= 63
4838        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
4839}
4840
4841/// Lowers `inference.alternates` under `transport: http`, with the rules one entry and the list
4842/// can be judged on alone. What needs `artifacts:` is [`validate_driver_choices`]'s.
4843///
4844/// `primary_driver` and `primary_model` are the primary choice's pair, which no alternate may
4845/// repeat: two names for one pair would make a switch between them a change of nothing.
4846fn parse_alternates(
4847    raw: Option<Vec<RawInferenceAlternate>>,
4848    primary_driver: &str,
4849    primary_model: &str,
4850) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
4851    let mut alternates: Vec<InferenceAlternate> = Vec::new();
4852    for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
4853        let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
4854            field: if key.is_empty() {
4855                format!("inference.alternates[{index}]")
4856            } else {
4857                format!("inference.alternates[{index}].{key}")
4858            },
4859            message,
4860        };
4861        let name = optional_trimmed_string(entry.name)
4862            .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
4863        if name == PRIMARY_DRIVER_CHOICE {
4864            return Err(refuse(
4865                "name",
4866                format!(
4867                    "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
4868                     inference.model; name the alternate something else"
4869                ),
4870            ));
4871        }
4872        if !is_valid_driver_choice_name(&name) {
4873            return Err(refuse(
4874                "name",
4875                format!(
4876                    "'{name}' is not a driver choice name; a name is a lowercase letter followed \
4877                     by up to 62 lowercase letters, digits and hyphens"
4878                ),
4879            ));
4880        }
4881        if alternates.iter().any(|alternate| alternate.name == name) {
4882            return Err(refuse(
4883                "name",
4884                format!("'{name}' names more than one alternate"),
4885            ));
4886        }
4887        let model = optional_trimmed_string(entry.model)
4888            .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
4889        let Some(driver) = entry.driver else {
4890            return Err(refuse(
4891                "driver.artifact",
4892                "missing required field".to_string(),
4893            ));
4894        };
4895        if driver
4896            .config
4897            .as_ref()
4898            .is_some_and(|config| !matches!(config, serde_yaml::Value::Null))
4899        {
4900            return Err(refuse(
4901                "driver.config",
4902                "is not valid on an alternate; inference.driver.config applies to every \
4903                 declared driver, and per-driver settings belong in that driver's \
4904                 artifacts[].config"
4905                    .to_string(),
4906            ));
4907        }
4908        let driver = optional_trimmed_string(driver.artifact)
4909            .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
4910        if driver == primary_driver && model == primary_model {
4911            return Err(refuse(
4912                "",
4913                format!(
4914                    "'{name}' is driver '{driver}' with model '{model}', which is the primary \
4915                     choice; an alternate names a different pair"
4916                ),
4917            ));
4918        }
4919        if let Some(twin) = alternates
4920            .iter()
4921            .find(|alternate| alternate.driver == driver && alternate.model == model)
4922        {
4923            return Err(refuse(
4924                "",
4925                format!(
4926                    "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
4927                     already is; an alternate names a different pair",
4928                    twin.name
4929                ),
4930            ));
4931        }
4932        alternates.push(InferenceAlternate {
4933            name,
4934            model,
4935            driver,
4936        });
4937    }
4938    Ok(alternates)
4939}
4940
4941/// `inference.tool_refresh` under `transport: http`: absent is [`ToolRefresh::Compaction`].
4942fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
4943    let Some(value) = raw else {
4944        return Ok(ToolRefresh::default());
4945    };
4946    ToolRefresh::ALL
4947        .into_iter()
4948        .find(|refresh| refresh.wire_name() == value)
4949        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
4950            field: "inference.tool_refresh".to_string(),
4951            message: format!(
4952                "must be one of: {}; got '{value}'",
4953                ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
4954            ),
4955        })
4956}
4957
4958fn parse_context(
4959    raw: Option<RawContextConfig>,
4960) -> Result<Option<ContextConfig>, RuntimeManifestError> {
4961    let Some(raw) = raw else {
4962        return Ok(None);
4963    };
4964
4965    if let Some(max_tokens) = raw.max_tokens {
4966        if max_tokens == 0 {
4967            return Err(RuntimeManifestError::InvalidInferenceConfig {
4968                field: "context.max_tokens".to_string(),
4969                message: "must be greater than 0".to_string(),
4970            });
4971        }
4972    }
4973
4974    let fraction = |field: &str, value: Option<f32>, default: f32| match value {
4975        None => Ok(default),
4976        Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
4977        Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
4978            field: field.to_string(),
4979            message: "must be between 0.0 and 1.0".to_string(),
4980        }),
4981    };
4982
4983    let record = match raw.record.as_deref().map(str::trim) {
4984        None => true,
4985        Some("on") => true,
4986        Some("off") => false,
4987        Some(other) => {
4988            return Err(RuntimeManifestError::InvalidInferenceConfig {
4989                field: "context.record".to_string(),
4990                message: format!("unknown value '{other}'; expected: on, off"),
4991            })
4992        }
4993    };
4994
4995    Ok(Some(ContextConfig {
4996        max_tokens: raw.max_tokens,
4997        record,
4998        record_store: raw.record_store.map(|store| store.trim().to_string()),
4999        seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
5000        seed_overflow_margin: fraction(
5001            "context.seed_overflow_margin",
5002            raw.seed_overflow_margin,
5003            DEFAULT_SEED_OVERFLOW_MARGIN,
5004        )?,
5005        retain: parse_context_retain(raw.retain)?,
5006    }))
5007}
5008
5009/// The keys `trace.retain` accepts, in the order an error lists them.
5010const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
5011
5012/// The keys `context.retain` accepts, in the order an error lists them.
5013const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
5014
5015/// What both `retain:` blocks say when they carry nothing. Stated once so the two errors, which
5016/// travel through different `RuntimeManifestError` variants, cannot drift apart.
5017const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
5018                                  keep everything, which is what no policy means";
5019
5020/// One `retain:` block, reduced to its two recognized keys.
5021///
5022/// Shared by `trace.retain` and `context.retain`: they differ only in the name of the count key,
5023/// so the shape check, the unknown-key refusal and the empty-block refusal are written once.
5024/// `count_key` is `max_sessions` or `max_messages`; `block` is the dotted path an error names.
5025fn parse_retain_block(
5026    block: &str,
5027    count_key: &str,
5028    accepted: &[&str],
5029    raw: serde_yaml::Value,
5030    invalid: &dyn Fn(String, String) -> RuntimeManifestError,
5031) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
5032    let mapping = match raw {
5033        serde_yaml::Value::Mapping(mapping) => mapping,
5034        // `retain:` with nothing under it parses as null and means exactly `retain: {}`.
5035        serde_yaml::Value::Null => {
5036            return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
5037        }
5038        other => {
5039            return Err(invalid(
5040                block.to_string(),
5041                format!(
5042                    "must be a block of keys ({}), got '{other:?}'",
5043                    accepted.join(", ")
5044                ),
5045            ))
5046        }
5047    };
5048    if mapping.is_empty() {
5049        return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
5050    }
5051
5052    let mut count: Option<u32> = None;
5053    let mut max_age_secs: Option<u64> = None;
5054    for (key, value) in &mapping {
5055        let key = key.as_str().unwrap_or_default();
5056        let field = format!("{block}.{key}");
5057        if key == count_key {
5058            let parsed = value
5059                .as_u64()
5060                .and_then(|n| u32::try_from(n).ok())
5061                .ok_or_else(|| {
5062                    invalid(
5063                        field.clone(),
5064                        "must be a whole number of at least 1".to_string(),
5065                    )
5066                })?;
5067            if parsed == 0 {
5068                return Err(invalid(
5069                    field,
5070                    "must be at least 1 — a limit of zero would delete what retention exists \
5071                     to keep; remove the key to leave it unbounded"
5072                        .to_string(),
5073                ));
5074            }
5075            count = Some(parsed);
5076        } else if key == "max_age" {
5077            let text = scalar_text(value).ok_or_else(|| {
5078                invalid(
5079                    field.clone(),
5080                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
5081                )
5082            })?;
5083            let parsed =
5084                parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
5085            if parsed == 0 {
5086                return Err(invalid(
5087                    field,
5088                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
5089                ));
5090            }
5091            max_age_secs = Some(parsed);
5092        } else {
5093            return Err(invalid(
5094                block.to_string(),
5095                format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
5096            ));
5097        }
5098    }
5099    Ok((count, max_age_secs))
5100}
5101
5102fn parse_trace_retain(
5103    raw: Option<serde_yaml::Value>,
5104) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
5105    let Some(raw) = raw else {
5106        return Ok(None);
5107    };
5108    let (max_sessions, max_age_secs) = parse_retain_block(
5109        "trace.retain",
5110        "max_sessions",
5111        &TRACE_RETAIN_KEYS,
5112        raw,
5113        &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
5114    )?;
5115    Ok(Some(TraceRetainConfig {
5116        max_sessions,
5117        max_age_secs,
5118    }))
5119}
5120
5121fn parse_context_retain(
5122    raw: Option<serde_yaml::Value>,
5123) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
5124    let Some(raw) = raw else {
5125        return Ok(None);
5126    };
5127    let (max_messages, max_age_secs) = parse_retain_block(
5128        "context.retain",
5129        "max_messages",
5130        &CONTEXT_RETAIN_KEYS,
5131        raw,
5132        &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
5133    )?;
5134    Ok(Some(ContextRetainConfig {
5135        max_messages,
5136        max_age_secs,
5137    }))
5138}
5139
5140fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
5141    raw.map(|raw| ObservabilityConfig {
5142        otel_endpoint: raw
5143            .otel_endpoint
5144            .map(|value| value.trim().to_string())
5145            .filter(|value| !value.is_empty()),
5146        eval: raw.eval.map(parse_eval_config),
5147    })
5148}
5149
5150fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
5151    let scorers = raw
5152        .scorers
5153        .into_iter()
5154        .filter_map(|raw| {
5155            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
5156            match raw.scorer_type.as_str() {
5157                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
5158                "max_turns" => Some(ScorerConfig::MaxTurns {
5159                    name,
5160                    max: raw.max.unwrap_or(10) as u32,
5161                }),
5162                "max_tokens" => Some(ScorerConfig::MaxTokens {
5163                    name,
5164                    max: raw.max.unwrap_or(100_000),
5165                }),
5166                "tool_sequence" => Some(ScorerConfig::ToolSequence {
5167                    name,
5168                    expected: raw.expected.unwrap_or_default(),
5169                }),
5170                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
5171                other => {
5172                    eprintln!("[murmur-artifact] unknown scorer type '{other}' — skipping");
5173                    None
5174                }
5175            }
5176        })
5177        .collect();
5178
5179    EvalConfig {
5180        dataset_id: raw.dataset_id,
5181        scorers,
5182    }
5183}
5184
5185fn parse_compaction(
5186    raw: Option<RawCompactionConfig>,
5187) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
5188    let Some(raw) = raw else {
5189        return Ok(None);
5190    };
5191
5192    if let Some(threshold) = raw.threshold {
5193        if threshold <= 0.0 || threshold > 1.0 {
5194            return Err(RuntimeManifestError::InvalidInferenceConfig {
5195                field: "inference.compaction.threshold".to_string(),
5196                message: format!("must be in (0.0, 1.0] but got {threshold}"),
5197            });
5198        }
5199    }
5200
5201    if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
5202        return Err(RuntimeManifestError::InvalidInferenceConfig {
5203            field: "inference.compaction.system_prompt".to_string(),
5204            message: "at most one of inference.compaction.system_prompt, \
5205                      inference.compaction.system_prompt_file may be set"
5206                .to_string(),
5207        });
5208    }
5209
5210    Ok(Some(CompactionConfig {
5211        threshold: raw.threshold,
5212        model: raw.model,
5213        system_prompt: raw.system_prompt,
5214        system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
5215        // A plain optional boolean: nothing to validate, and deliberately independent of the
5216        // threshold-range and prompt-source checks above.
5217        dump_summaries: raw.dump_summaries,
5218    }))
5219}
5220
5221fn optional_trimmed_string(value: Option<String>) -> Option<String> {
5222    value
5223        .map(|s| s.trim().to_string())
5224        .filter(|s| !s.is_empty())
5225}
5226
5227fn required_inference_field(
5228    value: Option<String>,
5229    key: &str,
5230) -> Result<String, RuntimeManifestError> {
5231    value
5232        .map(|value| value.trim().to_string())
5233        .filter(|value| !value.is_empty())
5234        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5235            field: format!("inference.{key}"),
5236            message: "missing required field".to_string(),
5237        })
5238}
5239
5240/// One artifact entry's `gateway:` block, checked and with its key reference kept unresolved.
5241///
5242/// The credential rule reads what the manifest wrote, never a resolved value, so it decides the
5243/// same under [`SecretResolution::Resolve`] and [`SecretResolution::Skip`]: a non-blank `api_key`
5244/// is keyed, `keyless: true` with no `api_key` key is keyless, both together is
5245/// [`RuntimeManifestError::InvalidArtifact`], and anything else is
5246/// [`RuntimeManifestError::GatewayWithoutCredential`]. The endpoint's address plays no part.
5247fn parse_artifact_gateway(
5248    index: usize,
5249    name: &str,
5250    raw: Option<RawArtifactGateway>,
5251    secrets: SecretResolution,
5252) -> Result<ArtifactGateway, RuntimeManifestError> {
5253    let (endpoint, api_key, keyless) = match raw {
5254        Some(raw) => (
5255            optional_trimmed_string(raw.endpoint),
5256            raw.api_key,
5257            raw.keyless,
5258        ),
5259        None => (None, None, None),
5260    };
5261    let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
5262        index,
5263        message: format!(
5264            "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
5265             the upstream URL the runtime sends this artifact's keyed requests to"
5266        ),
5267    })?;
5268    validate_gateway_endpoint(name, &endpoint)
5269        .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
5270
5271    let keyless = match keyless {
5272        None | Some(serde_yaml::Value::Null) | Some(serde_yaml::Value::Bool(false)) => false,
5273        Some(serde_yaml::Value::Bool(true)) => true,
5274        Some(_) => {
5275            return Err(RuntimeManifestError::InvalidArtifact {
5276                index,
5277                message: format!(
5278                    "artifact '{name}' declares gateway.keyless with a value that is not a \
5279                     boolean; write keyless: true for an upstream that takes no key"
5280                ),
5281            })
5282        }
5283    };
5284    if keyless && api_key.is_some() {
5285        return Err(RuntimeManifestError::InvalidArtifact {
5286            index,
5287            message: format!(
5288                "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
5289                 gateway either presents a key or is keyless"
5290            ),
5291        });
5292    }
5293    if keyless {
5294        return Ok(ArtifactGateway {
5295            endpoint,
5296            api_key: None,
5297            keyless: true,
5298        });
5299    }
5300
5301    let written = api_key
5302        .flatten()
5303        .map(|value| value.trim().to_string())
5304        .filter(|value| !value.is_empty());
5305    let Some(written) = written else {
5306        return Err(RuntimeManifestError::GatewayWithoutCredential {
5307            index,
5308            name: name.to_string(),
5309            endpoint,
5310        });
5311    };
5312    let api_key = match secrets {
5313        SecretResolution::Skip => None,
5314        SecretResolution::Resolve => Some(match parse_env_reference(&written) {
5315            Some(variable) => ApiKeyReference::Environment(variable.to_string()),
5316            None => ApiKeyReference::Literal(written),
5317        }),
5318    };
5319
5320    Ok(ArtifactGateway {
5321        endpoint,
5322        api_key,
5323        keyless: false,
5324    })
5325}
5326
5327/// Checks a `gateway.endpoint`, the only control on where an artifact's key is sent: a
5328/// gateway-addressed request is readdressed before any `network.allow` rule is consulted, so
5329/// nothing else confines the destination.
5330///
5331/// Accepted: an `https://` URL with a host, or an `http://` URL whose host is `localhost` or an
5332/// IP literal for which [`IpAddr::is_loopback`] returns true (`127.0.0.0/8`, `::1`). An optional
5333/// port and path are kept.
5334///
5335/// Refused:
5336/// - `${` anywhere, checked before parsing: the field is never interpolated, and the URL parser
5337///   accepts `$`, `{` and `}` in a host, so `https://${HOST}` would otherwise parse as a literal
5338///   host.
5339/// - A malformed URL, a scheme other than `http`/`https`, or no host.
5340/// - Userinfo (`user@`, `user:pass@`): `https://api.example.com@evil.example` addresses
5341///   `evil.example`, and the gateway never sends userinfo, so a password there is a second,
5342///   unmanaged credential. The message names the host the URL addresses.
5343/// - A query or a fragment: the gateway readdresses each request by its own path and query, so
5344///   either would be silently dropped.
5345/// - `http://` to any host that is not loopback, including `0.0.0.0` and `::ffff:127.0.0.1`.
5346///
5347/// The endpoint confines the origin only. The guest chooses the whole path and query of every
5348/// gateway request.
5349///
5350/// The error is the whole message, naming `gateway.endpoint` and the artifact entry `artifact`.
5351/// It quotes the endpoint with any userinfo masked, so a password written into the URL is never
5352/// echoed.
5353fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
5354    let shown = mask_endpoint_userinfo(endpoint);
5355    let refuse =
5356        |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
5357    if endpoint.contains("${") {
5358        return Err(refuse(
5359            "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
5360             literally"
5361                .to_string(),
5362        ));
5363    }
5364    let url = Url::parse(endpoint)
5365        .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
5366    let host = match url.scheme() {
5367        "http" | "https" => url
5368            .host_str()
5369            .filter(|host| !host.is_empty())
5370            .ok_or_else(|| refuse("which has no host".to_string()))?,
5371        other => {
5372            return Err(refuse(format!(
5373                "whose scheme '{other}' is unsupported — expected http or https"
5374            )))
5375        }
5376    };
5377    if !url.username().is_empty() || url.password().is_some() {
5378        return Err(refuse(format!(
5379            "which carries userinfo before '@' — the gateway never sends it, and this URL \
5380             addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
5381             the key through gateway.api_key"
5382        )));
5383    }
5384    if url.query().is_some() {
5385        return Err(refuse(
5386            "which carries a query — the gateway keeps each request's own query, so write the \
5387             upstream without one"
5388                .to_string(),
5389        ));
5390    }
5391    if url.fragment().is_some() {
5392        return Err(refuse(
5393            "which carries a fragment — write the upstream without one".to_string(),
5394        ));
5395    }
5396    if url.scheme() == "http" {
5397        let bare = host.trim_start_matches('[').trim_end_matches(']');
5398        let is_loopback = bare == "localhost"
5399            || bare
5400                .parse::<IpAddr>()
5401                .map(|ip| ip.is_loopback())
5402                .unwrap_or(false);
5403        if !is_loopback {
5404            return Err(refuse(format!(
5405                "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
5406                 remote upstream, or http://localhost or a loopback IP literal for a local one"
5407            )));
5408        }
5409    }
5410    Ok(())
5411}
5412
5413/// `endpoint` with everything before the last `@` of its authority replaced by `***`.
5414///
5415/// Textual rather than parsed, so it also masks an endpoint the URL parser rejects.
5416fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
5417    let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
5418    let rest = &endpoint[authority_start..];
5419    let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
5420    match rest[..authority_len].rfind('@') {
5421        Some(at) => format!(
5422            "{}***{}",
5423            &endpoint[..authority_start],
5424            &endpoint[authority_start + at..]
5425        )
5426        .into(),
5427        None => endpoint.into(),
5428    }
5429}
5430
5431/// The cross-entry rules for a `gateway:` on a driver, which need the parsed `inference:` block.
5432///
5433/// A driver's gateway is a metered inference gateway only when that driver is the configured
5434/// `transport: http` driver or an `inference.alternates` entry's driver. On any other driver entry
5435/// it could be spent only through tool dispatch, where nothing meters it, so it is refused. Under
5436/// `transport: http` the configured driver's entry must exist and must carry the upstream.
5437fn validate_driver_gateways(
5438    artifacts: &[RuntimeArtifact],
5439    inference: Option<&InferenceConfig>,
5440) -> Result<(), RuntimeManifestError> {
5441    let configured_driver = inference
5442        .filter(|inference| inference.transport == "http")
5443        .and_then(|inference| inference.driver.as_ref())
5444        .map(|driver| driver.artifact.as_str());
5445    let alternate_drivers: Vec<&str> = inference
5446        .map(|inference| {
5447            inference
5448                .alternates
5449                .iter()
5450                .map(|alternate| alternate.driver.as_str())
5451                .collect()
5452        })
5453        .unwrap_or_default();
5454    let under_process = inference.is_some_and(|inference| inference.transport == "process");
5455
5456    for (index, artifact) in artifacts.iter().enumerate() {
5457        if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
5458            continue;
5459        }
5460        if under_process {
5461            return Err(RuntimeManifestError::InvalidArtifact {
5462                index,
5463                message: format!(
5464                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
5465                     valid with transport: process — the CLI subprocess holds its own \
5466                     credentials and a process driver is granted nothing",
5467                    artifact.name
5468                ),
5469            });
5470        }
5471        if configured_driver != Some(artifact.name.as_str())
5472            && !alternate_drivers.contains(&artifact.name.as_str())
5473        {
5474            return Err(RuntimeManifestError::InvalidArtifact {
5475                index,
5476                message: format!(
5477                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
5478                     the configured inference driver; a driver's gateway is accepted only on the \
5479                     entry inference.driver.artifact names under transport: http, or on one an \
5480                     inference.alternates entry names",
5481                    artifact.name
5482                ),
5483            });
5484        }
5485    }
5486
5487    if let Some(driver) = configured_driver {
5488        let Some((index, entry)) = artifacts
5489            .iter()
5490            .enumerate()
5491            .find(|(_, artifact)| artifact.name == driver)
5492        else {
5493            return Err(RuntimeManifestError::InvalidInferenceConfig {
5494                field: "inference.driver.artifact".to_string(),
5495                message: format!("artifact '{driver}' is not declared in artifacts:"),
5496            });
5497        };
5498        if entry.gateway.is_none() {
5499            return Err(RuntimeManifestError::InvalidArtifact {
5500                index,
5501                message: format!(
5502                    "artifact '{driver}' is the transport: http inference driver but declares no \
5503                     gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
5504                     on its entry"
5505                ),
5506            });
5507        }
5508    }
5509    Ok(())
5510}
5511
5512/// The cross-entry rules for `inference.alternates`, which need `artifacts:`.
5513///
5514/// Each alternate's `driver.artifact` must be an `artifacts:` entry with `runtime: driver`, and
5515/// that entry must carry its own `gateway:` — the credential a switch attaches — unless it is the
5516/// primary's own driver, whose gateway [`validate_driver_gateways`] already requires.
5517fn validate_driver_choices(
5518    artifacts: &[RuntimeArtifact],
5519    inference: Option<&InferenceConfig>,
5520) -> Result<(), RuntimeManifestError> {
5521    let Some(inference) = inference else {
5522        return Ok(());
5523    };
5524    let primary_driver = inference
5525        .driver
5526        .as_ref()
5527        .map(|driver| driver.artifact.as_str());
5528    for (index, alternate) in inference.alternates.iter().enumerate() {
5529        let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
5530            field: format!("inference.alternates[{index}].driver.artifact"),
5531            message,
5532        };
5533        let driver = alternate.driver.as_str();
5534        let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
5535            return Err(refuse(format!(
5536                "artifact '{driver}' is not declared in artifacts:"
5537            )));
5538        };
5539        if entry.runtime != ArtifactRuntime::Driver {
5540            return Err(refuse(format!(
5541                "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
5542                 runtime: driver entry",
5543                entry.runtime.as_str()
5544            )));
5545        }
5546        if entry.gateway.is_none() && primary_driver != Some(driver) {
5547            return Err(refuse(format!(
5548                "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
5549                 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
5550                 URL>, api_key: ... }}' on its entry"
5551            )));
5552        }
5553    }
5554    Ok(())
5555}
5556
5557/// Refuses alternates nothing could ever select: `inference.driver` is in neither
5558/// `control.settings` nor `control.agent_settings`.
5559fn validate_alternates_selectable(
5560    inference: Option<&InferenceConfig>,
5561    control: Option<&ControlConfig>,
5562) -> Result<(), RuntimeManifestError> {
5563    if inference.is_none_or(|inference| inference.alternates.is_empty()) {
5564        return Ok(());
5565    }
5566    let selectable = control.is_some_and(|control| {
5567        control.controller_may_set(ControllableSetting::InferenceDriver)
5568            || control.agent_may_set(ControllableSetting::InferenceDriver)
5569    });
5570    if selectable {
5571        return Ok(());
5572    }
5573    Err(RuntimeManifestError::InvalidInferenceConfig {
5574        field: "inference.alternates".to_string(),
5575        message: "declares alternates that nothing could ever select; list inference.driver in \
5576                  control.settings for a controller to switch, or in control.agent_settings for \
5577                  the agent to"
5578            .to_string(),
5579    })
5580}
5581
5582/// The cross-entry rules for the `transport: process` driver, which need the parsed
5583/// `inference:` block.
5584///
5585/// The artifact `inference.driver.artifact` names must be declared in `artifacts:` with
5586/// `runtime: driver`. A process driver is granted nothing, so its entry may carry neither
5587/// `capabilities:` nor `config:`; its settings travel in `inference.driver.config`. A `gateway:`
5588/// on it is refused by [`validate_driver_gateways`].
5589fn validate_process_driver_entry(
5590    artifacts: &[RuntimeArtifact],
5591    inference: Option<&InferenceConfig>,
5592) -> Result<(), RuntimeManifestError> {
5593    let Some(driver) = inference
5594        .filter(|inference| inference.transport == "process")
5595        .and_then(|inference| inference.driver.as_ref())
5596        .map(|driver| driver.artifact.as_str())
5597    else {
5598        return Ok(());
5599    };
5600    let Some((index, entry)) = artifacts
5601        .iter()
5602        .enumerate()
5603        .find(|(_, artifact)| artifact.name == driver)
5604    else {
5605        return Err(RuntimeManifestError::InvalidInferenceConfig {
5606            field: "inference.driver.artifact".to_string(),
5607            message: format!("artifact '{driver}' is not declared in artifacts:"),
5608        });
5609    };
5610    if entry.runtime != ArtifactRuntime::Driver {
5611        return Err(RuntimeManifestError::InvalidArtifact {
5612            index,
5613            message: format!(
5614                "artifact '{driver}' is the transport: process driver and must be declared with \
5615                 runtime: driver"
5616            ),
5617        });
5618    }
5619    if entry.capabilities.is_some() || entry.config.is_some() {
5620        return Err(RuntimeManifestError::InvalidArtifact {
5621            index,
5622            message: format!(
5623                "artifact '{driver}' is the transport: process driver, which is granted nothing; \
5624                 remove capabilities:/config: from its entry — driver settings go in \
5625                 inference.driver.config"
5626            ),
5627        });
5628    }
5629    Ok(())
5630}
5631
5632fn parse_inference_driver(
5633    raw: RawInferenceDriver,
5634) -> Result<InferenceDriver, RuntimeManifestError> {
5635    let artifact = raw
5636        .artifact
5637        .map(|value| value.trim().to_string())
5638        .filter(|value| !value.is_empty())
5639        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5640            field: "inference.driver.artifact".to_string(),
5641            message: "missing required field".to_string(),
5642        })?;
5643
5644    let config = parse_inference_driver_config(raw.config)?;
5645
5646    Ok(InferenceDriver { artifact, config })
5647}
5648
5649fn parse_inference_driver_config(
5650    raw: Option<serde_yaml::Value>,
5651) -> Result<Option<String>, RuntimeManifestError> {
5652    let Some(raw) = raw else {
5653        return Ok(None);
5654    };
5655
5656    if matches!(raw, serde_yaml::Value::Null) {
5657        return Ok(None);
5658    }
5659
5660    if !matches!(raw, serde_yaml::Value::Mapping(_)) {
5661        return Err(RuntimeManifestError::InvalidInferenceConfig {
5662            field: "inference.driver.config".to_string(),
5663            message: "expected mapping/object".to_string(),
5664        });
5665    }
5666
5667    let value =
5668        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
5669            field: "inference.driver.config".to_string(),
5670            message: format!("failed to convert to JSON: {err}"),
5671        })?;
5672
5673    serde_json::to_string(&value).map(Some).map_err(|err| {
5674        RuntimeManifestError::InvalidInferenceConfig {
5675            field: "inference.driver.config".to_string(),
5676            message: format!("failed to encode JSON: {err}"),
5677        }
5678    })
5679}
5680
5681fn parse_env_reference(value: &str) -> Option<&str> {
5682    if !value.starts_with("${") || !value.ends_with('}') {
5683        return None;
5684    }
5685
5686    let variable = &value[2..value.len() - 1];
5687    is_valid_env_variable(variable).then_some(variable)
5688}
5689
5690fn is_valid_env_variable(value: &str) -> bool {
5691    let mut chars = value.chars();
5692    let Some(first) = chars.next() else {
5693        return false;
5694    };
5695
5696    if !(first == '_' || first.is_ascii_uppercase()) {
5697        return false;
5698    }
5699
5700    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
5701}
5702
5703#[cfg(test)]
5704mod tests {
5705    use super::*;
5706    use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
5707
5708    fn manifest_with_trace(block: &str) -> String {
5709        format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
5710    }
5711
5712    /// No `trace:` block at all leaves the config absent; the runtime substitutes
5713    /// [`TraceCapture::default`] rather than the manifest carrying a synthesized one.
5714    #[test]
5715    fn absent_trace_block_stays_absent_and_defaults_to_meta() {
5716        let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
5717        assert_eq!(manifest.trace, None);
5718        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
5719    }
5720
5721    #[test]
5722    fn each_capture_mode_parses_from_the_trace_block() {
5723        for (yaml, expected) in [
5724            ("none", TraceCapture::None),
5725            ("meta", TraceCapture::Meta),
5726            ("content", TraceCapture::Content),
5727        ] {
5728            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
5729                "  capture: {yaml}\n"
5730            )))
5731            .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
5732            assert_eq!(
5733                manifest.trace,
5734                Some(TraceConfig {
5735                    capture: expected,
5736                    retain: None
5737                })
5738            );
5739        }
5740    }
5741
5742    // ── retain ───────────────────────────────────────────────────────────────
5743
5744    /// A `trace:` block with only `capture` and a `context:` block with only `record` declare no
5745    /// retention at all: an absent `retain:` is the only way to say "keep everything", and it is
5746    /// what a capsule that never mentions retention gets.
5747    #[test]
5748    fn no_retain_block_anywhere_parses_as_no_policy() {
5749        let manifest = RuntimeManifest::from_yaml_str(
5750            "name: cap\nversion: 0.0.1\ntrace:\n  capture: content\ncontext:\n  record: on\n",
5751        )
5752        .unwrap();
5753        assert_eq!(manifest.trace.unwrap().retain, None);
5754        assert_eq!(manifest.context.unwrap().retain, None);
5755    }
5756
5757    #[test]
5758    fn trace_retain_parses_both_keys_and_the_day_suffix() {
5759        let manifest = RuntimeManifest::from_yaml_str(
5760            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 50\n    max_age: 14d\n",
5761        )
5762        .unwrap();
5763        assert_eq!(
5764            manifest.trace.unwrap().retain,
5765            Some(TraceRetainConfig {
5766                max_sessions: Some(50),
5767                max_age_secs: Some(14 * 86_400),
5768            })
5769        );
5770    }
5771
5772    #[test]
5773    fn context_retain_parses_both_keys_and_either_alone() {
5774        let both = RuntimeManifest::from_yaml_str(
5775            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n    max_messages: 2000\n    max_age: 90d\n",
5776        )
5777        .unwrap();
5778        assert_eq!(
5779            both.context.unwrap().retain,
5780            Some(ContextRetainConfig {
5781                max_messages: Some(2000),
5782                max_age_secs: Some(90 * 86_400),
5783            })
5784        );
5785
5786        let count_only = RuntimeManifest::from_yaml_str(
5787            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 10\n",
5788        )
5789        .unwrap();
5790        assert_eq!(
5791            count_only.context.unwrap().retain,
5792            Some(ContextRetainConfig {
5793                max_messages: Some(10),
5794                max_age_secs: None,
5795            })
5796        );
5797    }
5798
5799    /// An empty block is not a way to declare no policy — omitting the block is. Both spellings
5800    /// of empty are refused, naming the block an operator has to go and change.
5801    #[test]
5802    fn an_empty_retain_block_is_refused_naming_the_block() {
5803        for (yaml, expect_trace) in [
5804            (
5805                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain: {}\n",
5806                true,
5807            ),
5808            (
5809                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n",
5810                true,
5811            ),
5812            (
5813                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain: {}\n",
5814                false,
5815            ),
5816            (
5817                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n",
5818                false,
5819            ),
5820        ] {
5821            let err = RuntimeManifest::from_yaml_str(yaml)
5822                .expect_err("an empty retain block must be refused");
5823            let (field, message) = match (&err, expect_trace) {
5824                (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
5825                    (field, message)
5826                }
5827                (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
5828                    (field, message)
5829                }
5830                _ => panic!("wrong variant for {yaml:?}: {err:?}"),
5831            };
5832            assert_eq!(
5833                field,
5834                if expect_trace {
5835                    "trace.retain"
5836                } else {
5837                    "context.retain"
5838                }
5839            );
5840            assert!(message.contains("at least one key"), "{message}");
5841        }
5842    }
5843
5844    /// A limit of zero would delete what retention exists to keep, so it is
5845    /// refused naming the key rather than accepted as "keep nothing".
5846    #[test]
5847    fn a_zero_retain_limit_is_refused_naming_the_key() {
5848        let trace = RuntimeManifest::from_yaml_str(
5849            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 0\n",
5850        )
5851        .expect_err("max_sessions: 0 must be refused");
5852        assert!(
5853            matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
5854                     if field == "trace.retain.max_sessions"),
5855            "{trace:?}"
5856        );
5857
5858        let context = RuntimeManifest::from_yaml_str(
5859            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 0\n",
5860        )
5861        .expect_err("max_messages: 0 must be refused");
5862        assert!(
5863            matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
5864                     if field == "context.retain.max_messages"),
5865            "{context:?}"
5866        );
5867
5868        let age = RuntimeManifest::from_yaml_str(
5869            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_age: 0\n",
5870        )
5871        .expect_err("max_age: 0 must be refused");
5872        assert!(
5873            matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
5874                     if field == "trace.retain.max_age"),
5875            "{age:?}"
5876        );
5877    }
5878
5879    #[test]
5880    fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
5881        let err = RuntimeManifest::from_yaml_str(
5882            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_traces: 3\n",
5883        )
5884        .expect_err("an unknown key must be refused");
5885        match err {
5886            RuntimeManifestError::InvalidTraceConfig { field, message } => {
5887                assert_eq!(field, "trace.retain");
5888                assert!(message.contains("max_traces"), "{message}");
5889                assert!(message.contains("max_sessions"), "{message}");
5890            }
5891            other => panic!("expected InvalidTraceConfig, got {other:?}"),
5892        }
5893    }
5894
5895    #[test]
5896    fn an_unparseable_retain_duration_reports_the_accepted_form() {
5897        let err = RuntimeManifest::from_yaml_str(
5898            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_age: 5 weeks\n",
5899        )
5900        .expect_err("'5 weeks' must be refused");
5901        match err {
5902            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
5903                assert_eq!(field, "context.retain.max_age");
5904                assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
5905            }
5906            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
5907        }
5908    }
5909
5910    /// The retired boolean keeps working, mapping `true` to `Content` and `false` to `Meta`.
5911    #[test]
5912    fn include_tool_output_still_resolves_to_content_and_meta() {
5913        let opted_in =
5914            RuntimeManifest::from_yaml_str(&manifest_with_trace("  include_tool_output: true\n"))
5915                .unwrap();
5916        assert_eq!(
5917            opted_in.trace,
5918            Some(TraceConfig {
5919                capture: TraceCapture::Content,
5920                retain: None
5921            })
5922        );
5923
5924        let opted_out =
5925            RuntimeManifest::from_yaml_str(&manifest_with_trace("  include_tool_output: false\n"))
5926                .unwrap();
5927        assert_eq!(
5928            opted_out.trace,
5929            Some(TraceConfig {
5930                capture: TraceCapture::Meta,
5931                retain: None
5932            })
5933        );
5934    }
5935
5936    #[test]
5937    fn setting_both_capture_keys_is_refused_naming_both() {
5938        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(
5939            "  capture: content\n  include_tool_output: true\n",
5940        ))
5941        .expect_err("both keys must be refused even when they agree");
5942        assert!(matches!(
5943            err,
5944            RuntimeManifestError::InvalidTraceConfig { .. }
5945        ));
5946        let rendered = err.to_string();
5947        assert!(rendered.contains("trace.capture"), "{rendered}");
5948        assert!(rendered.contains("trace.include_tool_output"), "{rendered}");
5949    }
5950
5951    #[test]
5952    fn unparseable_capture_value_names_the_field_and_accepted_values() {
5953        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace("  capture: verbose\n"))
5954            .expect_err("'verbose' is not a capture mode");
5955        let rendered = err.to_string();
5956        assert!(rendered.contains("trace.capture"), "{rendered}");
5957        for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
5958            assert!(rendered.contains(accepted), "{rendered}");
5959        }
5960    }
5961
5962    #[test]
5963    fn resources_block_is_optional_and_absent_stays_absent() {
5964        let manifest = RuntimeManifest::from_yaml_str(
5965            r#"
5966name: cap
5967version: 0.0.1
5968capabilities:
5969  shell:
5970    allow: [bash]
5971"#,
5972        )
5973        .unwrap();
5974
5975        // `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
5976        // its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
5977        // keeping the two states distinct is what lets it own them in one place.
5978        let caps = manifest.capabilities.expect("shell block must parse");
5979        assert_eq!(caps.resources, None);
5980    }
5981
5982    #[test]
5983    fn resources_fields_parse_and_each_stays_independently_optional() {
5984        let manifest = RuntimeManifest::from_yaml_str(
5985            r#"
5986name: cap
5987version: 0.0.1
5988capabilities:
5989  resources:
5990    max_processes: 32
5991    max_open_files: 16
5992    max_file_size_bytes: 1048576
5993    cpu_seconds: 30
5994    memory_bytes: 268435456
5995    cgroup_memory_bytes: 536870912
5996    cgroup_pids_max: 64
5997    cgroup_cpu_percent: 50
5998    cgroup_io_bytes_per_sec: 1048576
5999    workdir_max_bytes: 2097152
6000"#,
6001        )
6002        .unwrap();
6003
6004        let resources = manifest
6005            .capabilities
6006            .and_then(|caps| caps.resources)
6007            .expect("resources block must parse");
6008        assert_eq!(resources.max_processes, Some(32));
6009        assert_eq!(resources.max_open_files, Some(16));
6010        assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
6011        assert_eq!(resources.cpu_seconds, Some(30));
6012        assert_eq!(resources.memory_bytes, Some(268_435_456));
6013        assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
6014        assert_eq!(resources.cgroup_pids_max, Some(64));
6015        assert_eq!(resources.cgroup_cpu_percent, Some(50));
6016        assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
6017        assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
6018    }
6019
6020    #[test]
6021    fn state_block_is_optional_and_absent_stays_absent() {
6022        let manifest = RuntimeManifest::from_yaml_str(
6023            r#"
6024name: cap
6025version: 0.0.1
6026artifacts:
6027  - name: notes-tool
6028    version: 1.0.0
6029    runtime: tool
6030    capabilities:
6031      filesystem:
6032        scope: cache
6033"#,
6034        )
6035        .unwrap();
6036
6037        // Absent is deny: no store name to default, and nothing downstream creates a directory.
6038        let caps = manifest.artifacts[0]
6039            .capabilities
6040            .as_ref()
6041            .expect("filesystem block must parse");
6042        assert_eq!(caps.state, None);
6043    }
6044
6045    /// A declared-but-empty `state:` block is a real grant — it means "give me a store, named
6046    /// after the capsule" — so it must lower to `Some(..)` with no name, distinguishable from
6047    /// the absent block above.
6048    #[test]
6049    fn state_block_declared_empty_grants_the_capsule_named_store() {
6050        let manifest = RuntimeManifest::from_yaml_str(
6051            r#"
6052name: cap
6053version: 0.0.1
6054artifacts:
6055  - name: notes-tool
6056    version: 1.0.0
6057    runtime: tool
6058    capabilities:
6059      state: {}
6060"#,
6061        )
6062        .unwrap();
6063
6064        let state = manifest.artifacts[0]
6065            .capabilities
6066            .as_ref()
6067            .and_then(|caps| caps.state.as_ref())
6068            .expect("state block must parse");
6069        assert_eq!(state.store, None);
6070    }
6071
6072    #[test]
6073    fn state_store_name_parses_and_stays_independently_optional() {
6074        let manifest = RuntimeManifest::from_yaml_str(
6075            r#"
6076name: cap
6077version: 0.0.1
6078artifacts:
6079  - name: notes-tool
6080    version: 1.0.0
6081    runtime: tool
6082    capabilities:
6083      state:
6084        store: "  shey  "
6085      network:
6086        allow: [https://api.example.com]
6087"#,
6088        )
6089        .unwrap();
6090
6091        let caps = manifest.artifacts[0]
6092            .capabilities
6093            .as_ref()
6094            .expect("capabilities must parse");
6095        // Trimmed, but otherwise verbatim: whether the name is a usable directory segment is
6096        // decided by the runtime (`E-CAP-009`), not here.
6097        assert_eq!(
6098            caps.state.as_ref().and_then(|state| state.store.as_deref()),
6099            Some("shey")
6100        );
6101        // Declaring a store never touches any sibling sub-block.
6102        assert_eq!(
6103            caps.network
6104                .as_ref()
6105                .map(|network| network.allow.as_slice()),
6106            Some(["https://api.example.com".to_string()].as_slice())
6107        );
6108        assert_eq!(caps.filesystem, None);
6109    }
6110
6111    /// An explicitly empty name is kept rather than normalised to "default to the capsule name":
6112    /// the operator wrote a name, and handing them a different store than the one they wrote
6113    /// would be worse than the refusal the runtime raises for it.
6114    #[test]
6115    fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
6116        let manifest = RuntimeManifest::from_yaml_str(
6117            r#"
6118name: cap
6119version: 0.0.1
6120artifacts:
6121  - name: notes-tool
6122    version: 1.0.0
6123    runtime: tool
6124    capabilities:
6125      state:
6126        store: ""
6127"#,
6128        )
6129        .unwrap();
6130
6131        assert_eq!(
6132            manifest.artifacts[0]
6133                .capabilities
6134                .as_ref()
6135                .and_then(|caps| caps.state.as_ref())
6136                .and_then(|state| state.store.as_deref()),
6137            Some("")
6138        );
6139    }
6140
6141    #[test]
6142    fn resources_partial_block_leaves_undeclared_fields_none() {
6143        let manifest = RuntimeManifest::from_yaml_str(
6144            r#"
6145name: cap
6146version: 0.0.1
6147capabilities:
6148  resources:
6149    max_open_files: 16
6150"#,
6151        )
6152        .unwrap();
6153
6154        let resources = manifest
6155            .capabilities
6156            .and_then(|caps| caps.resources)
6157            .expect("resources block must parse");
6158        assert_eq!(resources.max_open_files, Some(16));
6159        assert_eq!(resources.max_processes, None);
6160        assert_eq!(resources.workdir_max_bytes, None);
6161    }
6162
6163    /// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
6164    /// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
6165    /// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
6166    #[test]
6167    fn resources_zero_is_rejected_on_every_field() {
6168        for field in [
6169            "max_processes",
6170            "max_open_files",
6171            "max_file_size_bytes",
6172            "cpu_seconds",
6173            "memory_bytes",
6174            "cgroup_memory_bytes",
6175            "cgroup_pids_max",
6176            "cgroup_cpu_percent",
6177            "cgroup_io_bytes_per_sec",
6178            "workdir_max_bytes",
6179        ] {
6180            let yaml =
6181                format!("name: cap\nversion: 0.0.1\ncapabilities:\n  resources:\n    {field}: 0\n");
6182            let error =
6183                RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
6184
6185            match error {
6186                RuntimeManifestError::InvalidCapabilities {
6187                    field: reported,
6188                    message,
6189                } => {
6190                    assert_eq!(reported, format!("capabilities.resources.{field}"));
6191                    assert_eq!(message, "must be greater than zero");
6192                }
6193                other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
6194            }
6195        }
6196    }
6197
6198    #[test]
6199    fn artifact_runtime_defaults_to_tool() {
6200        let manifest = RuntimeManifest::from_yaml_str(
6201            r#"
6202name: cap
6203version: 0.0.1
6204artifacts:
6205  - name: echo
6206    version: 1.2.3
6207"#,
6208        )
6209        .unwrap();
6210
6211        assert_eq!(manifest.artifacts.len(), 1);
6212        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6213    }
6214
6215    #[test]
6216    fn artifact_runtime_tool_explicit() {
6217        let manifest = RuntimeManifest::from_yaml_str(
6218            r#"
6219name: cap
6220version: 0.0.1
6221artifacts:
6222  - name: echo
6223    version: 1.2.3
6224    runtime: tool
6225"#,
6226        )
6227        .unwrap();
6228
6229        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6230    }
6231
6232    #[test]
6233    fn artifact_runtime_wasm_is_error() {
6234        let err = RuntimeManifest::from_yaml_str(
6235            r#"
6236name: cap
6237version: 0.0.1
6238artifacts:
6239  - name: echo
6240    version: 1.2.3
6241    runtime: wasm
6242"#,
6243        )
6244        .unwrap_err();
6245
6246        let msg = err.to_string();
6247        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6248        assert!(msg.contains("wasm"), "error was: {msg}");
6249    }
6250
6251    #[test]
6252    fn artifact_runtime_native_is_error() {
6253        let err = RuntimeManifest::from_yaml_str(
6254            r#"
6255name: cap
6256version: 0.0.1
6257artifacts:
6258  - name: echo
6259    version: 1.2.3
6260    runtime: native
6261"#,
6262        )
6263        .unwrap_err();
6264
6265        let msg = err.to_string();
6266        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6267        assert!(msg.contains("native"), "error was: {msg}");
6268    }
6269
6270    #[test]
6271    fn invalid_artifact_reports_index() {
6272        let err = RuntimeManifest::from_yaml_str(
6273            r#"
6274name: cap
6275version: 0.0.1
6276artifacts:
6277  - name: ""
6278    version: 1.2.3
6279"#,
6280        )
6281        .unwrap_err();
6282
6283        let msg = err.to_string();
6284        assert!(msg.contains("index 0"));
6285        assert!(msg.contains("missing required field 'name'"));
6286    }
6287
6288    #[test]
6289    fn capabilities_parse_when_all_fields_present() {
6290        let manifest = RuntimeManifest::from_yaml_str(
6291            r#"
6292name: cap
6293version: 0.0.1
6294artifacts:
6295  - name: echo
6296    version: 1.2.3
6297capabilities:
6298  network:
6299    allow:
6300      - https://api.anthropic.com
6301      - http://127.0.0.1:8080
6302  filesystem:
6303    scope: ./sandbox
6304"#,
6305        )
6306        .unwrap();
6307
6308        let capabilities = manifest.capabilities.expect("capabilities should exist");
6309        assert_eq!(
6310            capabilities.network.unwrap().allow,
6311            vec![
6312                "https://api.anthropic.com".to_string(),
6313                "http://127.0.0.1:8080".to_string()
6314            ]
6315        );
6316        assert_eq!(
6317            capabilities.filesystem.unwrap().scope,
6318            Some("./sandbox".to_string())
6319        );
6320    }
6321
6322    #[test]
6323    fn env_capabilities_parse_allowlist() {
6324        let manifest = RuntimeManifest::from_yaml_str(
6325            r#"
6326name: cap
6327version: 0.0.1
6328artifacts:
6329  - name: echo
6330    version: 1.2.3
6331capabilities:
6332  env:
6333    allow:
6334      - MY_APP_REGION
6335      - GITHUB_TOKEN
6336"#,
6337        )
6338        .unwrap();
6339
6340        let capabilities = manifest.capabilities.expect("capabilities should exist");
6341        assert_eq!(
6342            capabilities.env.unwrap().allow,
6343            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
6344        );
6345    }
6346
6347    #[test]
6348    fn env_capabilities_absent_when_block_omitted() {
6349        let manifest = RuntimeManifest::from_yaml_str(
6350            r#"
6351name: cap
6352version: 0.0.1
6353artifacts:
6354  - name: echo
6355    version: 1.2.3
6356capabilities:
6357  network:
6358    allow:
6359      - https://api.anthropic.com
6360"#,
6361        )
6362        .unwrap();
6363
6364        assert!(manifest.capabilities.unwrap().env.is_none());
6365    }
6366
6367    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
6368    /// than a manifest error.
6369    #[test]
6370    fn env_capabilities_allow_empty_list_is_accepted() {
6371        let manifest = RuntimeManifest::from_yaml_str(
6372            r#"
6373name: cap
6374version: 0.0.1
6375artifacts:
6376  - name: echo
6377    version: 1.2.3
6378capabilities:
6379  env: {}
6380"#,
6381        )
6382        .unwrap();
6383
6384        assert_eq!(
6385            manifest.capabilities.unwrap().env,
6386            Some(EnvCapabilities { allow: vec![] })
6387        );
6388    }
6389
6390    #[test]
6391    fn missing_capabilities_block_is_backward_compatible() {
6392        let manifest = RuntimeManifest::from_yaml_str(
6393            r#"
6394name: cap
6395version: 0.0.1
6396artifacts:
6397  - name: echo
6398    version: 1.2.3
6399"#,
6400        )
6401        .unwrap();
6402
6403        assert!(manifest.capabilities.is_none());
6404    }
6405
6406    #[test]
6407    fn network_only_capabilities_parse() {
6408        let manifest = RuntimeManifest::from_yaml_str(
6409            r#"
6410name: cap
6411version: 0.0.1
6412artifacts:
6413  - name: echo
6414    version: 1.2.3
6415capabilities:
6416  network:
6417    allow:
6418      - https://allowed.example.com
6419"#,
6420        )
6421        .unwrap();
6422
6423        let capabilities = manifest.capabilities.unwrap();
6424        assert_eq!(
6425            capabilities.network.unwrap().allow,
6426            vec!["https://allowed.example.com".to_string()]
6427        );
6428        assert!(capabilities.filesystem.is_none());
6429    }
6430
6431    #[test]
6432    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
6433        let manifest = RuntimeManifest::from_yaml_str(
6434            r#"
6435name: cap
6436version: 0.0.1
6437artifacts:
6438  - name: echo
6439    version: 1.2.3
6440capabilities:
6441  network: {}
6442"#,
6443        )
6444        .unwrap();
6445
6446        let capabilities = manifest.capabilities.unwrap();
6447        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
6448    }
6449
6450    /// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
6451    /// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
6452    #[test]
6453    fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
6454        let manifest = RuntimeManifest::from_yaml_str(
6455            r#"
6456name: cap
6457version: 0.0.1
6458artifacts:
6459  - name: echo
6460    version: 1.2.3
6461capabilities:
6462  network:
6463    allow:
6464      - https://api.anthropic.com
6465"#,
6466        )
6467        .unwrap();
6468
6469        let network = manifest.capabilities.unwrap().network.unwrap();
6470        assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
6471        assert!(!network.unix_sockets);
6472    }
6473
6474    #[test]
6475    fn network_unix_sockets_parses_explicit_false() {
6476        let manifest = RuntimeManifest::from_yaml_str(
6477            r#"
6478name: cap
6479version: 0.0.1
6480artifacts:
6481  - name: echo
6482    version: 1.2.3
6483capabilities:
6484  network:
6485    unix_sockets: false
6486"#,
6487        )
6488        .unwrap();
6489
6490        assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
6491    }
6492
6493    #[test]
6494    fn network_unix_sockets_parses_explicit_true() {
6495        let manifest = RuntimeManifest::from_yaml_str(
6496            r#"
6497name: cap
6498version: 0.0.1
6499artifacts:
6500  - name: echo
6501    version: 1.2.3
6502capabilities:
6503  network:
6504    unix_sockets: true
6505"#,
6506        )
6507        .unwrap();
6508
6509        let network = manifest.capabilities.unwrap().network.unwrap();
6510        assert!(network.unix_sockets);
6511        // The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
6512        assert_eq!(network.allow, Vec::<String>::new());
6513    }
6514
6515    /// `read_only` is normalised on exactly the terms `scope` is — trimmed, with an entry left
6516    /// empty by that trim dropped rather than carried as a rule covering the whole workdir — and
6517    /// path shape is deliberately not judged here.
6518    #[test]
6519    fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
6520        let manifest = RuntimeManifest::from_yaml_str(
6521            r#"
6522name: cap
6523version: 0.0.1
6524capabilities:
6525  filesystem:
6526    read_only:
6527      - tests
6528      - "  bench/fixtures  "
6529      - ""
6530      - "   "
6531"#,
6532        )
6533        .unwrap();
6534
6535        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
6536        assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
6537    }
6538
6539    /// An absent key and an empty list are one declaration: nothing is protected.
6540    #[test]
6541    fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
6542        let manifest = RuntimeManifest::from_yaml_str(
6543            r#"
6544name: cap
6545version: 0.0.1
6546capabilities:
6547  filesystem:
6548    scope: workdir
6549"#,
6550        )
6551        .unwrap();
6552
6553        assert!(manifest
6554            .capabilities
6555            .unwrap()
6556            .filesystem
6557            .unwrap()
6558            .read_only
6559            .is_empty());
6560    }
6561
6562    /// Path shape is the runtime's refusal, not the parser's: an absolute or escaping entry parses
6563    /// here exactly as an absolute `scope` does, and fails at launch with `E-CAP-012`.
6564    #[test]
6565    fn filesystem_read_only_does_not_judge_path_shape() {
6566        let manifest = RuntimeManifest::from_yaml_str(
6567            r#"
6568name: cap
6569version: 0.0.1
6570capabilities:
6571  filesystem:
6572    read_only:
6573      - /etc
6574      - ../outside
6575"#,
6576        )
6577        .expect("shape is judged by the runtime, not the parser");
6578
6579        assert_eq!(
6580            manifest.capabilities.unwrap().filesystem.unwrap().read_only,
6581            vec!["/etc", "../outside"]
6582        );
6583    }
6584
6585    /// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
6586    /// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
6587    /// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
6588    #[test]
6589    fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
6590        let manifest = RuntimeManifest::from_yaml_str(
6591            r#"
6592name: cap
6593version: 0.0.1
6594artifacts:
6595  - name: echo
6596    version: 1.2.3
6597capabilities:
6598  filesystem:
6599    scope: workdir
6600"#,
6601        )
6602        .unwrap();
6603
6604        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
6605        assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
6606        assert!(!filesystem.workdir_exec);
6607    }
6608
6609    /// The other absent case: no `filesystem:` block at all. There is nothing to default here —
6610    /// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
6611    /// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
6612    /// covers with its own test.
6613    #[test]
6614    fn filesystem_block_stays_absent_when_undeclared() {
6615        let manifest = RuntimeManifest::from_yaml_str(
6616            r#"
6617name: cap
6618version: 0.0.1
6619artifacts:
6620  - name: echo
6621    version: 1.2.3
6622capabilities:
6623  network:
6624    allow:
6625      - https://api.anthropic.com
6626"#,
6627        )
6628        .unwrap();
6629
6630        assert!(manifest.capabilities.unwrap().filesystem.is_none());
6631    }
6632
6633    #[test]
6634    fn filesystem_workdir_exec_parses_explicit_false() {
6635        let manifest = RuntimeManifest::from_yaml_str(
6636            r#"
6637name: cap
6638version: 0.0.1
6639artifacts:
6640  - name: echo
6641    version: 1.2.3
6642capabilities:
6643  filesystem:
6644    workdir_exec: false
6645"#,
6646        )
6647        .unwrap();
6648
6649        assert!(
6650            !manifest
6651                .capabilities
6652                .unwrap()
6653                .filesystem
6654                .unwrap()
6655                .workdir_exec
6656        );
6657    }
6658
6659    #[test]
6660    fn filesystem_workdir_exec_parses_explicit_true() {
6661        let manifest = RuntimeManifest::from_yaml_str(
6662            r#"
6663name: cap
6664version: 0.0.1
6665artifacts:
6666  - name: echo
6667    version: 1.2.3
6668capabilities:
6669  filesystem:
6670    workdir_exec: true
6671"#,
6672        )
6673        .unwrap();
6674
6675        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
6676        assert!(filesystem.workdir_exec);
6677        // Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
6678        // declaring one says nothing about the other.
6679        assert_eq!(filesystem.scope, None);
6680    }
6681
6682    #[test]
6683    fn network_internal_port_parses() {
6684        let manifest = RuntimeManifest::from_yaml_str(
6685            r#"
6686name: worker
6687version: 0.1.0
6688artifacts: []
6689
6690network:
6691  internal_port: 58172
6692"#,
6693        )
6694        .unwrap();
6695
6696        assert_eq!(
6697            manifest.network.unwrap().internal_port,
6698            Some(58172),
6699            "network.internal_port should parse to Some(58172)"
6700        );
6701    }
6702
6703    #[test]
6704    fn network_internal_port_absent_when_omitted() {
6705        let manifest = RuntimeManifest::from_yaml_str(
6706            r#"
6707name: worker
6708version: 0.1.0
6709artifacts: []
6710"#,
6711        )
6712        .unwrap();
6713
6714        assert!(manifest.network.is_none());
6715    }
6716
6717    #[test]
6718    fn shell_capabilities_parse_with_allow_and_env_fields() {
6719        let manifest = RuntimeManifest::from_yaml_str(
6720            r#"
6721name: cap
6722version: 0.0.1
6723artifacts: []
6724capabilities:
6725  shell:
6726    allow:
6727      - bash
6728      - git
6729    strip_env:
6730      - "*_TOKEN"
6731      - SECRET_KEY
6732    baseline_env:
6733      - SSH_AUTH_SOCK
6734"#,
6735        )
6736        .unwrap();
6737
6738        let shell = manifest
6739            .capabilities
6740            .unwrap()
6741            .shell
6742            .expect("shell capabilities should be present");
6743        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
6744        assert_eq!(
6745            shell.strip_env,
6746            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
6747        );
6748        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
6749    }
6750
6751    #[test]
6752    fn shell_capabilities_require_non_empty_allowlist() {
6753        let err = RuntimeManifest::from_yaml_str(
6754            r#"
6755name: cap
6756version: 0.0.1
6757artifacts: []
6758capabilities:
6759  shell:
6760    allow: []
6761"#,
6762        )
6763        .unwrap_err();
6764
6765        let msg = err.to_string();
6766        assert!(msg.contains("capabilities.shell.allow"));
6767        assert!(msg.contains("at least one"));
6768    }
6769
6770    #[test]
6771    fn interpreter_runtime_parses_accepted_shape() {
6772        let manifest = RuntimeManifest::from_yaml_str(
6773            r#"
6774name: cap
6775version: 0.0.1
6776artifacts: []
6777capabilities:
6778  shell:
6779    allow:
6780      - python3
6781    interpreter_runtime:
6782      - binary: python3
6783        dirs:
6784          - path: /usr/lib/python3.11
6785            list_dir: true
6786          - path: /usr/lib/python3.11/lib-dynload
6787            list_dir: false
6788"#,
6789        )
6790        .unwrap();
6791
6792        let shell = manifest.capabilities.unwrap().shell.unwrap();
6793        assert_eq!(
6794            shell.interpreter_runtime,
6795            vec![InterpreterRuntimeGrant {
6796                binary: "python3".to_string(),
6797                dirs: vec![
6798                    InterpreterRuntimeDir {
6799                        path: "/usr/lib/python3.11".to_string(),
6800                        list_dir: true,
6801                    },
6802                    InterpreterRuntimeDir {
6803                        path: "/usr/lib/python3.11/lib-dynload".to_string(),
6804                        list_dir: false,
6805                    },
6806                ],
6807            }]
6808        );
6809    }
6810
6811    #[test]
6812    fn interpreter_runtime_defaults_to_empty_when_absent() {
6813        let manifest = RuntimeManifest::from_yaml_str(
6814            r#"
6815name: cap
6816version: 0.0.1
6817artifacts: []
6818capabilities:
6819  shell:
6820    allow:
6821      - python3
6822"#,
6823        )
6824        .unwrap();
6825
6826        let shell = manifest.capabilities.unwrap().shell.unwrap();
6827        assert!(shell.interpreter_runtime.is_empty());
6828    }
6829
6830    /// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
6831    /// (indented to sit under `interpreter_runtime:`), returning the error message.
6832    fn interpreter_runtime_reject(grant_yaml: &str) -> String {
6833        let yaml = format!(
6834            r#"
6835name: cap
6836version: 0.0.1
6837artifacts: []
6838capabilities:
6839  shell:
6840    allow:
6841      - python3
6842    interpreter_runtime:
6843{grant_yaml}
6844"#,
6845        );
6846        RuntimeManifest::from_yaml_str(&yaml)
6847            .expect_err("grant should be rejected")
6848            .to_string()
6849    }
6850
6851    #[test]
6852    fn interpreter_runtime_rejects_binary_not_in_allow() {
6853        let msg = interpreter_runtime_reject(
6854            "      - binary: ruby\n        dirs:\n          - path: /usr/lib/ruby\n            list_dir: true\n",
6855        );
6856        assert!(
6857            msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
6858            "{msg}"
6859        );
6860        assert!(msg.contains("ruby"), "{msg}");
6861        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
6862    }
6863
6864    #[test]
6865    fn interpreter_runtime_rejects_relative_path() {
6866        let msg = interpreter_runtime_reject(
6867            "      - binary: python3\n        dirs:\n          - path: usr/lib/python3.11\n            list_dir: true\n",
6868        );
6869        assert!(
6870            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
6871            "{msg}"
6872        );
6873        assert!(msg.contains("absolute"), "{msg}");
6874    }
6875
6876    // ── capabilities.plan (capsule-wide) ─────────────────────────────────────
6877
6878    /// A capsule manifest whose capsule-wide capabilities carry `plan: <block>`.
6879    fn manifest_with_plan(block: &str) -> String {
6880        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n{block}")
6881    }
6882
6883    #[test]
6884    fn plan_submit_true_grants_the_capsule() {
6885        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n"))
6886            .expect("a plan block parses");
6887        assert_eq!(
6888            manifest.capabilities.unwrap().plan,
6889            Some(PlanCapabilities { submit: true })
6890        );
6891    }
6892
6893    /// `submit: false` and an absent `plan:` block both leave the capsule ungranted; the
6894    /// difference between them is only whether the operator wrote the denial down.
6895    #[test]
6896    fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
6897        let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: false\n"))
6898            .expect("an explicit denial parses");
6899        assert_eq!(
6900            explicit.capabilities.unwrap().plan,
6901            Some(PlanCapabilities { submit: false })
6902        );
6903
6904        let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
6905            .expect("no capabilities block parses");
6906        assert!(absent.capabilities.is_none());
6907    }
6908
6909    /// The capability is never inferred: a `plan:` block that omits `submit:` is a parse error
6910    /// naming the key, exactly as `task_io:` without `read:` is.
6911    #[test]
6912    fn plan_without_submit_is_rejected() {
6913        let err = RuntimeManifest::from_yaml_str(&manifest_with_plan("    {}\n"))
6914            .expect_err("an empty plan block is refused");
6915        let msg = err.to_string();
6916        assert!(msg.contains("capabilities.plan.submit"), "{msg}");
6917        assert!(msg.contains("never"), "{msg}");
6918    }
6919
6920    /// An unknown key inside the block is reported by path, like every other capability block.
6921    #[test]
6922    fn an_unknown_plan_key_is_reported_by_path() {
6923        let manifest =
6924            RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n    depth: 3\n"))
6925                .expect("an unknown key is a warning, not a parse error");
6926        assert!(
6927            manifest
6928                .unknown_keys
6929                .iter()
6930                .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
6931            "{:?}",
6932            manifest.unknown_keys
6933        );
6934    }
6935
6936    // ── capabilities.install (capsule-wide) ──────────────────────────────────
6937
6938    /// A capsule manifest whose capsule-wide capabilities carry `install: <block>`.
6939    fn manifest_with_install(block: &str) -> String {
6940        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n{block}")
6941    }
6942
6943    #[test]
6944    fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
6945        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
6946            "    skill: [code-review, \"style-*\"]\n    tool: [\"b-*\", a, \"*\"]\n",
6947        ))
6948        .expect("an install block parses");
6949        assert_eq!(
6950            manifest.capabilities.unwrap().install,
6951            Some(InstallCapabilities {
6952                skill: vec!["code-review".into(), "style-*".into()],
6953                tool: vec!["b-*".into(), "a".into(), "*".into()],
6954            })
6955        );
6956    }
6957
6958    /// Either list alone is a grant; the other defaults to empty, which admits nothing of its
6959    /// kind.
6960    #[test]
6961    fn capabilities_install_accepts_either_list_alone() {
6962        let skill_only = RuntimeManifest::from_yaml_str(&manifest_with_install("    skill: [a]\n"))
6963            .expect("skill alone parses");
6964        assert_eq!(
6965            skill_only.capabilities.unwrap().install,
6966            Some(InstallCapabilities {
6967                skill: vec!["a".into()],
6968                tool: vec![],
6969            })
6970        );
6971        let tool_only =
6972            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [\"b-*\"]\n"))
6973                .expect("tool alone parses");
6974        assert_eq!(
6975            tool_only.capabilities.unwrap().install,
6976            Some(InstallCapabilities {
6977                skill: vec![],
6978                tool: vec!["b-*".into()],
6979            })
6980        );
6981    }
6982
6983    #[test]
6984    fn capabilities_install_absent_is_none() {
6985        let manifest = RuntimeManifest::from_yaml_str(
6986            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n    submit: true\n",
6987        )
6988        .expect("a capabilities block without install parses");
6989        assert_eq!(manifest.capabilities.unwrap().install, None);
6990    }
6991
6992    /// A block that names nothing is refused rather than read as "grant nothing": omitting the
6993    /// block is how a capsule grants nothing.
6994    #[test]
6995    fn capabilities_install_that_names_nothing_is_refused() {
6996        for block in [
6997            "    {}\n",
6998            "    skill: []\n",
6999            "    skill: []\n    tool: []\n",
7000            "    driver: [x]\n",
7001        ] {
7002            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
7003                .expect_err("an install block naming nothing is refused");
7004            match &err {
7005                RuntimeManifestError::InvalidCapabilities { field, message } => {
7006                    assert_eq!(field, "capabilities.install", "{block:?}");
7007                    assert!(message.contains("at least one"), "{message}");
7008                    assert!(message.contains("omit the block"), "{message}");
7009                }
7010                other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
7011            }
7012        }
7013    }
7014
7015    #[test]
7016    fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
7017        for entry in ["a*b", "*x", "**", "Foo", "-a", "a/b", " "] {
7018            for key in ["skill", "tool"] {
7019                let block = format!("    {key}: [{entry:?}]\n");
7020                let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
7021                    .expect_err("a bad entry is refused");
7022                match &err {
7023                    RuntimeManifestError::InvalidCapabilities { field, message } => {
7024                        assert_eq!(field, &format!("capabilities.install.{key}"));
7025                        assert!(message.contains(&format!("{entry:?}")), "{message}");
7026                        assert!(
7027                            message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
7028                            "{message}"
7029                        );
7030                    }
7031                    other => panic!("{entry:?}: expected InvalidCapabilities, got {other:?}"),
7032                }
7033            }
7034        }
7035    }
7036
7037    /// The grant is held by the session, so a per-artifact block is refused on every role.
7038    #[test]
7039    fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
7040        for runtime in ["tool", "hook", "driver", "skill"] {
7041            let yaml = format!(
7042                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: a\n    version: 0.1.0\n    \
7043                 runtime: {runtime}\n    capabilities:\n      install:\n        skill: [x]\n"
7044            );
7045            let err = RuntimeManifest::from_yaml_str(&yaml)
7046                .expect_err("a per-artifact install block is refused");
7047            match &err {
7048                RuntimeManifestError::InvalidArtifact { index, message } => {
7049                    assert_eq!(*index, 0);
7050                    assert!(message.contains("capabilities.install"), "{message}");
7051                    assert!(message.contains("capsule-wide"), "{message}");
7052                }
7053                other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
7054            }
7055        }
7056    }
7057
7058    /// An unknown key inside the block is reported by path and grants nothing.
7059    #[test]
7060    fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
7061        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7062            "    skill: [a]\n    driver: [x]\n",
7063        ))
7064        .expect("an unknown key beside a real entry is a warning, not a parse error");
7065        assert!(
7066            manifest
7067                .unknown_keys
7068                .iter()
7069                .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
7070            "{:?}",
7071            manifest.unknown_keys
7072        );
7073        assert_eq!(
7074            manifest.capabilities.unwrap().install,
7075            Some(InstallCapabilities {
7076                skill: vec!["a".into()],
7077                tool: vec![],
7078            })
7079        );
7080    }
7081
7082    // ── capabilities.task_io (per-hook, honored only on runtime: hook) ────────
7083
7084    /// A capsule manifest with one artifact entry carrying `capabilities: task_io: <block>`.
7085    fn manifest_with_task_io(runtime: &str, block: &str) -> String {
7086        format!(
7087            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: gate\n    version: 1.2.3\n    \
7088             runtime: {runtime}\n    capabilities:\n      task_io:\n{block}"
7089        )
7090    }
7091
7092    #[test]
7093    fn task_io_read_true_grants_the_hook() {
7094        let manifest =
7095            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: true\n"))
7096                .expect("task_io on a hook entry parses");
7097        assert_eq!(
7098            manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
7099            Some(TaskIoCapabilities { read: true })
7100        );
7101    }
7102
7103    /// `read: false` and an absent `task_io:` block both leave the hook ungranted; the
7104    /// difference between them is only whether the operator wrote the denial down.
7105    #[test]
7106    fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
7107        let explicit =
7108            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: false\n"))
7109                .expect("an explicit denial parses");
7110        assert_eq!(
7111            explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
7112            Some(TaskIoCapabilities { read: false })
7113        );
7114
7115        let absent = RuntimeManifest::from_yaml_str(
7116            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: gate\n    version: 1.2.3\n    \
7117             runtime: hook\n",
7118        )
7119        .expect("no capabilities block parses");
7120        assert!(absent.artifacts[0].capabilities.is_none());
7121    }
7122
7123    /// The capability is never inferred: a `task_io:` block that omits `read:` is a parse
7124    /// error naming the key, following `interpreter_runtime[].dirs[].list_dir`.
7125    #[test]
7126    fn task_io_without_read_is_rejected() {
7127        let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        {}\n"))
7128            .unwrap_err();
7129        let msg = err.to_string();
7130        assert!(
7131            msg.contains("capabilities.task_io.read"),
7132            "error was: {msg}"
7133        );
7134        assert!(msg.contains("explicitly"), "error was: {msg}");
7135    }
7136
7137    /// Nothing outside a hook can be handed the import, so declaring the key there is a parse
7138    /// error rather than a silently inert grant.
7139    #[test]
7140    fn task_io_outside_a_hook_entry_is_rejected() {
7141        for runtime in ["tool", "driver", "skill"] {
7142            let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
7143                runtime,
7144                "        read: true\n",
7145            ))
7146            .unwrap_err();
7147            let msg = err.to_string();
7148            assert!(
7149                msg.contains("capabilities") && msg.contains("runtime: hook"),
7150                "{runtime} entry: error was: {msg}"
7151            );
7152        }
7153    }
7154
7155    /// The capsule-wide block reaches capsule, tool and driver components, none of which can
7156    /// receive the import — so it is rejected there too, naming the key.
7157    #[test]
7158    fn task_io_in_the_capsule_wide_block_is_rejected() {
7159        let err = RuntimeManifest::from_yaml_str(
7160            "name: cap\nversion: 0.0.1\ncapabilities:\n  task_io:\n    read: true\n",
7161        )
7162        .unwrap_err();
7163        let msg = err.to_string();
7164        assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
7165        assert!(msg.contains("runtime: hook"), "error was: {msg}");
7166    }
7167
7168    // ── capabilities.conversation (per-hook, honored only on runtime: hook) ───
7169
7170    /// A capsule manifest with one artifact entry carrying `capabilities: conversation: <block>`.
7171    fn manifest_with_conversation(runtime: &str, block: &str) -> String {
7172        format!(
7173            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: memory\n    version: 1.2.3\n    \
7174             runtime: {runtime}\n    capabilities:\n      conversation:\n{block}"
7175        )
7176    }
7177
7178    #[test]
7179    fn conversation_read_lowers_onto_the_hook_entry() {
7180        for (declared, expected) in [("true", true), ("false", false)] {
7181            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
7182                "hook",
7183                &format!("        read: {declared}\n"),
7184            ))
7185            .expect("conversation on a hook entry parses");
7186            assert_eq!(
7187                manifest.artifacts[0]
7188                    .capabilities
7189                    .as_ref()
7190                    .unwrap()
7191                    .conversation,
7192                Some(ConversationCapabilities { read: expected })
7193            );
7194        }
7195    }
7196
7197    /// The capability is never inferred: a block that omits `read:` is a parse error naming the
7198    /// key, exactly as `capabilities.task_io.read` is.
7199    #[test]
7200    fn conversation_without_read_is_rejected() {
7201        let err =
7202            RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", "        {}\n"))
7203                .unwrap_err();
7204        let msg = err.to_string();
7205        assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
7206        assert!(msg.contains("explicitly"), "was: {msg}");
7207    }
7208
7209    /// Only a hook's world can import the interface, so declaring the grant on any other role is
7210    /// a parse error rather than a silently inert block.
7211    #[test]
7212    fn conversation_outside_a_hook_entry_is_rejected() {
7213        for runtime in ["tool", "driver", "skill"] {
7214            let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
7215                runtime,
7216                "        read: true\n",
7217            ))
7218            .unwrap_err();
7219            let msg = err.to_string();
7220            assert!(
7221                msg.contains("capabilities") && msg.contains("runtime: hook"),
7222                "{runtime} entry: error was: {msg}"
7223            );
7224        }
7225    }
7226
7227    /// Unlike `task_io`, the capsule-wide block parses: it is inert, and the runtime says so once
7228    /// as `W-SEC-016` rather than refusing a manifest an operator can fix at leisure.
7229    #[test]
7230    fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
7231        let manifest = RuntimeManifest::from_yaml_str(
7232            "name: cap\nversion: 0.0.1\ncapabilities:\n  conversation:\n    read: true\n",
7233        )
7234        .expect("a capsule-wide block is accepted");
7235        assert_eq!(
7236            manifest.capabilities.unwrap().conversation,
7237            Some(ConversationCapabilities { read: true })
7238        );
7239    }
7240
7241    // ── context.record / context.record_store ────────────────────────────────
7242
7243    /// The record is on unless the manifest says otherwise, and `record_store` defaults to
7244    /// nothing — the runtime substitutes the capsule name.
7245    #[test]
7246    fn a_context_block_records_by_default() {
7247        let context = context_of("context:\n  max_tokens: 1000\n").unwrap();
7248        assert!(context.record);
7249        assert_eq!(context.record_store, None);
7250    }
7251
7252    #[test]
7253    fn record_off_and_on_are_the_two_accepted_values() {
7254        assert!(!context_of("context:\n  record: off\n").unwrap().record);
7255        assert!(context_of("context:\n  record: on\n").unwrap().record);
7256
7257        let err = RuntimeManifest::from_yaml_str(
7258            "name: cap\nversion: 0.0.1\ncontext:\n  record: sometimes\n",
7259        )
7260        .unwrap_err();
7261        let msg = err.to_string();
7262        assert!(msg.contains("context.record"), "was: {msg}");
7263        assert!(msg.contains("sometimes"), "was: {msg}");
7264    }
7265
7266    /// `record_store` is a name the runtime turns into a directory, so it is kept verbatim apart
7267    /// from surrounding whitespace; its *shape* is refused at launch, where the path is built.
7268    #[test]
7269    fn record_store_is_kept_verbatim_and_trimmed() {
7270        assert_eq!(
7271            context_of("context:\n  record_store: '  shey  '\n")
7272                .unwrap()
7273                .record_store,
7274            Some("shey".to_string())
7275        );
7276        assert_eq!(
7277            context_of("context:\n  record: off\n  record_store: shey\n")
7278                .unwrap()
7279                .record_store,
7280            Some("shey".to_string()),
7281            "a store declared beside record: off is kept and simply never used"
7282        );
7283    }
7284
7285    #[test]
7286    fn interpreter_runtime_rejects_missing_list_dir() {
7287        let msg = interpreter_runtime_reject(
7288            "      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.11\n",
7289        );
7290        assert!(
7291            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
7292            "{msg}"
7293        );
7294        assert!(msg.contains("explicitly"), "{msg}");
7295    }
7296
7297    #[test]
7298    fn interpreter_runtime_rejects_empty_dirs() {
7299        let msg = interpreter_runtime_reject("      - binary: python3\n        dirs: []\n");
7300        assert!(
7301            msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
7302            "{msg}"
7303        );
7304        assert!(msg.contains("at least one"), "{msg}");
7305    }
7306
7307    #[test]
7308    fn staged_runtime_parses_accepted_shape() {
7309        let manifest = RuntimeManifest::from_yaml_str(
7310            r#"
7311name: cap
7312version: 0.0.1
7313artifacts: []
7314capabilities:
7315  containment: sealed
7316  shell:
7317    allow:
7318      - python3
7319      - bash
7320    staged_runtime:
7321      - binary: python3
7322        source_path: /opt/testbed/conda/envs/django__django
7323        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
7324"#,
7325        )
7326        .unwrap();
7327
7328        let shell = manifest.capabilities.unwrap().shell.unwrap();
7329        assert_eq!(
7330            shell.staged_runtime,
7331            vec![StagedRuntimeGrant {
7332                binary: "python3".to_string(),
7333                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
7334                pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
7335            }]
7336        );
7337        // The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
7338        // interpreter_runtime list untouched rather than populating it as a side effect.
7339        assert!(shell.interpreter_runtime.is_empty());
7340    }
7341
7342    #[test]
7343    fn staged_runtime_defaults_to_empty_when_absent() {
7344        let manifest = RuntimeManifest::from_yaml_str(
7345            r#"
7346name: cap
7347version: 0.0.1
7348artifacts: []
7349capabilities:
7350  shell:
7351    allow:
7352      - python3
7353"#,
7354        )
7355        .unwrap();
7356
7357        let shell = manifest.capabilities.unwrap().shell.unwrap();
7358        assert!(shell.staged_runtime.is_empty());
7359    }
7360
7361    /// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
7362    /// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
7363    /// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
7364    fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
7365        let yaml = format!(
7366            r#"
7367name: cap
7368version: 0.0.1
7369artifacts: []
7370capabilities:
7371  shell:
7372    allow:
7373      - python3
7374{extra}    staged_runtime:
7375{grant_yaml}
7376"#,
7377        );
7378        RuntimeManifest::from_yaml_str(&yaml)
7379            .expect_err("grant should be rejected")
7380            .to_string()
7381    }
7382
7383    #[test]
7384    fn staged_runtime_rejects_binary_not_in_allow() {
7385        let msg = staged_runtime_reject(
7386            "      - binary: ruby\n        source_path: /opt/ruby\n        pin: ruby-3.2.2\n",
7387            "",
7388        );
7389        assert!(
7390            msg.contains("capabilities.shell.staged_runtime[0].binary"),
7391            "{msg}"
7392        );
7393        assert!(msg.contains("ruby"), "{msg}");
7394        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
7395    }
7396
7397    #[test]
7398    fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
7399        let msg = staged_runtime_reject(
7400            "      - binary: python3\n        source_path: /opt/py\n        pin: cpython-3.9.19\n",
7401            "    interpreter_runtime:\n      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.9\n            list_dir: true\n",
7402        );
7403        assert!(
7404            msg.contains("capabilities.shell.staged_runtime[0].binary"),
7405            "{msg}"
7406        );
7407        assert!(msg.contains("python3"), "{msg}");
7408        assert!(msg.contains("interpreter_runtime"), "{msg}");
7409        assert!(msg.contains("mutually exclusive"), "{msg}");
7410    }
7411
7412    #[test]
7413    fn staged_runtime_rejects_relative_source_path() {
7414        let msg = staged_runtime_reject(
7415            "      - binary: python3\n        source_path: opt/testbed/conda\n        pin: cpython-3.9.19\n",
7416            "",
7417        );
7418        assert!(
7419            msg.contains("capabilities.shell.staged_runtime[0].source_path"),
7420            "{msg}"
7421        );
7422        assert!(msg.contains("absolute"), "{msg}");
7423    }
7424
7425    #[test]
7426    fn staged_runtime_rejects_missing_or_empty_pin() {
7427        // Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
7428        // precisely so that state is unrepresentable.
7429        for grant in [
7430            "      - binary: python3\n        source_path: /opt/testbed/conda\n",
7431            "      - binary: python3\n        source_path: /opt/testbed/conda\n        pin: \"   \"\n",
7432        ] {
7433            let msg = staged_runtime_reject(grant, "");
7434            assert!(
7435                msg.contains("capabilities.shell.staged_runtime[0].pin"),
7436                "{msg}"
7437            );
7438            assert!(msg.contains("never inferred"), "{msg}");
7439        }
7440    }
7441
7442    #[test]
7443    fn parses_inference_block() {
7444        let manifest = RuntimeManifest::from_yaml_str(
7445            r#"
7446name: cap
7447version: 0.0.1
7448artifacts:
7449  - name: murmur-driver-anthropic
7450    version: 0.1.0
7451    runtime: driver
7452    gateway:
7453      endpoint: http://127.0.0.1:8080
7454      api_key: literal-key
7455inference:
7456  transport: http
7457  model: test-model
7458  driver:
7459    artifact: murmur-driver-anthropic
7460    config:
7461      temperature: 0.2
7462      headers:
7463        x-test: true
7464"#,
7465        )
7466        .unwrap();
7467
7468        assert_eq!(
7469            manifest.artifacts[0].gateway,
7470            Some(ArtifactGateway {
7471                endpoint: "http://127.0.0.1:8080".to_string(),
7472                api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
7473                keyless: false,
7474            })
7475        );
7476        let inference = manifest.inference.expect("inference should exist");
7477        assert_eq!(inference.transport, "http");
7478        assert_eq!(inference.model, "test-model");
7479        let driver = inference.driver.as_ref().expect("driver should be present");
7480        assert_eq!(driver.artifact, "murmur-driver-anthropic");
7481        assert_eq!(
7482            driver.config,
7483            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
7484        );
7485        assert!(inference.system_prompt.is_none());
7486        assert!(inference.system_prompt_file.is_none());
7487    }
7488
7489    /// A capsule whose one tool entry declares `gateway:` with `block` as its body, indented
7490    /// under the key.
7491    fn tool_gateway_manifest(block: &str) -> String {
7492        format!(
7493            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: web-search\n    version: 0.1.0\n    runtime: tool\n    gateway:{block}\n"
7494        )
7495    }
7496
7497    fn gateway_endpoint_error(endpoint: &str) -> String {
7498        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
7499            "\n      endpoint: \"{endpoint}\""
7500        )))
7501        .unwrap_err()
7502        .to_string()
7503    }
7504
7505    #[test]
7506    fn gateway_endpoint_https_is_accepted() {
7507        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7508            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
7509        ))
7510        .unwrap();
7511        assert_eq!(
7512            manifest.artifacts[0].gateway,
7513            Some(ArtifactGateway {
7514                endpoint: "https://api.tavily.com".to_string(),
7515                api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
7516                keyless: false,
7517            })
7518        );
7519    }
7520
7521    #[test]
7522    fn gateway_endpoint_http_localhost_is_accepted() {
7523        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7524            "\n      endpoint: http://localhost:11434\n      keyless: true",
7525        ))
7526        .unwrap();
7527        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
7528        assert_eq!(gateway.endpoint, "http://localhost:11434");
7529        assert_eq!(gateway.api_key, None);
7530        assert!(gateway.keyless);
7531    }
7532
7533    #[test]
7534    fn gateway_endpoint_http_loopback_ip_is_accepted() {
7535        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7536            "\n      endpoint: http://127.0.0.1:8080\n      api_key: ${LOCAL_KEY}",
7537        ))
7538        .unwrap();
7539        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
7540        assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
7541        assert_eq!(
7542            gateway.api_key,
7543            Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
7544        );
7545    }
7546
7547    #[test]
7548    fn gateway_endpoint_without_scheme_is_refused() {
7549        let msg = gateway_endpoint_error("api.tavily.com");
7550        assert!(msg.contains("gateway.endpoint"), "{msg}");
7551        assert!(msg.contains("artifact 'web-search'"), "{msg}");
7552        assert!(msg.contains("does not parse as a URL"), "{msg}");
7553    }
7554
7555    #[test]
7556    fn gateway_endpoint_ftp_scheme_is_refused() {
7557        let msg = gateway_endpoint_error("ftp://example.com");
7558        assert!(msg.contains("gateway.endpoint"), "{msg}");
7559        assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
7560    }
7561
7562    #[test]
7563    fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
7564        let msg = gateway_endpoint_error("http://api.attacker.example.com");
7565        assert!(msg.contains("gateway.endpoint"), "{msg}");
7566        assert!(msg.contains("api.attacker.example.com"), "{msg}");
7567        assert!(msg.contains("non-loopback"), "{msg}");
7568    }
7569
7570    #[test]
7571    fn gateway_endpoint_with_a_query_is_refused() {
7572        let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
7573        assert!(msg.contains("gateway.endpoint"), "{msg}");
7574        assert!(msg.contains("carries a query"), "{msg}");
7575    }
7576
7577    #[test]
7578    fn gateway_endpoint_with_a_fragment_is_refused() {
7579        let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
7580        assert!(msg.contains("gateway.endpoint"), "{msg}");
7581        assert!(msg.contains("carries a fragment"), "{msg}");
7582    }
7583
7584    #[test]
7585    fn gateway_endpoint_with_userinfo_is_refused() {
7586        let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
7587        assert!(msg.contains("artifact 'web-search'"), "{msg}");
7588        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
7589        assert!(msg.contains("host 'api.example.com'"), "{msg}");
7590        assert!(msg.contains("gateway.api_key"), "{msg}");
7591        assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
7592        assert!(
7593            !msg.contains("sk-in-url"),
7594            "a password in the URL is never echoed: {msg}"
7595        );
7596    }
7597
7598    #[test]
7599    fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
7600        let msg = gateway_endpoint_error("https://user:sk-in-url@");
7601        assert!(msg.contains("does not parse as a URL"), "{msg}");
7602        assert!(
7603            !msg.contains("sk-in-url"),
7604            "a password in the URL is never echoed: {msg}"
7605        );
7606    }
7607
7608    #[test]
7609    fn gateway_endpoint_with_a_masquerading_host_is_refused() {
7610        let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
7611        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
7612        assert!(
7613            msg.contains("addresses host '127.0.0.1'"),
7614            "the message names the host the URL really addresses: {msg}"
7615        );
7616    }
7617
7618    #[test]
7619    fn gateway_endpoint_with_interpolation_is_refused() {
7620        let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
7621        assert!(msg.contains("artifact 'web-search'"), "{msg}");
7622        assert!(msg.contains("contains '${'"), "{msg}");
7623        assert!(msg.contains("not interpolated"), "{msg}");
7624    }
7625
7626    #[test]
7627    fn gateway_endpoint_http_unspecified_address_is_refused() {
7628        let msg = gateway_endpoint_error("http://0.0.0.0:8080");
7629        assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
7630    }
7631
7632    #[test]
7633    fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
7634        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7635            "\n      endpoint: http://[::1]:8080\n      api_key: ${LOCAL_KEY}",
7636        ))
7637        .unwrap();
7638        assert_eq!(
7639            manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
7640            "http://[::1]:8080"
7641        );
7642    }
7643
7644    /// The error a tool entry whose `gateway:` body is `block` draws, parsed with secrets resolved.
7645    fn gateway_credential_error(block: &str) -> RuntimeManifestError {
7646        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
7647    }
7648
7649    fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
7650        match &error {
7651            RuntimeManifestError::GatewayWithoutCredential {
7652                index,
7653                name,
7654                endpoint: refused,
7655            } => {
7656                assert_eq!(*index, 0);
7657                assert_eq!(name, "web-search");
7658                assert_eq!(refused, endpoint);
7659            }
7660            other => panic!("expected GatewayWithoutCredential, got {other:?}"),
7661        }
7662        let msg = error.to_string();
7663        assert!(
7664            msg.contains(&format!(
7665                "artifact 'web-search' declares gateway.endpoint '{endpoint}' but binds no \
7666                 credential"
7667            )),
7668            "{msg}"
7669        );
7670    }
7671
7672    #[test]
7673    fn gateway_credential_absent_is_refused() {
7674        assert_without_credential(
7675            gateway_credential_error("\n      endpoint: https://api.tavily.com"),
7676            "https://api.tavily.com",
7677        );
7678    }
7679
7680    #[test]
7681    fn gateway_credential_blank_is_refused() {
7682        for key in ["\"\"", "\"   \"", "''"] {
7683            assert_without_credential(
7684                gateway_credential_error(&format!(
7685                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
7686                )),
7687                "https://api.tavily.com",
7688            );
7689        }
7690    }
7691
7692    #[test]
7693    fn gateway_credential_null_is_refused() {
7694        for key in ["~", "null", ""] {
7695            assert_without_credential(
7696                gateway_credential_error(&format!(
7697                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
7698                )),
7699                "https://api.tavily.com",
7700            );
7701        }
7702    }
7703
7704    #[test]
7705    fn gateway_credential_keyless_false_is_refused() {
7706        assert_without_credential(
7707            gateway_credential_error(
7708                "\n      endpoint: https://api.tavily.com\n      keyless: false",
7709            ),
7710            "https://api.tavily.com",
7711        );
7712    }
7713
7714    #[test]
7715    fn gateway_credential_keyless_is_accepted() {
7716        for (parse, mode) in [
7717            (
7718                RuntimeManifest::from_yaml_str as fn(&str) -> _,
7719                "with secrets",
7720            ),
7721            (
7722                RuntimeManifest::from_yaml_str_without_secrets,
7723                "without secrets",
7724            ),
7725        ] {
7726            let manifest = parse(&tool_gateway_manifest(
7727                "\n      endpoint: https://api.example.com\n      keyless: true",
7728            ))
7729            .unwrap();
7730            assert_eq!(
7731                manifest.artifacts[0].gateway,
7732                Some(ArtifactGateway {
7733                    endpoint: "https://api.example.com".to_string(),
7734                    api_key: None,
7735                    keyless: true,
7736                }),
7737                "{mode}"
7738            );
7739        }
7740    }
7741
7742    #[test]
7743    fn gateway_credential_keyless_not_a_boolean_is_refused() {
7744        for value in ["\"true\"", "yes please", "1"] {
7745            let error = gateway_credential_error(&format!(
7746                "\n      endpoint: https://api.tavily.com\n      keyless: {value}"
7747            ));
7748            assert!(
7749                matches!(
7750                    error,
7751                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
7752                ),
7753                "{value}: {error:?}"
7754            );
7755            assert!(
7756                error
7757                    .to_string()
7758                    .contains("gateway.keyless with a value that is not a boolean"),
7759                "{error}"
7760            );
7761        }
7762    }
7763
7764    #[test]
7765    fn gateway_credential_and_keyless_together_are_refused() {
7766        for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
7767            let error = gateway_credential_error(&format!(
7768                "\n      endpoint: https://api.tavily.com\n      api_key: {key}\n      keyless: true"
7769            ));
7770            assert!(
7771                matches!(
7772                    error,
7773                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
7774                ),
7775                "{error:?}"
7776            );
7777            assert!(
7778                error.to_string().contains(
7779                    "artifact 'web-search' declares both gateway.api_key and gateway.keyless: \
7780                     true; a gateway either presents a key or is keyless"
7781                ),
7782                "{error}"
7783            );
7784        }
7785    }
7786
7787    #[test]
7788    fn gateway_credential_absent_is_refused_without_secrets() {
7789        for block in [
7790            "\n      endpoint: https://api.tavily.com",
7791            "\n      endpoint: https://api.tavily.com\n      api_key: \"\"",
7792            "\n      endpoint: https://api.tavily.com\n      keyless: false",
7793        ] {
7794            assert_without_credential(
7795                RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
7796                    .unwrap_err(),
7797                "https://api.tavily.com",
7798            );
7799        }
7800        let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
7801            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
7802        ))
7803        .unwrap();
7804        let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
7805        assert_eq!(gateway.api_key, None, "the key reference is left unread");
7806        assert!(!gateway.keyless, "a keyed gateway is never keyless");
7807    }
7808
7809    /// The refusal reads what the manifest wrote, never the address: a loopback or private
7810    /// endpoint with no credential is refused exactly as a public one is.
7811    #[test]
7812    fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
7813        for endpoint in [
7814            "http://127.0.0.1:11434",
7815            "http://localhost:11434",
7816            "http://[::1]:11434",
7817            "https://10.0.0.5",
7818            "https://192.168.1.10:8443",
7819        ] {
7820            assert_without_credential(
7821                gateway_credential_error(&format!("\n      endpoint: {endpoint}")),
7822                endpoint,
7823            );
7824        }
7825    }
7826
7827    #[test]
7828    fn gateway_endpoint_missing_from_the_block_is_refused() {
7829        for block in [
7830            "\n      api_key: ${TAVILY_API_KEY}",
7831            "",
7832            " {}",
7833            "\n      endpoint: \"  \"",
7834        ] {
7835            let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
7836                .unwrap_err()
7837                .to_string();
7838            assert!(
7839                msg.contains("without 'gateway.endpoint'"),
7840                "{block:?}: {msg}"
7841            );
7842            assert!(msg.contains("artifact 'web-search'"), "{block:?}: {msg}");
7843        }
7844    }
7845
7846    #[test]
7847    fn gateway_block_unknown_key_is_reported() {
7848        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7849            "\n      endpoint: https://api.tavily.com\n      keyless: true\n      api_kee: x",
7850        ))
7851        .unwrap();
7852        let unknown = manifest.unknown_keys;
7853        assert_eq!(unknown.len(), 1, "{unknown:?}");
7854        assert_eq!(unknown[0].key, "api_kee");
7855        assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
7856    }
7857
7858    #[test]
7859    fn gateway_on_skill_entry_is_refused() {
7860        let msg = RuntimeManifest::from_yaml_str(
7861            "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",
7862        )
7863        .unwrap_err()
7864        .to_string();
7865        assert!(
7866            msg.contains("artifact 'notes' declares 'gateway:'"),
7867            "{msg}"
7868        );
7869        assert!(msg.contains("runtime: skill"), "{msg}");
7870    }
7871
7872    #[test]
7873    fn gateway_on_hook_entry_is_accepted() {
7874        let manifest = RuntimeManifest::from_yaml_str(
7875            "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",
7876        )
7877        .unwrap();
7878        assert!(manifest.artifacts[0].gateway.is_some());
7879    }
7880
7881    /// An `http` driver capsule. `driver_entry` is the YAML of the driver's artifact entry, or
7882    /// empty for none.
7883    fn http_driver_manifest(artifacts: &str) -> String {
7884        format!(
7885            "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur-driver-anthropic\n"
7886        )
7887    }
7888
7889    #[test]
7890    fn http_driver_entry_without_gateway_endpoint_is_refused() {
7891        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
7892            "\n  - name: murmur-driver-anthropic\n    version: 0.1.0\n    runtime: driver",
7893        ))
7894        .unwrap_err()
7895        .to_string();
7896        assert!(msg.contains("'murmur-driver-anthropic'"), "{msg}");
7897        assert!(msg.contains("gateway.endpoint"), "{msg}");
7898    }
7899
7900    #[test]
7901    fn http_driver_absent_from_artifacts_is_refused() {
7902        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
7903            .unwrap_err()
7904            .to_string();
7905        assert!(msg.contains("inference.driver.artifact"), "{msg}");
7906        assert!(msg.contains("not declared in artifacts:"), "{msg}");
7907    }
7908
7909    #[test]
7910    fn gateway_on_unconfigured_driver_entry_is_refused() {
7911        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
7912            "\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",
7913        ))
7914        .unwrap_err()
7915        .to_string();
7916        assert!(msg.contains("'murmur-driver-openai'"), "{msg}");
7917        assert!(
7918            msg.contains("accepted only on the entry inference.driver.artifact names"),
7919            "{msg}"
7920        );
7921
7922        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";
7923        let msg = RuntimeManifest::from_yaml_str(no_inference)
7924            .unwrap_err()
7925            .to_string();
7926        assert!(msg.contains("not the configured inference driver"), "{msg}");
7927    }
7928
7929    #[test]
7930    fn gateway_on_driver_entry_under_process_transport_is_refused() {
7931        let msg = RuntimeManifest::from_yaml_str(
7932            "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",
7933        )
7934        .unwrap_err()
7935        .to_string();
7936        assert!(msg.contains("'murmur-driver-anthropic'"), "{msg}");
7937        assert!(msg.contains("transport: process"), "{msg}");
7938    }
7939
7940    #[test]
7941    fn inference_api_key_is_refused_naming_gateway_api_key() {
7942        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";
7943        for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
7944            let manifest = http_driver_manifest(entry)
7945                .replace("  model:", &format!("  api_key: {value}\n  model:"));
7946            let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
7947            let msg = err.to_string();
7948            assert!(msg.contains("'inference.api_key'"), "{msg}");
7949            assert!(msg.contains("was removed"), "{msg}");
7950            assert!(
7951                msg.contains(
7952                    "set gateway.api_key on the artifacts: entry 'murmur-driver-anthropic'"
7953                ),
7954                "{msg}"
7955            );
7956            assert!(!msg.contains("sk-literal"), "{msg}");
7957        }
7958
7959        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  api_key: ${KEY}\n";
7960        let msg = RuntimeManifest::from_yaml_str(process)
7961            .unwrap_err()
7962            .to_string();
7963        assert!(msg.contains("'inference.api_key'"), "{msg}");
7964        assert!(
7965            msg.contains("on the artifacts: entry inference.driver.artifact names"),
7966            "{msg}"
7967        );
7968        assert!(
7969            RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
7970            "the refusal does not depend on resolving secrets"
7971        );
7972    }
7973
7974    #[test]
7975    fn inference_endpoint_is_refused_naming_gateway_endpoint() {
7976        let manifest = http_driver_manifest(
7977            "\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",
7978        )
7979        .replace("  model:", "  endpoint: https://api.anthropic.com\n  model:");
7980        let msg = RuntimeManifest::from_yaml_str(&manifest)
7981            .unwrap_err()
7982            .to_string();
7983        assert!(msg.contains("'inference.endpoint'"), "{msg}");
7984        assert!(
7985            msg.contains("set gateway.endpoint on the artifacts: entry 'murmur-driver-anthropic'"),
7986            "{msg}"
7987        );
7988
7989        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  endpoint: http://localhost:8080\n";
7990        let msg = RuntimeManifest::from_yaml_str(process)
7991            .unwrap_err()
7992            .to_string();
7993        assert!(msg.contains("'inference.endpoint'"), "{msg}");
7994        assert!(msg.contains("was removed"), "{msg}");
7995    }
7996
7997    #[test]
7998    fn without_secrets_leaves_gateway_api_key_unread() {
7999        let manifest = tool_gateway_manifest(
8000            "\n      endpoint: https://api.tavily.com\n      api_key: tvly-literal",
8001        );
8002        let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
8003        assert_eq!(
8004            resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
8005            Some(ApiKeyReference::Literal("tvly-literal".to_string()))
8006        );
8007        let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
8008        let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
8009        assert_eq!(gateway.endpoint, "https://api.tavily.com");
8010        assert_eq!(gateway.api_key, None);
8011    }
8012
8013    #[test]
8014    fn referenced_env_variables_reads_gateway_api_key() {
8015        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";
8016        let referenced = referenced_env_variables(manifest);
8017        assert_eq!(
8018            referenced,
8019            vec![
8020                ReferencedEnvVariable {
8021                    field: "artifacts.murmur-driver-anthropic.gateway.api_key".to_string(),
8022                    variable: "ANTHROPIC_API_KEY".to_string(),
8023                },
8024                ReferencedEnvVariable {
8025                    field: "artifacts.web-search.gateway.api_key".to_string(),
8026                    variable: "TAVILY_API_KEY".to_string(),
8027                },
8028            ]
8029        );
8030        assert!(referenced
8031            .iter()
8032            .all(ReferencedEnvVariable::is_gateway_credential));
8033    }
8034
8035    #[test]
8036    fn parses_inline_system_prompt() {
8037        let manifest = RuntimeManifest::from_yaml_str(
8038            r#"
8039name: cap
8040version: 0.0.1
8041artifacts:
8042  - name: murmur-driver-anthropic
8043    version: 0.1.0
8044    runtime: driver
8045    gateway:
8046      endpoint: http://127.0.0.1:8080
8047      keyless: true
8048inference:
8049  model: test-model
8050  system_prompt: "Always begin with CONFIRMED:"
8051  driver:
8052    artifact: murmur-driver-anthropic
8053"#,
8054        )
8055        .unwrap();
8056
8057        let inference = manifest.inference.unwrap();
8058        assert_eq!(
8059            inference.system_prompt,
8060            Some("Always begin with CONFIRMED:".to_string())
8061        );
8062        assert!(inference.system_prompt_file.is_none());
8063    }
8064
8065    #[test]
8066    fn parses_compaction_system_prompt() {
8067        let manifest = RuntimeManifest::from_yaml_str(
8068            r#"
8069name: cap
8070version: 0.0.1
8071artifacts:
8072  - name: murmur-driver-anthropic
8073    version: 0.1.0
8074    runtime: driver
8075    gateway:
8076      endpoint: http://127.0.0.1:8080
8077      keyless: true
8078inference:
8079  model: test-model
8080  compaction:
8081    model: compaction-model
8082    system_prompt: "task = X, currently editing Y, already tried Z."
8083  driver:
8084    artifact: murmur-driver-anthropic
8085"#,
8086        )
8087        .unwrap();
8088
8089        let compaction = manifest.inference.unwrap().compaction.unwrap();
8090        assert_eq!(
8091            compaction.system_prompt,
8092            Some("task = X, currently editing Y, already tried Z.".to_string())
8093        );
8094        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8095    }
8096
8097    /// The two override fields resolve independently: `model` set, `system_prompt`
8098    /// absent leaves the latter `None` with no default string substituted.
8099    #[test]
8100    fn compaction_system_prompt_absent_stays_none() {
8101        let manifest = RuntimeManifest::from_yaml_str(
8102            r#"
8103name: cap
8104version: 0.0.1
8105artifacts:
8106  - name: murmur-driver-anthropic
8107    version: 0.1.0
8108    runtime: driver
8109    gateway:
8110      endpoint: http://127.0.0.1:8080
8111      keyless: true
8112inference:
8113  model: test-model
8114  compaction:
8115    model: compaction-model
8116  driver:
8117    artifact: murmur-driver-anthropic
8118"#,
8119        )
8120        .unwrap();
8121
8122        let compaction = manifest.inference.unwrap().compaction.unwrap();
8123        assert!(compaction.system_prompt.is_none());
8124        assert!(compaction.system_prompt_file.is_none());
8125        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8126    }
8127
8128    /// `system_prompt_file` parses as the second, independent prompt source: the path is
8129    /// carried through verbatim (resolution against the manifest dir happens in the
8130    /// runtime), the inline field stays `None`, and `model` is unaffected.
8131    #[test]
8132    fn parses_compaction_system_prompt_file() {
8133        let manifest = RuntimeManifest::from_yaml_str(
8134            r#"
8135name: cap
8136version: 0.0.1
8137artifacts:
8138  - name: murmur-driver-anthropic
8139    version: 0.1.0
8140    runtime: driver
8141    gateway:
8142      endpoint: http://127.0.0.1:8080
8143      keyless: true
8144inference:
8145  model: test-model
8146  compaction:
8147    model: compaction-model
8148    system_prompt_file: "compaction-instructions.md"
8149  driver:
8150    artifact: murmur-driver-anthropic
8151"#,
8152        )
8153        .unwrap();
8154
8155        let compaction = manifest.inference.unwrap().compaction.unwrap();
8156        assert_eq!(
8157            compaction.system_prompt_file,
8158            Some("compaction-instructions.md".to_string())
8159        );
8160        assert!(compaction.system_prompt.is_none());
8161        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8162    }
8163
8164    /// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
8165    /// depend on each other in either direction.
8166    #[test]
8167    fn compaction_system_prompt_file_independent_of_model() {
8168        let manifest = RuntimeManifest::from_yaml_str(
8169            r#"
8170name: cap
8171version: 0.0.1
8172artifacts:
8173  - name: murmur-driver-anthropic
8174    version: 0.1.0
8175    runtime: driver
8176    gateway:
8177      endpoint: http://127.0.0.1:8080
8178      keyless: true
8179inference:
8180  model: test-model
8181  compaction:
8182    threshold: 0.5
8183    system_prompt_file: "compaction-instructions.md"
8184  driver:
8185    artifact: murmur-driver-anthropic
8186"#,
8187        )
8188        .unwrap();
8189
8190        let compaction = manifest.inference.unwrap().compaction.unwrap();
8191        assert_eq!(
8192            compaction.system_prompt_file,
8193            Some("compaction-instructions.md".to_string())
8194        );
8195        assert!(compaction.model.is_none());
8196        assert_eq!(compaction.threshold, Some(0.5));
8197    }
8198
8199    /// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
8200    /// are three distinguishable states, so the runtime can tell "explicitly off" from
8201    /// "never configured" even though both resolve to the same behavior.
8202    #[test]
8203    fn parses_compaction_dump_summaries() {
8204        let parse = |line: &str| {
8205            RuntimeManifest::from_yaml_str(&format!(
8206                r#"
8207name: cap
8208version: 0.0.1
8209artifacts:
8210  - name: murmur-driver-anthropic
8211    version: 0.1.0
8212    runtime: driver
8213    gateway:
8214      endpoint: http://127.0.0.1:8080
8215      keyless: true
8216inference:
8217  model: test-model
8218  compaction:
8219    model: compaction-model
8220{line}  driver:
8221    artifact: murmur-driver-anthropic
8222"#
8223            ))
8224            .unwrap()
8225            .inference
8226            .unwrap()
8227            .compaction
8228            .unwrap()
8229        };
8230
8231        assert_eq!(
8232            parse("    dump_summaries: true\n").dump_summaries,
8233            Some(true)
8234        );
8235        assert_eq!(
8236            parse("    dump_summaries: false\n").dump_summaries,
8237            Some(false)
8238        );
8239        assert_eq!(parse("").dump_summaries, None);
8240    }
8241
8242    /// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
8243    /// prompt-source exclusivity check nor the threshold range check, and does not disturb
8244    /// the values those fields parse to.
8245    #[test]
8246    fn compaction_dump_summaries_does_not_interact_with_other_fields() {
8247        let compaction = RuntimeManifest::from_yaml_str(
8248            r#"
8249name: cap
8250version: 0.0.1
8251artifacts:
8252  - name: murmur-driver-anthropic
8253    version: 0.1.0
8254    runtime: driver
8255    gateway:
8256      endpoint: http://127.0.0.1:8080
8257      keyless: true
8258inference:
8259  model: test-model
8260  compaction:
8261    threshold: 0.5
8262    model: compaction-model
8263    system_prompt_file: "compaction-instructions.md"
8264    dump_summaries: true
8265  driver:
8266    artifact: murmur-driver-anthropic
8267"#,
8268        )
8269        .unwrap()
8270        .inference
8271        .unwrap()
8272        .compaction
8273        .unwrap();
8274
8275        assert_eq!(compaction.dump_summaries, Some(true));
8276        assert_eq!(compaction.threshold, Some(0.5));
8277        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8278        assert_eq!(
8279            compaction.system_prompt_file,
8280            Some("compaction-instructions.md".to_string())
8281        );
8282        assert!(compaction.system_prompt.is_none());
8283
8284        // Still mutually exclusive with dump_summaries in the mix...
8285        let err = RuntimeManifest::from_yaml_str(
8286            r#"
8287name: cap
8288version: 0.0.1
8289artifacts:
8290  - name: murmur-driver-anthropic
8291    version: 0.1.0
8292    runtime: driver
8293    gateway:
8294      endpoint: http://127.0.0.1:8080
8295      keyless: true
8296inference:
8297  model: test-model
8298  compaction:
8299    dump_summaries: true
8300    system_prompt: "inline"
8301    system_prompt_file: "compaction-instructions.md"
8302  driver:
8303    artifact: murmur-driver-anthropic
8304"#,
8305        )
8306        .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
8307        assert!(matches!(
8308            err,
8309            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
8310                if field == "inference.compaction.system_prompt"
8311        ));
8312
8313        // ...and the threshold range is still enforced.
8314        let err = RuntimeManifest::from_yaml_str(
8315            r#"
8316name: cap
8317version: 0.0.1
8318artifacts:
8319  - name: murmur-driver-anthropic
8320    version: 0.1.0
8321    runtime: driver
8322    gateway:
8323      endpoint: http://127.0.0.1:8080
8324      keyless: true
8325inference:
8326  model: test-model
8327  compaction:
8328    dump_summaries: true
8329    threshold: 1.5
8330  driver:
8331    artifact: murmur-driver-anthropic
8332"#,
8333        )
8334        .expect_err("dump_summaries must not suppress the threshold range check");
8335        assert!(matches!(
8336            err,
8337            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
8338                if field == "inference.compaction.threshold"
8339        ));
8340    }
8341
8342    /// Setting both compaction prompt sources is a parse error naming the compaction
8343    /// fields — distinguishable from the top-level three-way `inference.system_prompt`
8344    /// exclusivity error, and with neither value silently preferred.
8345    #[test]
8346    fn compaction_prompt_sources_are_mutually_exclusive() {
8347        let err = RuntimeManifest::from_yaml_str(
8348            r#"
8349name: cap
8350version: 0.0.1
8351artifacts:
8352  - name: murmur-driver-anthropic
8353    version: 0.1.0
8354    runtime: driver
8355    gateway:
8356      endpoint: http://127.0.0.1:8080
8357      keyless: true
8358inference:
8359  model: test-model
8360  compaction:
8361    system_prompt: "inline"
8362    system_prompt_file: "compaction-instructions.md"
8363  driver:
8364    artifact: murmur-driver-anthropic
8365"#,
8366        )
8367        .expect_err("both compaction prompt sources set must fail");
8368
8369        match err {
8370            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
8371                assert_eq!(field, "inference.compaction.system_prompt");
8372                assert_eq!(
8373                    message,
8374                    "at most one of inference.compaction.system_prompt, \
8375                     inference.compaction.system_prompt_file may be set"
8376                );
8377            }
8378            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
8379        }
8380    }
8381
8382    /// The compaction-level exclusivity check is separate from the top-level one: an
8383    /// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
8384    /// perfectly legal manifest.
8385    #[test]
8386    fn top_level_and_compaction_prompt_sources_do_not_collide() {
8387        let manifest = RuntimeManifest::from_yaml_str(
8388            r#"
8389name: cap
8390version: 0.0.1
8391artifacts:
8392  - name: murmur-driver-anthropic
8393    version: 0.1.0
8394    runtime: driver
8395    gateway:
8396      endpoint: http://127.0.0.1:8080
8397      keyless: true
8398inference:
8399  model: test-model
8400  system_prompt: "be helpful"
8401  compaction:
8402    system_prompt_file: "compaction-instructions.md"
8403  driver:
8404    artifact: murmur-driver-anthropic
8405"#,
8406        )
8407        .unwrap();
8408
8409        let inference = manifest.inference.unwrap();
8410        assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
8411        assert_eq!(
8412            inference.compaction.unwrap().system_prompt_file,
8413            Some("compaction-instructions.md".to_string())
8414        );
8415    }
8416
8417    #[test]
8418    fn parses_system_prompt_file() {
8419        let manifest = RuntimeManifest::from_yaml_str(
8420            r#"
8421name: cap
8422version: 0.0.1
8423artifacts:
8424  - name: murmur-driver-anthropic
8425    version: 0.1.0
8426    runtime: driver
8427    gateway:
8428      endpoint: http://127.0.0.1:8080
8429      keyless: true
8430inference:
8431  model: test-model
8432  system_prompt_file: "conventions.md"
8433  driver:
8434    artifact: murmur-driver-anthropic
8435"#,
8436        )
8437        .unwrap();
8438
8439        let inference = manifest.inference.unwrap();
8440        assert_eq!(
8441            inference.system_prompt_file,
8442            Some("conventions.md".to_string())
8443        );
8444        assert!(inference.system_prompt.is_none());
8445    }
8446
8447    #[test]
8448    fn rejects_both_inline_and_file_system_prompt() {
8449        let err = RuntimeManifest::from_yaml_str(
8450            r#"
8451name: cap
8452version: 0.0.1
8453artifacts:
8454  - name: murmur-driver-anthropic
8455    version: 0.1.0
8456    runtime: driver
8457    gateway:
8458      endpoint: http://127.0.0.1:8080
8459      keyless: true
8460inference:
8461  model: test-model
8462  system_prompt: "inline"
8463  system_prompt_file: "conventions.md"
8464  driver:
8465    artifact: murmur-driver-anthropic
8466"#,
8467        )
8468        .unwrap_err();
8469
8470        let msg = err.to_string();
8471        assert!(msg.contains("inference.system_prompt"));
8472        assert!(msg.contains("system_prompt_file"));
8473    }
8474
8475    /// A `${NAME}` is kept as the name it is, whether or not this process holds the variable: the
8476    /// parse never reads the environment.
8477    #[test]
8478    fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
8479        let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
8480        let yaml = format!(
8481            "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"
8482        );
8483
8484        assert_eq!(
8485            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
8486                .gateway
8487                .clone()
8488                .unwrap()
8489                .api_key,
8490            Some(ApiKeyReference::Environment(key.to_string()))
8491        );
8492        assert_eq!(
8493            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
8494                .unwrap()
8495                .artifacts[0]
8496                .gateway
8497                .clone()
8498                .unwrap()
8499                .api_key,
8500            None
8501        );
8502    }
8503
8504    #[test]
8505    fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
8506        let literal = ["sk-", "ant-", "debug"].concat();
8507        let debug = format!(
8508            "{:?} {:?}",
8509            ApiKeyReference::Environment("SOME_KEY".to_string()),
8510            ApiKeyReference::Literal(literal.clone())
8511        );
8512        assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
8513        assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
8514        assert!(!debug.contains(&literal), "{debug}");
8515    }
8516
8517    #[test]
8518    fn parse_without_secrets_drops_a_literal_api_key() {
8519        // Key assembled at runtime so the source never contains a
8520        // credential-shaped literal that secret scanners could flag.
8521        let literal = ["sk-", "ant-", "literal"].concat();
8522        let yaml = format!(
8523            "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"
8524        );
8525
8526        assert_eq!(
8527            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
8528                .gateway
8529                .clone()
8530                .unwrap()
8531                .api_key,
8532            Some(ApiKeyReference::Literal(literal))
8533        );
8534        assert_eq!(
8535            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
8536                .unwrap()
8537                .artifacts[0]
8538                .gateway
8539                .clone()
8540                .unwrap()
8541                .api_key,
8542            None
8543        );
8544    }
8545
8546    /// The non-resolving parse is narrower only in what it resolves, never in what it validates:
8547    /// a manifest roost accepts is a manifest `mur run` would also accept.
8548    #[test]
8549    fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
8550        let process_with_key = format!(
8551            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  model: test-model\n  api_key: {}\n",
8552            ["sk-", "ant-", "secret"].concat()
8553        );
8554        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";
8555
8556        for yaml in [process_with_key.as_str(), http_without_model] {
8557            let resolving = RuntimeManifest::from_yaml_str(yaml)
8558                .unwrap_err()
8559                .to_string();
8560            let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
8561                .unwrap_err()
8562                .to_string();
8563            assert_eq!(resolving, without_secrets);
8564        }
8565    }
8566
8567    /// The reference scan answers from the manifest text alone: it reports a reference, ignores
8568    /// a literal, and has nothing to say about a manifest that carries no `inference` block or
8569    /// does not parse at all.
8570    #[test]
8571    fn referenced_env_variables_reports_a_reference_and_nothing_else() {
8572        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";
8573        let referenced = referenced_env_variables(referencing);
8574        assert_eq!(referenced.len(), 1);
8575        assert_eq!(
8576            referenced[0].field,
8577            "artifacts.murmur-driver-anthropic.gateway.api_key"
8578        );
8579        assert!(referenced[0].is_gateway_credential());
8580        assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
8581
8582        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";
8583        assert!(referenced_env_variables(literal).is_empty());
8584
8585        let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
8586        assert!(referenced_env_variables(no_inference).is_empty());
8587
8588        let unparseable = "name: broken\nversion: [\n";
8589        assert!(referenced_env_variables(unparseable).is_empty());
8590    }
8591
8592    #[test]
8593    fn accepts_legacy_inference_provider_field() {
8594        let manifest = RuntimeManifest::from_yaml_str(
8595            r#"
8596name: cap
8597version: 0.0.1
8598artifacts:
8599  - name: murmur-driver-openai
8600    version: 0.1.0
8601    runtime: driver
8602    gateway:
8603      endpoint: http://127.0.0.1:8080
8604      keyless: true
8605inference:
8606  transport: http
8607  model: test-model
8608  provider:
8609    artifact: murmur-driver-openai
8610"#,
8611        )
8612        .unwrap();
8613
8614        assert_eq!(
8615            manifest
8616                .inference
8617                .unwrap()
8618                .driver
8619                .as_ref()
8620                .unwrap()
8621                .artifact,
8622            "murmur-driver-openai"
8623        );
8624    }
8625
8626    #[test]
8627    fn prefers_driver_field_over_legacy_provider_field() {
8628        let manifest = RuntimeManifest::from_yaml_str(
8629            r#"
8630name: cap
8631version: 0.0.1
8632artifacts:
8633  - name: murmur-driver-anthropic
8634    version: 0.1.0
8635    runtime: driver
8636    gateway:
8637      endpoint: http://127.0.0.1:8080
8638      keyless: true
8639inference:
8640  transport: http
8641  model: test-model
8642  driver:
8643    artifact: murmur-driver-anthropic
8644  provider:
8645    artifact: murmur-driver-openai
8646"#,
8647        )
8648        .unwrap();
8649
8650        assert_eq!(
8651            manifest
8652                .inference
8653                .unwrap()
8654                .driver
8655                .as_ref()
8656                .unwrap()
8657                .artifact,
8658            "murmur-driver-anthropic"
8659        );
8660    }
8661
8662    #[test]
8663    fn inference_requires_driver_artifact() {
8664        let err = RuntimeManifest::from_yaml_str(
8665            r#"
8666name: cap
8667version: 0.0.1
8668artifacts: []
8669inference:
8670  transport: http
8671  endpoint: http://127.0.0.1:8080
8672  model: test-model
8673"#,
8674        )
8675        .unwrap_err();
8676
8677        assert!(err.to_string().contains("inference.driver"));
8678    }
8679
8680    #[test]
8681    fn test_runtime_driver_deserializes() {
8682        let manifest = RuntimeManifest::from_yaml_str(
8683            r#"
8684name: cap
8685version: 0.0.1
8686artifacts:
8687  - name: my-provider
8688    version: 1.0.0
8689    runtime: driver
8690"#,
8691        )
8692        .unwrap();
8693
8694        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
8695    }
8696
8697    #[test]
8698    fn test_runtime_unknown_is_error() {
8699        let err = RuntimeManifest::from_yaml_str(
8700            r#"
8701name: cap
8702version: 0.0.1
8703artifacts:
8704  - name: foo
8705    version: 1.0.0
8706    runtime: unknown_type
8707"#,
8708        )
8709        .unwrap_err();
8710
8711        let msg = err.to_string();
8712        assert!(msg.contains("runtime"), "error should mention 'runtime'");
8713        assert!(
8714            msg.contains("unknown_type"),
8715            "error should identify the bad value"
8716        );
8717    }
8718
8719    #[test]
8720    fn test_runtime_all_variants_roundtrip() {
8721        for variant in [
8722            ArtifactRuntime::Tool,
8723            ArtifactRuntime::Driver,
8724            ArtifactRuntime::Hook,
8725            ArtifactRuntime::Skill,
8726        ] {
8727            let json = serde_json::to_string(&variant).unwrap();
8728            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
8729            assert_eq!(variant, back);
8730        }
8731    }
8732
8733    #[test]
8734    fn skill_source_without_version_defaults_to_local() {
8735        let manifest = RuntimeManifest::from_yaml_str(
8736            r#"
8737name: cap
8738version: 0.0.1
8739artifacts:
8740  - name: my-skill
8741    source: ./skills/my-skill/skill.md
8742    runtime: skill
8743"#,
8744        )
8745        .unwrap();
8746
8747        assert_eq!(manifest.artifacts.len(), 1);
8748        let artifact = &manifest.artifacts[0];
8749        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
8750        assert_eq!(
8751            artifact.source.as_deref(),
8752            Some("./skills/my-skill/skill.md")
8753        );
8754        assert_eq!(artifact.version, "local");
8755    }
8756
8757    #[test]
8758    fn skill_source_directory_path_is_accepted() {
8759        let manifest = RuntimeManifest::from_yaml_str(
8760            r#"
8761name: cap
8762version: 0.0.1
8763artifacts:
8764  - name: my-skill
8765    source: ./skills/my-skill/
8766    runtime: skill
8767"#,
8768        )
8769        .unwrap();
8770
8771        assert_eq!(
8772            manifest.artifacts[0].source.as_deref(),
8773            Some("./skills/my-skill/")
8774        );
8775        assert_eq!(manifest.artifacts[0].version, "local");
8776    }
8777
8778    #[test]
8779    fn skill_source_with_explicit_version_substitutes_local() {
8780        // Both set is allowed; version is ignored and "local" is substituted.
8781        let manifest = RuntimeManifest::from_yaml_str(
8782            r#"
8783name: cap
8784version: 0.0.1
8785artifacts:
8786  - name: my-skill
8787    source: ./skills/my-skill/skill.md
8788    version: 9.9.9
8789    runtime: skill
8790"#,
8791        )
8792        .unwrap();
8793
8794        assert_eq!(manifest.artifacts[0].version, "local");
8795    }
8796
8797    #[test]
8798    fn local_source_true_allows_source_on_non_skill_runtime() {
8799        for runtime in ["tool", "driver", "hook"] {
8800            let yaml = format!(
8801                r#"
8802name: cap
8803version: 0.0.1
8804artifacts:
8805  - name: my-thing
8806    source: ./local/path
8807    local_source: true
8808    runtime: {runtime}
8809"#
8810            );
8811            let manifest = RuntimeManifest::from_yaml_str(&yaml)
8812                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
8813            let artifact = &manifest.artifacts[0];
8814            assert!(artifact.local_source, "runtime {runtime}");
8815            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
8816            assert_eq!(artifact.version, "local", "runtime {runtime}");
8817        }
8818    }
8819
8820    #[test]
8821    fn local_source_false_on_skill_with_source_is_error() {
8822        // The gate reads the declared field, not the role: an explicit `local_source: false`
8823        // overrides the skill default and rejects `source:`.
8824        let err = RuntimeManifest::from_yaml_str(
8825            r#"
8826name: cap
8827version: 0.0.1
8828artifacts:
8829  - name: my-skill
8830    source: ./skills/my-skill
8831    local_source: false
8832    runtime: skill
8833"#,
8834        )
8835        .unwrap_err();
8836        let msg = err.to_string();
8837        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
8838        assert!(msg.contains("my-skill"), "error was: {msg}");
8839    }
8840
8841    #[test]
8842    fn local_source_defaults_from_role_when_absent() {
8843        let manifest = RuntimeManifest::from_yaml_str(
8844            r#"
8845name: cap
8846version: 0.0.1
8847artifacts:
8848  - name: my-skill
8849    source: ./skills/my-skill
8850    runtime: skill
8851  - name: my-tool
8852    version: 1.0.0
8853    runtime: tool
8854"#,
8855        )
8856        .unwrap();
8857        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
8858        assert!(
8859            !manifest.artifacts[1].local_source,
8860            "tool defaults to false"
8861        );
8862    }
8863
8864    #[test]
8865    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
8866        let manifest = RuntimeManifest::from_yaml_str(
8867            r#"
8868name: cap
8869version: 0.0.1
8870artifacts:
8871  - name: my-tool
8872    version: 1.0.0
8873    runtime: tool
8874    prompt_payload: true
8875  - name: murmur-driver-anthropic
8876    version: 0.1.0
8877    runtime: driver
8878    gateway:
8879      endpoint: http://127.0.0.1:8080
8880      keyless: true
8881inference:
8882  model: test-model
8883  system_prompt_artifact: my-tool
8884  driver:
8885    artifact: murmur-driver-anthropic
8886"#,
8887        )
8888        .unwrap();
8889        assert!(manifest.artifacts[0].prompt_payload);
8890    }
8891
8892    #[test]
8893    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
8894        let err = RuntimeManifest::from_yaml_str(
8895            r#"
8896name: cap
8897version: 0.0.1
8898artifacts:
8899  - name: my-skill
8900    version: 1.0.0
8901    runtime: skill
8902    prompt_payload: false
8903  - name: murmur-driver-anthropic
8904    version: 0.1.0
8905    runtime: driver
8906    gateway:
8907      endpoint: http://127.0.0.1:8080
8908      keyless: true
8909inference:
8910  model: test-model
8911  system_prompt_artifact: my-skill
8912  driver:
8913    artifact: murmur-driver-anthropic
8914"#,
8915        )
8916        .unwrap_err();
8917        let msg = err.to_string();
8918        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
8919    }
8920
8921    #[test]
8922    fn prompt_payload_defaults_from_role_when_absent() {
8923        let manifest = RuntimeManifest::from_yaml_str(
8924            r#"
8925name: cap
8926version: 0.0.1
8927artifacts:
8928  - name: my-skill
8929    version: 1.0.0
8930    runtime: skill
8931  - name: my-tool
8932    version: 1.0.0
8933    runtime: tool
8934"#,
8935        )
8936        .unwrap();
8937        assert!(
8938            manifest.artifacts[0].prompt_payload,
8939            "skill defaults to true"
8940        );
8941        assert!(
8942            !manifest.artifacts[1].prompt_payload,
8943            "tool defaults to false"
8944        );
8945    }
8946
8947    #[test]
8948    fn source_on_non_skill_runtime_is_error() {
8949        for runtime in ["tool", "driver", "hook"] {
8950            let yaml = format!(
8951                r#"
8952name: cap
8953version: 0.0.1
8954artifacts:
8955  - name: my-thing
8956    source: ./local/path
8957    runtime: {runtime}
8958"#
8959            );
8960            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
8961            let msg = err.to_string();
8962            assert!(
8963                msg.contains("'local_source: true'"),
8964                "runtime {runtime}: error was: {msg}"
8965            );
8966            assert!(
8967                msg.contains("my-thing"),
8968                "runtime {runtime}: error was: {msg}"
8969            );
8970        }
8971    }
8972
8973    #[test]
8974    fn registry_skill_still_requires_version() {
8975        let err = RuntimeManifest::from_yaml_str(
8976            r#"
8977name: cap
8978version: 0.0.1
8979artifacts:
8980  - name: my-skill
8981    runtime: skill
8982"#,
8983        )
8984        .unwrap_err();
8985
8986        let msg = err.to_string();
8987        assert!(
8988            msg.contains("missing required field 'version'"),
8989            "error was: {msg}"
8990        );
8991    }
8992
8993    #[test]
8994    fn rejects_unknown_inference_transport() {
8995        let err = RuntimeManifest::from_yaml_str(
8996            r#"
8997name: cap
8998version: 0.0.1
8999artifacts: []
9000inference:
9001  transport: grpc
9002  model: test-model
9003  driver:
9004    artifact: murmur-driver-anthropic
9005"#,
9006        )
9007        .unwrap_err();
9008
9009        let msg = err.to_string();
9010        assert!(msg.contains("inference.transport"), "error was: {msg}");
9011        assert!(msg.contains("unknown value"), "error was: {msg}");
9012        assert!(msg.contains("grpc"), "error was: {msg}");
9013    }
9014
9015    /// The driver is what knows a harness, so `command:` alone selects nothing: a manifest that
9016    /// names only a command is refused and told which field to add.
9017    #[test]
9018    fn process_transport_with_command_and_no_driver_is_refused() {
9019        let err = RuntimeManifest::from_yaml_str(
9020            r#"
9021name: cap
9022version: 0.0.1
9023artifacts: []
9024inference:
9025  transport: process
9026  command: some-cli
9027  model: some-model
9028  max_turns: 20
9029"#,
9030        )
9031        .unwrap_err();
9032
9033        let msg = err.to_string();
9034        assert!(
9035            msg.contains("inference.driver.artifact"),
9036            "error was: {msg}"
9037        );
9038        assert!(msg.contains("inference.command"), "error was: {msg}");
9039        assert!(
9040            msg.contains("murmur-driver-claude-code"),
9041            "error was: {msg}"
9042        );
9043    }
9044
9045    #[test]
9046    fn process_transport_with_neither_driver_nor_command_is_refused() {
9047        let err = RuntimeManifest::from_yaml_str(
9048            r#"
9049name: cap
9050version: 0.0.1
9051artifacts: []
9052inference:
9053  transport: process
9054  model: some-model
9055"#,
9056        )
9057        .unwrap_err();
9058
9059        let msg = err.to_string();
9060        assert!(
9061            msg.contains("inference.driver.artifact"),
9062            "error was: {msg}"
9063        );
9064        assert!(
9065            msg.contains("murmur-driver-claude-code"),
9066            "error was: {msg}"
9067        );
9068    }
9069
9070    #[test]
9071    fn process_transport_allows_absent_model() {
9072        // model is optional for transport: process — an absent model leaves the choice to the
9073        // harness, which has an account default of its own.
9074        let manifest =
9075            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9076                .expect("process transport should parse without a model");
9077        let inference = manifest.inference.expect("inference present");
9078        assert_eq!(inference.transport, "process");
9079        assert!(inference.command.is_none());
9080        assert_eq!(
9081            inference.model, "",
9082            "absent model resolves to empty (harness default)"
9083        );
9084    }
9085
9086    fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
9087        format!(
9088            "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n  transport: process\n  \
9089             driver:\n    artifact: fixture-process-driver\n{inference_extra}"
9090        )
9091    }
9092
9093    const PROCESS_DRIVER_ENTRY: &str =
9094        "  - name: fixture-process-driver\n    version: 0.1.0\n    runtime: driver";
9095
9096    #[test]
9097    fn process_transport_accepts_driver() {
9098        let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9099            PROCESS_DRIVER_ENTRY,
9100            "    config:\n      profile: work\n  command: /usr/local/bin/fixture-cli\n",
9101        ))
9102        .unwrap();
9103
9104        let inference = manifest.inference.expect("inference should exist");
9105        assert_eq!(inference.transport, "process");
9106        let driver = inference.driver.expect("driver should be parsed");
9107        assert_eq!(driver.artifact, "fixture-process-driver");
9108        assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
9109        assert_eq!(
9110            inference.command.as_deref(),
9111            Some("/usr/local/bin/fixture-cli")
9112        );
9113    }
9114
9115    #[test]
9116    fn process_transport_accepts_driver_without_command() {
9117        let manifest =
9118            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9119                .unwrap();
9120
9121        let inference = manifest.inference.expect("inference should exist");
9122        assert_eq!(
9123            inference.driver.map(|d| d.artifact).as_deref(),
9124            Some("fixture-process-driver")
9125        );
9126        assert!(inference.command.is_none());
9127        assert_eq!(inference.model, "");
9128    }
9129
9130    #[test]
9131    fn process_transport_rejects_provider_alias() {
9132        let msg = RuntimeManifest::from_yaml_str(
9133            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  \
9134             command: fixture-cli\n  provider:\n    artifact: fixture-process-driver\n",
9135        )
9136        .unwrap_err()
9137        .to_string();
9138        assert!(msg.contains("inference.provider.artifact"), "{msg}");
9139        assert!(msg.contains("inference.driver"), "{msg}");
9140    }
9141
9142    #[test]
9143    fn process_driver_entry_must_be_declared() {
9144        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9145            "  - name: other\n    version: 0.1.0\n    runtime: tool",
9146            "",
9147        ))
9148        .unwrap_err()
9149        .to_string();
9150        assert!(msg.contains("inference.driver.artifact"), "{msg}");
9151        assert!(
9152            msg.contains("artifact 'fixture-process-driver' is not declared in artifacts:"),
9153            "{msg}"
9154        );
9155    }
9156
9157    #[test]
9158    fn process_driver_entry_must_be_runtime_driver() {
9159        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9160            "  - name: fixture-process-driver\n    version: 0.1.0\n    runtime: tool",
9161            "",
9162        ))
9163        .unwrap_err()
9164        .to_string();
9165        assert!(
9166            msg.contains("must be declared with runtime: driver"),
9167            "{msg}"
9168        );
9169    }
9170
9171    #[test]
9172    fn process_driver_entry_refuses_capabilities() {
9173        let entry =
9174            format!("{PROCESS_DRIVER_ENTRY}\n    capabilities:\n      env:\n        allow: [HOME]");
9175        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9176            .unwrap_err()
9177            .to_string();
9178        assert!(msg.contains("granted nothing"), "{msg}");
9179        assert!(msg.contains("inference.driver.config"), "{msg}");
9180    }
9181
9182    #[test]
9183    fn process_driver_entry_refuses_config() {
9184        let entry = format!("{PROCESS_DRIVER_ENTRY}\n    config:\n      profile: work");
9185        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9186            .unwrap_err()
9187            .to_string();
9188        assert!(msg.contains("granted nothing"), "{msg}");
9189        assert!(msg.contains("remove capabilities:/config:"), "{msg}");
9190    }
9191
9192    #[test]
9193    fn process_transport_rejects_endpoint() {
9194        let err = RuntimeManifest::from_yaml_str(
9195            r#"
9196name: cap
9197version: 0.0.1
9198artifacts: []
9199inference:
9200  transport: process
9201  command: claude
9202  model: claude-haiku-4-5-20251001
9203  endpoint: http://localhost:8080
9204"#,
9205        )
9206        .unwrap_err();
9207
9208        let msg = err.to_string();
9209        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
9210        assert!(msg.contains("was removed"), "error was: {msg}");
9211    }
9212
9213    #[test]
9214    fn process_transport_rejects_api_key() {
9215        // Key assembled at runtime so the source never contains a
9216        // credential-shaped literal that secret scanners could flag.
9217        let key = ["sk-", "ant-", "secret"].concat();
9218        let err = RuntimeManifest::from_yaml_str(&format!(
9219            r#"
9220name: cap
9221version: 0.0.1
9222artifacts: []
9223inference:
9224  transport: process
9225  command: claude
9226  model: claude-haiku-4-5-20251001
9227  api_key: {key}
9228"#
9229        ))
9230        .unwrap_err();
9231
9232        let msg = err.to_string();
9233        assert!(msg.contains("inference.api_key"), "error was: {msg}");
9234        assert!(msg.contains("was removed"), "error was: {msg}");
9235        assert!(!msg.contains(&key), "error was: {msg}");
9236    }
9237
9238    #[test]
9239    fn process_transport_rejects_max_tokens() {
9240        // Rejected even at a perfectly valid value: the CLI subprocess path never builds a
9241        // driver payload, so accepting it would leave it silently inert.
9242        let err = RuntimeManifest::from_yaml_str(
9243            r#"
9244name: cap
9245version: 0.0.1
9246artifacts: []
9247inference:
9248  transport: process
9249  command: claude
9250  model: claude-haiku-4-5-20251001
9251  max_tokens: 4096
9252"#,
9253        )
9254        .unwrap_err();
9255
9256        let msg = err.to_string();
9257        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
9258        assert!(
9259            msg.contains("not valid with transport: process"),
9260            "error was: {msg}"
9261        );
9262    }
9263
9264    /// The harness's own reported counts are what this transport meters, so the ceiling parses
9265    /// here as it does under `http`. A driver that reports no counts is refused at load, not
9266    /// here: the manifest cannot see which driver it names.
9267    #[test]
9268    fn process_transport_carries_max_session_tokens() {
9269        let manifest = RuntimeManifest::from_yaml_str(
9270            r#"
9271name: cap
9272version: 0.0.1
9273artifacts:
9274  - name: murmur-driver-claude-code
9275    version: 0.1.0
9276    runtime: driver
9277inference:
9278  transport: process
9279  driver:
9280    artifact: murmur-driver-claude-code
9281  model: claude-haiku-4-5-20251001
9282  max_session_tokens: 200000
9283"#,
9284        )
9285        .unwrap();
9286
9287        assert_eq!(
9288            manifest.inference.unwrap().max_session_tokens,
9289            Some(200_000)
9290        );
9291    }
9292
9293    #[test]
9294    fn process_transport_rejects_a_zero_max_session_tokens() {
9295        let err = RuntimeManifest::from_yaml_str(
9296            r#"
9297name: cap
9298version: 0.0.1
9299artifacts:
9300  - name: murmur-driver-claude-code
9301    version: 0.1.0
9302    runtime: driver
9303inference:
9304  transport: process
9305  driver:
9306    artifact: murmur-driver-claude-code
9307  model: claude-haiku-4-5-20251001
9308  max_session_tokens: 0
9309"#,
9310        )
9311        .unwrap_err();
9312
9313        let msg = err.to_string();
9314        assert!(
9315            msg.contains("inference.max_session_tokens"),
9316            "error was: {msg}"
9317        );
9318        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
9319    }
9320
9321    #[test]
9322    fn inference_max_session_tokens_round_trips() {
9323        let manifest = RuntimeManifest::from_yaml_str(
9324            r#"
9325name: cap
9326version: 0.0.1
9327artifacts:
9328  - name: murmur-driver-anthropic
9329    version: 0.1.0
9330    runtime: driver
9331    gateway:
9332      endpoint: https://api.anthropic.com
9333      api_key: test-key
9334inference:
9335  transport: http
9336  model: claude-opus-4-5
9337  max_session_tokens: 5000000000
9338  driver:
9339    artifact: murmur-driver-anthropic
9340"#,
9341        )
9342        .unwrap();
9343
9344        assert_eq!(
9345            manifest.inference.unwrap().max_session_tokens,
9346            Some(5_000_000_000)
9347        );
9348    }
9349
9350    #[test]
9351    fn inference_max_session_tokens_absent_is_none() {
9352        let manifest = RuntimeManifest::from_yaml_str(
9353            r#"
9354name: cap
9355version: 0.0.1
9356artifacts:
9357  - name: murmur-driver-anthropic
9358    version: 0.1.0
9359    runtime: driver
9360    gateway:
9361      endpoint: https://api.anthropic.com
9362      api_key: test-key
9363inference:
9364  transport: http
9365  model: claude-opus-4-5
9366  driver:
9367    artifact: murmur-driver-anthropic
9368"#,
9369        )
9370        .unwrap();
9371
9372        assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
9373    }
9374
9375    #[test]
9376    fn inference_max_session_tokens_zero_is_rejected() {
9377        let err = RuntimeManifest::from_yaml_str(
9378            r#"
9379name: cap
9380version: 0.0.1
9381artifacts:
9382  - name: murmur-driver-anthropic
9383    version: 0.1.0
9384    runtime: driver
9385    gateway:
9386      endpoint: https://api.anthropic.com
9387      api_key: test-key
9388inference:
9389  transport: http
9390  model: claude-opus-4-5
9391  max_session_tokens: 0
9392  driver:
9393    artifact: murmur-driver-anthropic
9394"#,
9395        )
9396        .unwrap_err();
9397
9398        let msg = err.to_string();
9399        assert!(
9400            msg.contains("inference.max_session_tokens"),
9401            "error was: {msg}"
9402        );
9403        assert!(msg.contains("greater than 0"), "error was: {msg}");
9404    }
9405
9406    /// An http capsule manifest with `extra` appended to its `inference:` block.
9407    fn http_manifest_with_inference(extra: &str) -> String {
9408        format!(
9409            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur-driver-anthropic\n    \
9410             version: 0.1.0\n    runtime: driver\n    gateway:\n      \
9411             endpoint: https://api.anthropic.com\n      api_key: test-key\n\
9412             inference:\n  transport: http\n  model: claude-opus-4-5\n  driver:\n    \
9413             artifact: murmur-driver-anthropic\n{extra}"
9414        )
9415    }
9416
9417    #[test]
9418    fn inference_tool_refresh_absent_is_compaction() {
9419        let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
9420        assert_eq!(
9421            manifest.inference.unwrap().tool_refresh,
9422            ToolRefresh::Compaction
9423        );
9424        assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
9425    }
9426
9427    #[test]
9428    fn inference_tool_refresh_parses_both_values() {
9429        for (value, expected) in [
9430            ("compaction", ToolRefresh::Compaction),
9431            ("immediate", ToolRefresh::Immediate),
9432        ] {
9433            let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
9434                "  tool_refresh: {value}\n"
9435            )))
9436            .unwrap();
9437            assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
9438            assert_eq!(expected.wire_name(), value);
9439            assert!(
9440                manifest.unknown_keys.is_empty(),
9441                "tool_refresh is a known key: {:?}",
9442                manifest.unknown_keys
9443            );
9444        }
9445    }
9446
9447    #[test]
9448    fn inference_tool_refresh_rejects_an_unknown_value() {
9449        let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
9450            "  tool_refresh: sometimes\n",
9451        ))
9452        .unwrap_err();
9453        match err {
9454            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9455                assert_eq!(field, "inference.tool_refresh");
9456                assert_eq!(
9457                    message,
9458                    "must be one of: compaction, immediate; got 'sometimes'"
9459                );
9460            }
9461            other => panic!("unexpected error: {other:?}"),
9462        }
9463    }
9464
9465    /// Refused whatever its value: the harness owns its tool list under `transport: process`,
9466    /// so the key would be silently inert there.
9467    #[test]
9468    fn process_transport_rejects_tool_refresh() {
9469        for value in ["compaction", "immediate"] {
9470            let err = RuntimeManifest::from_yaml_str(&format!(
9471                "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  \
9472                 command: claude\n  model: claude-haiku-4-5-20251001\n  tool_refresh: {value}\n"
9473            ))
9474            .unwrap_err();
9475            match err {
9476                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9477                    assert_eq!(field, "inference.tool_refresh");
9478                    assert_eq!(message, "is not valid with transport: process");
9479                }
9480                other => panic!("unexpected error for {value}: {other:?}"),
9481            }
9482        }
9483    }
9484
9485    #[test]
9486    fn inference_max_tokens_round_trips() {
9487        let manifest = RuntimeManifest::from_yaml_str(
9488            r#"
9489name: cap
9490version: 0.0.1
9491artifacts:
9492  - name: murmur-driver-anthropic
9493    version: 0.1.0
9494    runtime: driver
9495    gateway:
9496      endpoint: https://api.anthropic.com
9497      api_key: test-key
9498inference:
9499  transport: http
9500  model: claude-opus-4-5
9501  max_tokens: 4096
9502  driver:
9503    artifact: murmur-driver-anthropic
9504"#,
9505        )
9506        .unwrap();
9507
9508        let inference = manifest.inference.unwrap();
9509        assert_eq!(inference.max_tokens, Some(4096));
9510    }
9511
9512    #[test]
9513    fn inference_max_tokens_absent_is_none() {
9514        // Absent means "the manifest didn't set it" all the way down; the 8192 default is
9515        // applied once, by the runtime, not smuggled in here.
9516        let manifest = RuntimeManifest::from_yaml_str(
9517            r#"
9518name: cap
9519version: 0.0.1
9520artifacts:
9521  - name: murmur-driver-anthropic
9522    version: 0.1.0
9523    runtime: driver
9524    gateway:
9525      endpoint: https://api.anthropic.com
9526      api_key: test-key
9527inference:
9528  transport: http
9529  model: claude-opus-4-5
9530  driver:
9531    artifact: murmur-driver-anthropic
9532"#,
9533        )
9534        .unwrap();
9535
9536        assert_eq!(manifest.inference.unwrap().max_tokens, None);
9537    }
9538
9539    #[test]
9540    fn inference_max_tokens_zero_is_rejected() {
9541        let err = RuntimeManifest::from_yaml_str(
9542            r#"
9543name: cap
9544version: 0.0.1
9545artifacts:
9546  - name: murmur-driver-anthropic
9547    version: 0.1.0
9548    runtime: driver
9549    gateway:
9550      endpoint: https://api.anthropic.com
9551      api_key: test-key
9552inference:
9553  transport: http
9554  model: claude-opus-4-5
9555  max_tokens: 0
9556  driver:
9557    artifact: murmur-driver-anthropic
9558"#,
9559        )
9560        .unwrap_err();
9561
9562        let msg = err.to_string();
9563        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
9564        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
9565    }
9566
9567    #[test]
9568    fn inference_max_tokens_large_value_is_accepted() {
9569        // Manifest validation stays advisory: an over-large cap is the provider's to reject
9570        // at request time, not ours to clamp or block at load time.
9571        let manifest = RuntimeManifest::from_yaml_str(
9572            r#"
9573name: cap
9574version: 0.0.1
9575artifacts:
9576  - name: murmur-driver-anthropic
9577    version: 0.1.0
9578    runtime: driver
9579    gateway:
9580      endpoint: https://api.anthropic.com
9581      api_key: test-key
9582inference:
9583  transport: http
9584  model: claude-opus-4-5
9585  max_tokens: 999999
9586  driver:
9587    artifact: murmur-driver-anthropic
9588"#,
9589        )
9590        .unwrap();
9591
9592        assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
9593    }
9594
9595    #[test]
9596    fn inference_and_context_max_tokens_are_independent() {
9597        // Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
9598        // session compaction budget. Neither may leak into the other.
9599        let manifest = RuntimeManifest::from_yaml_str(
9600            r#"
9601name: cap
9602version: 0.0.1
9603artifacts:
9604  - name: murmur-driver-anthropic
9605    version: 0.1.0
9606    runtime: driver
9607    gateway:
9608      endpoint: https://api.anthropic.com
9609      api_key: test-key
9610inference:
9611  transport: http
9612  model: claude-opus-4-5
9613  max_tokens: 4096
9614  driver:
9615    artifact: murmur-driver-anthropic
9616context:
9617  max_tokens: 200000
9618"#,
9619        )
9620        .unwrap();
9621
9622        assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
9623        assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
9624    }
9625
9626    #[test]
9627    fn hook_config_defaults_when_fields_absent() {
9628        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
9629        assert_eq!(config.binding, HookBinding::All);
9630        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
9631        assert_eq!(config.commit_policy, HookCommitPolicy::None);
9632    }
9633
9634    #[test]
9635    fn hook_config_parses_all_fields() {
9636        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
9637        let config = parse_hook_config_from_yaml(yaml).unwrap();
9638        assert_eq!(config.binding, HookBinding::OnCompaction);
9639        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
9640        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
9641    }
9642
9643    #[test]
9644    fn hook_config_parses_on_task_start() {
9645        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
9646        let config = parse_hook_config_from_yaml(yaml).unwrap();
9647        assert_eq!(config.binding, HookBinding::OnTaskStart);
9648    }
9649
9650    #[test]
9651    fn hook_config_parses_on_task_end() {
9652        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
9653        let config = parse_hook_config_from_yaml(yaml).unwrap();
9654        assert_eq!(config.binding, HookBinding::OnTaskEnd);
9655    }
9656
9657    #[test]
9658    fn hook_config_unknown_binding_lists_task_events() {
9659        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
9660        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9661        assert!(err.contains("on-task-start"), "error was: {err}");
9662        assert!(err.contains("on-task-end"), "error was: {err}");
9663    }
9664
9665    #[test]
9666    fn hook_config_async_with_commit_is_rejected() {
9667        let yaml =
9668            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
9669        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9670        assert!(err.contains("async-with-commit"), "error was: {err}");
9671    }
9672
9673    #[test]
9674    fn hook_config_on_stage_async_is_rejected() {
9675        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
9676        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9677        assert!(err.contains("on-stage"), "error was: {err}");
9678        assert!(err.contains("blocking"), "error was: {err}");
9679    }
9680
9681    #[test]
9682    fn hook_config_async_none_is_valid() {
9683        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
9684        let config = parse_hook_config_from_yaml(yaml).unwrap();
9685        assert_eq!(config.execution_mode, HookExecutionMode::Async);
9686        assert_eq!(config.commit_policy, HookCommitPolicy::None);
9687    }
9688
9689    #[test]
9690    fn hook_config_parses_reopen_task_commit_policy() {
9691        let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
9692        let config = parse_hook_config_from_yaml(yaml).unwrap();
9693        assert_eq!(config.binding, HookBinding::OnTaskEnd);
9694        assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
9695    }
9696
9697    #[test]
9698    fn hook_config_unknown_commit_policy_lists_reopen_task() {
9699        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
9700        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9701        assert!(err.contains("reopen-task"), "error was: {err}");
9702    }
9703
9704    #[test]
9705    fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
9706        for binding in ["on-shell", "on-tool-call"] {
9707            let yaml =
9708                format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
9709            let config = parse_hook_config_from_yaml(&yaml).unwrap();
9710            assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
9711        }
9712        assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
9713    }
9714
9715    /// `deny` is the one policy an omitted `binding:` cannot carry: a hook that does not name
9716    /// which of the two gated events it decides on would be asked at both.
9717    #[test]
9718    fn hook_config_deny_without_a_binding_is_rejected() {
9719        let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
9720        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9721        assert!(
9722            err.contains("requires an explicit binding"),
9723            "error was: {err}"
9724        );
9725        assert!(err.contains("on-shell"), "error was: {err}");
9726        assert!(err.contains("on-tool-call"), "error was: {err}");
9727    }
9728
9729    /// Every other binding refuses `deny` through the existing binding-mismatch check.
9730    #[test]
9731    fn hook_config_deny_on_an_ungated_binding_is_rejected() {
9732        let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
9733        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9734        assert!(err.contains("is not valid for binding"), "error was: {err}");
9735        assert!(err.contains("replace-context"), "error was: {err}");
9736    }
9737
9738    /// The unchanged async/commit check covers `deny` for free; this pins that it does.
9739    #[test]
9740    fn hook_config_async_deny_is_rejected() {
9741        let yaml =
9742            "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
9743        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9744        assert!(err.contains("async-with-commit"), "error was: {err}");
9745        assert!(err.contains("deny"), "error was: {err}");
9746    }
9747
9748    #[test]
9749    fn hook_config_unknown_commit_policy_lists_deny() {
9750        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
9751        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9752        assert!(err.contains("deny"), "error was: {err}");
9753    }
9754
9755    /// The existing, unmodified async/commit validation must also reject
9756    /// `commit_policy: reopen-task` combined with `execution_mode: async`.
9757    #[test]
9758    fn hook_config_async_reopen_task_is_rejected() {
9759        let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
9760        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9761        assert!(err.contains("async-with-commit"), "error was: {err}");
9762        assert!(err.contains("reopen-task"), "error was: {err}");
9763    }
9764
9765    // ── binding is the single source of truth for what a hook commits ─────────────
9766
9767    /// The two bindings whose events honor no `hook-output` arm at all cannot declare
9768    /// any non-`none` `commit_policy`. The error names the binding, the declared policy,
9769    /// and `none` as what the binding honors.
9770    #[test]
9771    fn hook_config_non_committing_binding_rejects_any_commit_policy() {
9772        for binding in ["on-session-start", "on-session-end"] {
9773            let yaml = format!(
9774                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
9775            );
9776            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
9777            assert!(err.contains(binding), "error was: {err}");
9778            assert!(err.contains("replace-context"), "error was: {err}");
9779            assert!(
9780                err.contains("honors commit_policy 'none'"),
9781                "error was: {err}"
9782            );
9783        }
9784    }
9785
9786    /// A binding that honors one arm rejects every *other* policy, naming the one it does
9787    /// honor so the fix is obvious from the message alone.
9788    #[test]
9789    fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
9790        for (binding, declared, honored) in [
9791            ("on-stage", "reopen-task", "write-manifests"),
9792            ("on-compaction", "write-manifests", "replace-context"),
9793            ("on-task-end", "replace-context", "reopen-task"),
9794            ("on-task-start", "replace-context", "seed-context"),
9795            ("on-shell", "replace-context", "deny"),
9796            ("on-tool-call", "replace-context", "deny"),
9797        ] {
9798            let yaml = format!(
9799                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
9800            );
9801            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
9802            assert!(err.contains(binding), "error was: {err}");
9803            assert!(
9804                err.contains(&format!("commit_policy '{declared}' is not valid")),
9805                "error was: {err}"
9806            );
9807            assert!(
9808                err.contains(&format!("honors commit_policy '{honored}'")),
9809                "error was: {err}"
9810            );
9811        }
9812    }
9813
9814    /// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
9815    /// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
9816    #[test]
9817    fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
9818        for declared in ["replace-context", "write-manifests", "reopen-task"] {
9819            let yaml = format!(
9820                "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
9821            );
9822            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
9823            assert!(err.contains("on-inference"), "error was: {err}");
9824            assert!(err.contains(declared), "error was: {err}");
9825            assert!(
9826                err.contains("honors commit_policy 'none'"),
9827                "error was: {err}"
9828            );
9829            assert!(err.contains("artifact"), "error was: {err}");
9830        }
9831    }
9832
9833    /// A binding declaring exactly the policy it honors is accepted, for every
9834    /// representable pair.
9835    #[test]
9836    fn hook_config_matching_binding_and_commit_policy_is_valid() {
9837        for (binding, policy, expected) in [
9838            (
9839                "on-stage",
9840                "write-manifests",
9841                HookCommitPolicy::WriteManifests,
9842            ),
9843            (
9844                "on-compaction",
9845                "replace-context",
9846                HookCommitPolicy::ReplaceContext,
9847            ),
9848            ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
9849            ("on-shell", "deny", HookCommitPolicy::Deny),
9850            ("on-tool-call", "deny", HookCommitPolicy::Deny),
9851        ] {
9852            let yaml =
9853                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
9854            let config = parse_hook_config_from_yaml(&yaml).unwrap();
9855            assert_eq!(config.commit_policy, expected);
9856        }
9857    }
9858
9859    /// Every binding may declare `commit_policy: none` — including the ones that honor
9860    /// an arm, which simply means the hook opts out of committing.
9861    #[test]
9862    fn hook_config_commit_policy_none_is_valid_for_every_binding() {
9863        for binding in [
9864            "on-stage",
9865            "on-session-start",
9866            "on-task-start",
9867            "on-inference",
9868            "on-tool-call",
9869            "on-shell",
9870            "on-compaction",
9871            "on-task-end",
9872            "on-session-end",
9873        ] {
9874            let yaml =
9875                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
9876            let config = parse_hook_config_from_yaml(&yaml).unwrap();
9877            assert_eq!(config.commit_policy, HookCommitPolicy::None);
9878        }
9879    }
9880
9881    /// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
9882    /// to every event — including every one that honors an arm — so every `commit_policy` is
9883    /// accepted for it. Deliberate: no narrowing for `All`. `deny` is the one exception,
9884    /// covered by [`hook_config_deny_without_a_binding_is_rejected`].
9885    #[test]
9886    fn hook_config_omitted_binding_accepts_every_commit_policy() {
9887        for (policy, expected) in [
9888            ("replace-context", HookCommitPolicy::ReplaceContext),
9889            ("write-manifests", HookCommitPolicy::WriteManifests),
9890            ("reopen-task", HookCommitPolicy::ReopenTask),
9891            ("none", HookCommitPolicy::None),
9892        ] {
9893            let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
9894            let config = parse_hook_config_from_yaml(&yaml).unwrap();
9895            assert_eq!(config.binding, HookBinding::All);
9896            assert_eq!(config.commit_policy, expected);
9897        }
9898    }
9899
9900    /// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
9901    /// binding/commit_policy check exists — this change is non-breaking for all eight. The
9902    /// field combinations are reproduced literally rather than read from that repo, so this
9903    /// suite stays self-contained.
9904    #[test]
9905    fn hook_config_shipped_default_artifact_manifests_still_parse() {
9906        let shipped: [(&str, &str, HookConfig); 8] = [
9907            (
9908                "murmur-hook-compact",
9909                "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
9910                HookConfig {
9911                    binding: HookBinding::OnCompaction,
9912                    execution_mode: HookExecutionMode::Blocking,
9913                    commit_policy: HookCommitPolicy::ReplaceContext,
9914                },
9915            ),
9916            (
9917                "murmur-hook-shell-desc",
9918                "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
9919                HookConfig {
9920                    binding: HookBinding::OnStage,
9921                    execution_mode: HookExecutionMode::Blocking,
9922                    commit_policy: HookCommitPolicy::WriteManifests,
9923                },
9924            ),
9925            (
9926                // No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
9927                "murmur-hook-regression-verifier",
9928                "execution_mode: blocking\ncommit_policy: reopen-task\n",
9929                HookConfig {
9930                    binding: HookBinding::All,
9931                    execution_mode: HookExecutionMode::Blocking,
9932                    commit_policy: HookCommitPolicy::ReopenTask,
9933                },
9934            ),
9935            (
9936                "murmur-hook-memory",
9937                "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
9938                HookConfig {
9939                    binding: HookBinding::OnTaskStart,
9940                    execution_mode: HookExecutionMode::Blocking,
9941                    commit_policy: HookCommitPolicy::SeedContext,
9942                },
9943            ),
9944            (
9945                "murmur-hook-debug",
9946                "execution_mode: async\ncommit_policy: none\n",
9947                HookConfig {
9948                    binding: HookBinding::All,
9949                    execution_mode: HookExecutionMode::Async,
9950                    commit_policy: HookCommitPolicy::None,
9951                },
9952            ),
9953            (
9954                "murmur-hook-diff-summary",
9955                "execution_mode: blocking\ncommit_policy: none\n",
9956                HookConfig {
9957                    binding: HookBinding::All,
9958                    execution_mode: HookExecutionMode::Blocking,
9959                    commit_policy: HookCommitPolicy::None,
9960                },
9961            ),
9962            ("murmur-hook-eval", "", HookConfig::default()),
9963            ("murmur-hook-grafana", "", HookConfig::default()),
9964        ];
9965
9966        for (name, fields, expected) in shipped {
9967            let yaml = format!("name: {name}\nruntime: hook\n{fields}");
9968            let config = parse_hook_config_from_yaml(&yaml)
9969                .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
9970            assert_eq!(config, expected, "shipped hook {name}");
9971        }
9972    }
9973
9974    /// The manifest-side honored-policy table agrees with the manifest spellings the
9975    /// parser accepts; `capsule-runtime` separately cross-checks it against its own
9976    /// `HONORED_OUTPUT_ARM` dispatch table.
9977    #[test]
9978    fn commit_policy_for_binding_matches_the_declarable_pairs() {
9979        assert_eq!(
9980            commit_policy_for_binding(&HookBinding::OnStage),
9981            Some(HookCommitPolicy::WriteManifests)
9982        );
9983        assert_eq!(
9984            commit_policy_for_binding(&HookBinding::OnCompaction),
9985            Some(HookCommitPolicy::ReplaceContext)
9986        );
9987        assert_eq!(
9988            commit_policy_for_binding(&HookBinding::OnTaskEnd),
9989            Some(HookCommitPolicy::ReopenTask)
9990        );
9991        assert_eq!(
9992            commit_policy_for_binding(&HookBinding::OnTaskStart),
9993            Some(HookCommitPolicy::SeedContext)
9994        );
9995        assert_eq!(
9996            commit_policy_for_binding(&HookBinding::OnToolCall),
9997            Some(HookCommitPolicy::Deny)
9998        );
9999        assert_eq!(
10000            commit_policy_for_binding(&HookBinding::OnShell),
10001            Some(HookCommitPolicy::Deny)
10002        );
10003        // `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
10004        // other two honor nothing. `All` is unconstrained by design, apart from the `deny`
10005        // carve-out the parser applies.
10006        for binding in [
10007            HookBinding::OnSessionStart,
10008            HookBinding::OnInference,
10009            HookBinding::OnSessionEnd,
10010            HookBinding::All,
10011        ] {
10012            assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
10013        }
10014    }
10015
10016    /// The `on-task-start` pairing is declarable end to end: the parser accepts it, and
10017    /// refuses the same policy on any other binding by name.
10018    #[test]
10019    fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
10020        let config = parse_hook_config_from_yaml(
10021            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
10022             commit_policy: seed-context\n",
10023        )
10024        .expect("on-task-start + seed-context is the declarable pairing");
10025        assert_eq!(config.binding, HookBinding::OnTaskStart);
10026        assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
10027
10028        let error = parse_hook_config_from_yaml(
10029            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
10030             commit_policy: seed-context\n",
10031        )
10032        .expect_err("on-compaction cannot commit a seed");
10033        assert!(error.contains("on-compaction"), "{error}");
10034        assert!(error.contains("seed-context"), "{error}");
10035    }
10036
10037    /// The unknown-value diagnostic lists every accepted spelling, so an operator who
10038    /// mistypes one reads the whole set rather than guessing.
10039    #[test]
10040    fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
10041        let error = parse_hook_config_from_yaml(
10042            "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
10043        )
10044        .expect_err("a misspelled policy is rejected");
10045        assert!(error.contains("seed-context"), "{error}");
10046    }
10047
10048    // ── context.seed_budget / context.seed_overflow_margin ───────────────────
10049
10050    fn context_of(yaml_block: &str) -> Option<ContextConfig> {
10051        RuntimeManifest::from_yaml_str(&format!(
10052            "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
10053        ))
10054        .expect("the manifest under test parses")
10055        .context
10056    }
10057
10058    #[test]
10059    fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
10060        let context = context_of(
10061            "context:\n  max_tokens: 200000\n  seed_budget: 0.10\n  seed_overflow_margin: 0.10\n",
10062        )
10063        .expect("a declared context block parses to Some");
10064        assert_eq!(context.max_tokens, Some(200_000));
10065        assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
10066        assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
10067    }
10068
10069    #[test]
10070    fn seed_budget_and_overflow_margin_default_when_omitted() {
10071        let context = context_of("context:\n  max_tokens: 200000\n")
10072            .expect("a declared context block parses to Some");
10073        assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
10074        assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
10075    }
10076
10077    /// No `context:` block at all stays `None` — the two fractions default *within* a
10078    /// declared block, and never conjure one that the operator did not write.
10079    #[test]
10080    fn seed_budget_defaults_do_not_create_a_context_block() {
10081        assert_eq!(context_of(""), None);
10082    }
10083
10084    #[test]
10085    fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
10086        for (block, field) in [
10087            ("context:\n  seed_budget: 1.5\n", "context.seed_budget"),
10088            ("context:\n  seed_budget: -0.1\n", "context.seed_budget"),
10089            (
10090                "context:\n  seed_overflow_margin: 2.0\n",
10091                "context.seed_overflow_margin",
10092            ),
10093        ] {
10094            let error = RuntimeManifest::from_yaml_str(&format!(
10095                "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
10096            ))
10097            .expect_err("an out-of-range fraction is rejected");
10098            match error {
10099                RuntimeManifestError::InvalidInferenceConfig {
10100                    field: got,
10101                    ref message,
10102                } => {
10103                    assert_eq!(got, field);
10104                    assert!(message.contains("0.0 and 1.0"), "{message}");
10105                }
10106                other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
10107            }
10108        }
10109    }
10110
10111    #[test]
10112    fn parse_tool_implementation_defaults_to_wasm() {
10113        let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
10114        assert_eq!(impl_, ArtifactImplementation::Wasm);
10115    }
10116
10117    #[test]
10118    fn parse_tool_implementation_native() {
10119        let impl_ = parse_tool_implementation_from_yaml(
10120            "name: my-tool\nruntime: tool\nimplementation: native\n",
10121        );
10122        assert_eq!(impl_, ArtifactImplementation::Native);
10123    }
10124
10125    #[test]
10126    fn mur_version_parses_correctly() {
10127        let manifest = RuntimeManifest::from_yaml_str(
10128            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
10129        )
10130        .unwrap();
10131        assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
10132    }
10133
10134    #[test]
10135    fn mur_version_absent_is_none() {
10136        let manifest =
10137            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
10138        assert_eq!(manifest.mur_version, None);
10139    }
10140
10141    #[test]
10142    fn mur_version_empty_string_is_none() {
10143        let manifest = RuntimeManifest::from_yaml_str(
10144            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
10145        )
10146        .unwrap();
10147        assert_eq!(manifest.mur_version, None);
10148    }
10149
10150    #[test]
10151    fn inference_max_turns_defaults_to_10() {
10152        let manifest = RuntimeManifest::from_yaml_str(
10153            "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",
10154        ).unwrap();
10155        assert_eq!(manifest.inference.unwrap().max_turns, 10);
10156    }
10157
10158    #[test]
10159    fn inference_max_turns_explicit_value() {
10160        let manifest = RuntimeManifest::from_yaml_str(
10161            "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",
10162        ).unwrap();
10163        assert_eq!(manifest.inference.unwrap().max_turns, 20);
10164    }
10165
10166    #[test]
10167    fn inference_max_turns_zero_is_rejected() {
10168        let err = RuntimeManifest::from_yaml_str(
10169            "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",
10170        ).unwrap_err();
10171        let msg = err.to_string();
10172        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
10173        assert!(msg.contains("greater than 0"), "error was: {msg}");
10174    }
10175
10176    /// A `lifecycle:` block that says nothing about the budget still permits one reopen.
10177    #[test]
10178    fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
10179        let manifest = RuntimeManifest::from_yaml_str(
10180            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 45\n",
10181        )
10182        .unwrap();
10183        let lifecycle = manifest.lifecycle.unwrap();
10184        assert_eq!(lifecycle.shell_grace_secs, 45);
10185        // Every other lifecycle field is untouched by declaring this one.
10186        assert_eq!(
10187            lifecycle.task_acceptance,
10188            LifecycleConfig::default().task_acceptance
10189        );
10190        assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
10191        assert_eq!(
10192            lifecycle.queue_depth,
10193            LifecycleConfig::default().queue_depth
10194        );
10195        assert_eq!(lifecycle.input_timeout_secs, None);
10196        assert_eq!(
10197            lifecycle.conversation_mode,
10198            LifecycleConfig::default().conversation_mode
10199        );
10200        assert_eq!(
10201            lifecycle.max_task_reopens,
10202            LifecycleConfig::default().max_task_reopens
10203        );
10204    }
10205
10206    #[test]
10207    fn lifecycle_shell_grace_secs_defaults_to_10() {
10208        let manifest = RuntimeManifest::from_yaml_str(
10209            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
10210        )
10211        .unwrap();
10212        let lifecycle = manifest.lifecycle.unwrap();
10213        assert_eq!(lifecycle.shell_grace_secs, 10);
10214        assert_eq!(lifecycle.queue_depth, 3);
10215
10216        let without_block =
10217            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
10218        assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
10219    }
10220
10221    /// The bound a capsule delegates under is declared, and a capsule that declares nothing is
10222    /// still bounded: absent means a ceiling, never "wait forever".
10223    #[test]
10224    fn lifecycle_delegation_deadline_secs_defaults_to_600() {
10225        let without_block =
10226            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
10227        assert_eq!(
10228            without_block.effective_lifecycle().delegation_deadline_secs,
10229            600
10230        );
10231
10232        let without_key = RuntimeManifest::from_yaml_str(
10233            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
10234        )
10235        .unwrap();
10236        let lifecycle = without_key.lifecycle.unwrap();
10237        assert_eq!(lifecycle.delegation_deadline_secs, 600);
10238        assert_eq!(lifecycle.queue_depth, 3);
10239    }
10240
10241    #[test]
10242    fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
10243        let manifest = RuntimeManifest::from_yaml_str(
10244            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
10245             delegation_deadline_secs: 30\n",
10246        )
10247        .unwrap();
10248        let lifecycle = manifest.lifecycle.unwrap();
10249        assert_eq!(lifecycle.delegation_deadline_secs, 30);
10250        // Every other lifecycle field is untouched by declaring this one.
10251        assert_eq!(
10252            lifecycle.shell_grace_secs,
10253            LifecycleConfig::default().shell_grace_secs
10254        );
10255        assert_eq!(
10256            lifecycle.max_task_reopens,
10257            LifecycleConfig::default().max_task_reopens
10258        );
10259    }
10260
10261    /// Declaring the key produces no unknown-key report, and a misspelling of it still does.
10262    #[test]
10263    fn lifecycle_delegation_deadline_secs_is_a_known_key() {
10264        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
10265        let manifest = RuntimeManifest::from_yaml_str(
10266            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
10267             delegation_deadline_secs: 30\n  delegation_deadline: 30\n",
10268        )
10269        .unwrap();
10270        let reported: Vec<(String, String)> = manifest
10271            .unknown_keys
10272            .iter()
10273            .map(|key| (key.key.clone(), key.block_path.clone()))
10274            .collect();
10275        assert_eq!(
10276            reported,
10277            vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
10278            "the declared key is known and only the misspelt neighbour is reported"
10279        );
10280        assert_eq!(
10281            manifest.lifecycle.unwrap().delegation_deadline_secs,
10282            30,
10283            "the misspelt neighbour does not disturb the declared value"
10284        );
10285    }
10286
10287    /// `0` is a valid explicit value, not an absent one: it demotes at the first poll after the
10288    /// spawn, so effectively every command detaches.
10289    #[test]
10290    fn lifecycle_shell_grace_secs_zero_is_accepted() {
10291        let manifest = RuntimeManifest::from_yaml_str(
10292            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 0\n",
10293        )
10294        .unwrap();
10295        let lifecycle = manifest.lifecycle.unwrap();
10296        assert_eq!(lifecycle.shell_grace_secs, 0);
10297        assert_eq!(
10298            lifecycle.max_task_reopens,
10299            LifecycleConfig::default().max_task_reopens
10300        );
10301    }
10302
10303    #[test]
10304    fn lifecycle_max_task_reopens_defaults_to_1() {
10305        let manifest = RuntimeManifest::from_yaml_str(
10306            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  after_task: sleep\n",
10307        )
10308        .unwrap();
10309        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
10310    }
10311
10312    /// No `lifecycle:` block at all lands on the same default through `effective_lifecycle`.
10313    #[test]
10314    fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
10315        let manifest =
10316            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
10317        assert!(manifest.lifecycle.is_none());
10318        assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
10319    }
10320
10321    #[test]
10322    fn lifecycle_max_task_reopens_explicit_value() {
10323        let manifest = RuntimeManifest::from_yaml_str(
10324            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 3\n",
10325        )
10326        .unwrap();
10327        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
10328    }
10329
10330    /// Unlike `inference.max_turns`, `0` is a valid explicit value — it disables reopening.
10331    #[test]
10332    fn lifecycle_max_task_reopens_zero_is_accepted() {
10333        let manifest = RuntimeManifest::from_yaml_str(
10334            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 0\n",
10335        )
10336        .unwrap();
10337        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
10338    }
10339
10340    /// The budget is independent of the inference transport in play.
10341    #[test]
10342    fn lifecycle_max_task_reopens_with_process_transport() {
10343        let manifest = RuntimeManifest::from_yaml_str(
10344            "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",
10345        ).unwrap();
10346        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
10347    }
10348
10349    #[test]
10350    fn inference_max_task_reopens_is_rejected_under_http_transport() {
10351        let err = RuntimeManifest::from_yaml_str(
10352            "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",
10353        ).unwrap_err();
10354        assert!(
10355            matches!(
10356                &err,
10357                RuntimeManifestError::InvalidInferenceConfig { field, .. }
10358                    if field == "inference.max_task_reopens"
10359            ),
10360            "error was: {err:?}"
10361        );
10362        let msg = err.to_string();
10363        assert!(
10364            msg.contains("lifecycle.max_task_reopens"),
10365            "error was: {msg}"
10366        );
10367    }
10368
10369    #[test]
10370    fn inference_max_task_reopens_is_rejected_under_process_transport() {
10371        let err = RuntimeManifest::from_yaml_str(
10372            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\n",
10373        ).unwrap_err();
10374        assert!(
10375            matches!(
10376                &err,
10377                RuntimeManifestError::InvalidInferenceConfig { field, .. }
10378                    if field == "inference.max_task_reopens"
10379            ),
10380            "error was: {err:?}"
10381        );
10382        let msg = err.to_string();
10383        assert!(
10384            msg.contains("lifecycle.max_task_reopens"),
10385            "error was: {msg}"
10386        );
10387    }
10388
10389    /// The rejection is unconditional: the new key being present alongside it changes nothing.
10390    #[test]
10391    fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
10392        let err = RuntimeManifest::from_yaml_str(
10393            "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",
10394        ).unwrap_err();
10395        assert!(
10396            matches!(
10397                &err,
10398                RuntimeManifestError::InvalidInferenceConfig { field, .. }
10399                    if field == "inference.max_task_reopens"
10400            ),
10401            "error was: {err:?}"
10402        );
10403    }
10404
10405    #[test]
10406    fn skill_is_llm_visible() {
10407        assert!(ArtifactRuntime::Skill.is_llm_visible());
10408    }
10409
10410    #[test]
10411    fn driver_and_hook_are_not_llm_visible() {
10412        assert!(!ArtifactRuntime::Driver.is_llm_visible());
10413        assert!(!ArtifactRuntime::Hook.is_llm_visible());
10414    }
10415
10416    #[test]
10417    fn parses_system_prompt_artifact() {
10418        let manifest = RuntimeManifest::from_yaml_str(
10419            r#"
10420name: cap
10421version: 0.0.1
10422artifacts:
10423  - name: my-skill
10424    version: 1.0.0
10425    runtime: skill
10426  - name: murmur-driver-anthropic
10427    version: 0.1.0
10428    runtime: driver
10429    gateway:
10430      endpoint: http://127.0.0.1:8080
10431      keyless: true
10432inference:
10433  model: test-model
10434  system_prompt_artifact: my-skill
10435  driver:
10436    artifact: murmur-driver-anthropic
10437"#,
10438        )
10439        .unwrap();
10440
10441        let inference = manifest.inference.unwrap();
10442        assert_eq!(
10443            inference.system_prompt_artifact,
10444            Some("my-skill".to_string())
10445        );
10446        assert!(inference.system_prompt.is_none());
10447        assert!(inference.system_prompt_file.is_none());
10448    }
10449
10450    #[test]
10451    fn rejects_system_prompt_artifact_with_inline_prompt() {
10452        let err = RuntimeManifest::from_yaml_str(
10453            r#"
10454name: cap
10455version: 0.0.1
10456artifacts:
10457  - name: my-skill
10458    version: 1.0.0
10459    runtime: skill
10460  - name: murmur-driver-anthropic
10461    version: 0.1.0
10462    runtime: driver
10463    gateway:
10464      endpoint: http://127.0.0.1:8080
10465      keyless: true
10466inference:
10467  model: test-model
10468  system_prompt: "inline"
10469  system_prompt_artifact: my-skill
10470  driver:
10471    artifact: murmur-driver-anthropic
10472"#,
10473        )
10474        .unwrap_err();
10475
10476        let msg = err.to_string();
10477        assert!(msg.contains("at most one"), "error was: {msg}");
10478    }
10479
10480    #[test]
10481    fn rejects_system_prompt_artifact_naming_non_skill() {
10482        let err = RuntimeManifest::from_yaml_str(
10483            r#"
10484name: cap
10485version: 0.0.1
10486artifacts:
10487  - name: my-tool
10488    version: 1.0.0
10489    runtime: tool
10490  - name: murmur-driver-anthropic
10491    version: 0.1.0
10492    runtime: driver
10493    gateway:
10494      endpoint: http://127.0.0.1:8080
10495      keyless: true
10496inference:
10497  model: test-model
10498  system_prompt_artifact: my-tool
10499  driver:
10500    artifact: murmur-driver-anthropic
10501"#,
10502        )
10503        .unwrap_err();
10504
10505        let msg = err.to_string();
10506        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
10507        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10508    }
10509
10510    #[test]
10511    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
10512        let err = RuntimeManifest::from_yaml_str(
10513            r#"
10514name: cap
10515version: 0.0.1
10516artifacts:
10517  - name: murmur-driver-anthropic
10518    version: 0.1.0
10519    runtime: driver
10520    gateway:
10521      endpoint: http://127.0.0.1:8080
10522      keyless: true
10523inference:
10524  model: test-model
10525  system_prompt_artifact: missing-skill
10526  driver:
10527    artifact: murmur-driver-anthropic
10528"#,
10529        )
10530        .unwrap_err();
10531
10532        let msg = err.to_string();
10533        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
10534        assert!(msg.contains("not declared"), "error was: {msg}");
10535    }
10536
10537    #[test]
10538    fn rejects_all_three_system_prompt_sources() {
10539        let err = RuntimeManifest::from_yaml_str(
10540            r#"
10541name: cap
10542version: 0.0.1
10543artifacts:
10544  - name: my-skill
10545    version: 1.0.0
10546    runtime: skill
10547  - name: murmur-driver-anthropic
10548    version: 0.1.0
10549    runtime: driver
10550    gateway:
10551      endpoint: http://127.0.0.1:8080
10552      keyless: true
10553inference:
10554  model: test-model
10555  system_prompt: "inline"
10556  system_prompt_file: "conventions.md"
10557  system_prompt_artifact: my-skill
10558  driver:
10559    artifact: murmur-driver-anthropic
10560"#,
10561        )
10562        .unwrap_err();
10563
10564        let msg = err.to_string();
10565        assert!(msg.contains("at most one"), "error was: {msg}");
10566    }
10567
10568    // ── Per-artifact capability grants ───────────────────────────────────────
10569
10570    #[test]
10571    fn hook_artifact_capabilities_parse_network_and_filesystem() {
10572        let manifest = RuntimeManifest::from_yaml_str(
10573            r#"
10574name: cap
10575version: 0.0.1
10576artifacts:
10577  - name: telemetry-hook
10578    version: 1.0.0
10579    runtime: hook
10580    capabilities:
10581      network:
10582        allow:
10583          - https://telemetry.example.com
10584      filesystem:
10585        scope: hook-state
10586"#,
10587        )
10588        .unwrap();
10589
10590        let caps = manifest.artifacts[0]
10591            .capabilities
10592            .as_ref()
10593            .expect("hook entry carries a capability grant");
10594        assert_eq!(
10595            caps.network.as_ref().unwrap().allow,
10596            vec!["https://telemetry.example.com".to_string()]
10597        );
10598        assert_eq!(
10599            caps.filesystem.as_ref().unwrap().scope,
10600            Some("hook-state".to_string())
10601        );
10602    }
10603
10604    /// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
10605    /// runtime lowers to zero network and zero preopened directories.
10606    #[test]
10607    fn hook_artifact_without_capabilities_is_none() {
10608        let manifest = RuntimeManifest::from_yaml_str(
10609            r#"
10610name: cap
10611version: 0.0.1
10612artifacts:
10613  - name: quiet-hook
10614    version: 1.0.0
10615    runtime: hook
10616"#,
10617        )
10618        .unwrap();
10619
10620        assert!(manifest.artifacts[0].capabilities.is_none());
10621    }
10622
10623    /// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
10624    /// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
10625    #[test]
10626    fn tool_and_driver_artifact_capabilities_parse() {
10627        for role in ["tool", "driver"] {
10628            let yaml = format!(
10629                r#"
10630name: cap
10631version: 0.0.1
10632artifacts:
10633  - name: scoped
10634    version: 1.0.0
10635    runtime: {role}
10636    capabilities:
10637      network:
10638        allow:
10639          - https://api.example.com
10640      filesystem:
10641        scope: cache
10642"#
10643            );
10644            let manifest = RuntimeManifest::from_yaml_str(&yaml)
10645                .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
10646
10647            let caps = manifest.artifacts[0]
10648                .capabilities
10649                .as_ref()
10650                .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
10651            assert_eq!(
10652                caps.network.as_ref().unwrap().allow,
10653                vec!["https://api.example.com".to_string()]
10654            );
10655            assert_eq!(
10656                caps.filesystem.as_ref().unwrap().scope,
10657                Some("cache".to_string())
10658            );
10659        }
10660    }
10661
10662    /// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
10663    /// lowers to the unchanged capsule ceiling.
10664    #[test]
10665    fn tool_artifact_without_capabilities_is_none() {
10666        let manifest = RuntimeManifest::from_yaml_str(
10667            r#"
10668name: cap
10669version: 0.0.1
10670artifacts:
10671  - name: plain-tool
10672    version: 1.0.0
10673    runtime: tool
10674"#,
10675        )
10676        .unwrap();
10677
10678        assert!(manifest.artifacts[0].capabilities.is_none());
10679    }
10680
10681    /// A skill has no execution surface, so a grant on one would be silently unenforced —
10682    /// rejected at parse time, with a message naming the roles that do work.
10683    #[test]
10684    fn skill_artifact_capabilities_are_rejected() {
10685        let yaml = r#"
10686name: cap
10687version: 0.0.1
10688artifacts:
10689  - name: sneaky
10690    version: 1.0.0
10691    runtime: skill
10692    capabilities:
10693      network:
10694        allow:
10695          - https://evil.example.com
10696"#;
10697        let err = match RuntimeManifest::from_yaml_str(yaml) {
10698            Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
10699            Err(err) => err,
10700        };
10701        let msg = err.to_string();
10702        assert!(msg.contains("sneaky"), "error was: {msg}");
10703        assert!(
10704            msg.contains(
10705                "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
10706                 entries"
10707            ),
10708            "error was: {msg}"
10709        );
10710        assert!(msg.contains("runtime: skill"), "error was: {msg}");
10711    }
10712
10713    // ── Per-artifact `config:` (operator-authored artifact configuration) ────
10714
10715    /// The block is carried verbatim as YAML. This crate accepts any mapping; the runtime is
10716    /// what lowers it to JSON and refuses a shape that cannot travel that way.
10717    #[test]
10718    fn artifact_config_is_carried_verbatim() {
10719        let manifest = RuntimeManifest::from_yaml_str(
10720            r#"
10721name: cap
10722version: 0.0.1
10723artifacts:
10724  - name: murmur-tool-corpus
10725    version: 0.1.0
10726    runtime: tool
10727    config:
10728      read_recent: { default: 20, max: 100 }
10729"#,
10730        )
10731        .unwrap();
10732
10733        let config = manifest.artifacts[0]
10734            .config
10735            .as_ref()
10736            .expect("a declared block parses");
10737        assert_eq!(
10738            config["read_recent"]["max"],
10739            serde_yaml::Value::Number(100.into())
10740        );
10741    }
10742
10743    /// Absent means absent: no key, no variable, and nothing for the runtime to lower.
10744    #[test]
10745    fn an_undeclared_config_is_none() {
10746        let manifest = RuntimeManifest::from_yaml_str(
10747            r#"
10748name: cap
10749version: 0.0.1
10750artifacts:
10751  - name: git
10752    version: 1.0.0
10753    runtime: tool
10754"#,
10755        )
10756        .unwrap();
10757
10758        assert!(manifest.artifacts[0].config.is_none());
10759    }
10760
10761    /// `config:` with nothing under it is a written declaration that carries nothing, and must
10762    /// stay distinguishable from the key being absent — the runtime refuses it, and can only do
10763    /// so if the parser does not collapse YAML null into `None`.
10764    #[test]
10765    fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
10766        let manifest = RuntimeManifest::from_yaml_str(
10767            r#"
10768name: cap
10769version: 0.0.1
10770artifacts:
10771  - name: git
10772    version: 1.0.0
10773    runtime: tool
10774    config:
10775"#,
10776        )
10777        .unwrap();
10778
10779        assert_eq!(
10780            manifest.artifacts[0].config,
10781            Some(serde_yaml::Value::Null),
10782            "an empty block must survive parsing as a declaration, not vanish into None"
10783        );
10784    }
10785
10786    /// A skill runs no component and holds no grant, so nothing would ever deliver a config
10787    /// block to one — rejected at parse time, with a message naming the roles that do.
10788    #[test]
10789    fn skill_artifact_config_is_rejected() {
10790        let yaml = r#"
10791name: cap
10792version: 0.0.1
10793artifacts:
10794  - name: sneaky
10795    version: 1.0.0
10796    runtime: skill
10797    config:
10798      who: me
10799"#;
10800        let err = match RuntimeManifest::from_yaml_str(yaml) {
10801            Ok(_) => panic!("config: must be rejected for runtime: skill"),
10802            Err(err) => err,
10803        };
10804        assert!(matches!(
10805            err,
10806            RuntimeManifestError::InvalidArtifact { index: 0, .. }
10807        ));
10808        let msg = err.to_string();
10809        assert!(msg.contains("sneaky"), "error was: {msg}");
10810        assert!(msg.contains("runtime: skill"), "error was: {msg}");
10811        assert!(
10812            msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
10813            "error was: {msg}"
10814        );
10815    }
10816
10817    /// Config is delivered on one artifact's own grant, so a capsule-wide block reaches nothing.
10818    /// Refused rather than silently ignored, and the message points at the artifact entry.
10819    #[test]
10820    fn a_capsule_wide_config_block_is_rejected() {
10821        let yaml = r#"
10822name: cap
10823version: 0.0.1
10824config:
10825  who: capsule
10826artifacts:
10827  - name: git
10828    version: 1.0.0
10829    runtime: tool
10830"#;
10831        let err = match RuntimeManifest::from_yaml_str(yaml) {
10832            Ok(_) => panic!("a capsule-wide config block must be rejected"),
10833            Err(err) => err,
10834        };
10835        let msg = err.to_string();
10836        assert!(msg.contains("config"), "error was: {msg}");
10837        assert!(msg.contains("declared per artifact"), "error was: {msg}");
10838    }
10839
10840    // ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
10841
10842    /// `on_overflow: block` on a hook entry parses to the blocking policy.
10843    #[test]
10844    fn hook_on_overflow_block_parses() {
10845        let manifest = RuntimeManifest::from_yaml_str(
10846            r#"
10847name: cap
10848version: 0.0.1
10849artifacts:
10850  - name: murmur-hook-grafana
10851    version: 1.0.0
10852    runtime: hook
10853    on_overflow: block
10854"#,
10855        )
10856        .unwrap();
10857
10858        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
10859    }
10860
10861    /// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
10862    /// not backpressure they never asked for.
10863    #[test]
10864    fn hook_on_overflow_defaults_to_drop() {
10865        let manifest = RuntimeManifest::from_yaml_str(
10866            r#"
10867name: cap
10868version: 0.0.1
10869artifacts:
10870  - name: murmur-hook-grafana
10871    version: 1.0.0
10872    runtime: hook
10873"#,
10874        )
10875        .unwrap();
10876
10877        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
10878        assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
10879    }
10880
10881    /// Only a hook has a job queue, so the key is rejected on every other role rather than
10882    /// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
10883    #[test]
10884    fn on_overflow_on_a_non_hook_artifact_is_rejected() {
10885        let yaml = r#"
10886name: cap
10887version: 0.0.1
10888artifacts:
10889  - name: git
10890    version: 1.0.0
10891    runtime: tool
10892    on_overflow: block
10893"#;
10894        let err = match RuntimeManifest::from_yaml_str(yaml) {
10895            Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
10896            Err(err) => err,
10897        };
10898        assert!(matches!(
10899            err,
10900            RuntimeManifestError::InvalidArtifact { index: 0, .. }
10901        ));
10902        let msg = err.to_string();
10903        assert!(msg.contains("git"), "error was: {msg}");
10904        assert!(
10905            msg.contains("only recognized on 'runtime: hook' entries"),
10906            "error was: {msg}"
10907        );
10908    }
10909
10910    /// An unknown value is a typo, not a policy — rejected with both spellings named.
10911    #[test]
10912    fn unknown_on_overflow_value_is_rejected() {
10913        let yaml = r#"
10914name: cap
10915version: 0.0.1
10916artifacts:
10917  - name: murmur-hook-grafana
10918    version: 1.0.0
10919    runtime: hook
10920    on_overflow: spill
10921"#;
10922        let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
10923        let msg = err.to_string();
10924        assert!(msg.contains("spill"), "error was: {msg}");
10925        assert!(msg.contains("expected: drop, block"), "error was: {msg}");
10926    }
10927
10928    /// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
10929    /// cannot know a hook's execution mode, which lives in the artifact's own manifest.
10930    #[test]
10931    fn on_overflow_is_accepted_on_any_hook_entry() {
10932        let manifest = RuntimeManifest::from_yaml_str(
10933            r#"
10934name: cap
10935version: 0.0.1
10936artifacts:
10937  - name: murmur-hook-eval
10938    version: 1.0.0
10939    runtime: hook
10940    on_overflow: drop
10941    capabilities:
10942      network:
10943        allow:
10944          - https://telemetry.example.com
10945"#,
10946        )
10947        .unwrap();
10948
10949        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
10950        assert!(manifest.artifacts[0].capabilities.is_some());
10951    }
10952
10953    /// The capsule-wide top-level block is untouched by the per-artifact field: both may be
10954    /// present in one manifest and neither shadows the other.
10955    #[test]
10956    fn capsule_wide_and_hook_capabilities_coexist() {
10957        let manifest = RuntimeManifest::from_yaml_str(
10958            r#"
10959name: cap
10960version: 0.0.1
10961artifacts:
10962  - name: telemetry-hook
10963    version: 1.0.0
10964    runtime: hook
10965    capabilities:
10966      network:
10967        allow:
10968          - https://telemetry.example.com
10969capabilities:
10970  network:
10971    allow:
10972      - https://api.anthropic.com
10973"#,
10974        )
10975        .unwrap();
10976
10977        assert_eq!(
10978            manifest.capabilities.unwrap().network.unwrap().allow,
10979            vec!["https://api.anthropic.com".to_string()]
10980        );
10981        assert_eq!(
10982            manifest.artifacts[0]
10983                .capabilities
10984                .as_ref()
10985                .unwrap()
10986                .network
10987                .as_ref()
10988                .unwrap()
10989                .allow,
10990            vec!["https://telemetry.example.com".to_string()]
10991        );
10992    }
10993
10994    /// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
10995    /// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
10996    /// reads one. A self-declared `capabilities:` block there is inert.
10997    #[test]
10998    fn hook_own_manifest_capabilities_are_not_read_into_config() {
10999        let config = parse_hook_config_from_yaml(
11000            r#"
11001name: telemetry-hook
11002version: 1.0.0
11003binding: on-inference
11004execution_mode: blocking
11005capabilities:
11006  network:
11007    allow:
11008      - https://evil.example.com
11009  filesystem:
11010    scope: /
11011"#,
11012        )
11013        .expect("a self-declared capabilities block is ignored, not an error");
11014
11015        assert_eq!(
11016            config,
11017            HookConfig {
11018                binding: HookBinding::OnInference,
11019                execution_mode: HookExecutionMode::Blocking,
11020                commit_policy: HookCommitPolicy::None,
11021            }
11022        );
11023    }
11024
11025    // ── Containment class ────────────────────────────────────────────────────
11026
11027    #[test]
11028    fn containment_classes_order_weakest_to_strongest() {
11029        assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
11030        assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
11031        assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
11032        assert_eq!(
11033            ContainmentClass::ALL.iter().copied().max(),
11034            Some(ContainmentClass::Sealed)
11035        );
11036    }
11037
11038    #[test]
11039    fn containment_class_defaults_to_advisory() {
11040        assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
11041    }
11042
11043    #[test]
11044    fn containment_class_round_trips_through_string() {
11045        for class in ContainmentClass::ALL {
11046            assert_eq!(
11047                class.to_string().parse::<ContainmentClass>(),
11048                Ok(class),
11049                "{class} must survive Display -> FromStr"
11050            );
11051        }
11052        assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
11053        assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
11054        assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
11055    }
11056
11057    #[test]
11058    fn containment_class_rejects_unknown_names() {
11059        let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
11060        assert_eq!(err.value, "paranoid");
11061        assert_eq!(
11062            err.to_string(),
11063            "must be one of: advisory, scoped, sealed; got 'paranoid'"
11064        );
11065        // Case-sensitive by design: the wire form is lowercase everywhere.
11066        assert!("Sealed".parse::<ContainmentClass>().is_err());
11067    }
11068
11069    #[test]
11070    fn containment_class_serializes_as_its_wire_name() {
11071        assert_eq!(
11072            serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
11073            "\"scoped\""
11074        );
11075        assert_eq!(
11076            serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
11077            ContainmentClass::Sealed
11078        );
11079    }
11080
11081    #[test]
11082    fn effective_floor_is_advisory_when_nothing_is_declared() {
11083        assert_eq!(
11084            effective_containment_floor(None, None, None),
11085            ContainmentClass::Advisory
11086        );
11087    }
11088
11089    #[test]
11090    fn effective_floor_takes_the_strongest_across_every_presence_combination() {
11091        use ContainmentClass::{Advisory, Scoped, Sealed};
11092
11093        // Exactly one source present: that source wins outright.
11094        assert_eq!(
11095            effective_containment_floor(Some(Scoped), None, None),
11096            Scoped
11097        );
11098        assert_eq!(
11099            effective_containment_floor(None, Some(Scoped), None),
11100            Scoped
11101        );
11102        assert_eq!(
11103            effective_containment_floor(None, None, Some(Scoped)),
11104            Scoped
11105        );
11106        assert_eq!(
11107            effective_containment_floor(Some(Sealed), None, None),
11108            Sealed
11109        );
11110        assert_eq!(
11111            effective_containment_floor(None, Some(Sealed), None),
11112            Sealed
11113        );
11114        assert_eq!(
11115            effective_containment_floor(None, None, Some(Sealed)),
11116            Sealed
11117        );
11118
11119        // Two sources present: the stronger wins regardless of which slot holds it, and a
11120        // weaker source can never pull the stronger one down.
11121        assert_eq!(
11122            effective_containment_floor(Some(Advisory), Some(Sealed), None),
11123            Sealed
11124        );
11125        assert_eq!(
11126            effective_containment_floor(Some(Sealed), Some(Advisory), None),
11127            Sealed
11128        );
11129        assert_eq!(
11130            effective_containment_floor(Some(Advisory), None, Some(Scoped)),
11131            Scoped
11132        );
11133        assert_eq!(
11134            effective_containment_floor(Some(Scoped), None, Some(Advisory)),
11135            Scoped
11136        );
11137        assert_eq!(
11138            effective_containment_floor(None, Some(Advisory), Some(Scoped)),
11139            Scoped
11140        );
11141        assert_eq!(
11142            effective_containment_floor(None, Some(Scoped), Some(Advisory)),
11143            Scoped
11144        );
11145
11146        // All three present: max wins from every slot, and all-equal is a fixed point.
11147        assert_eq!(
11148            effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
11149            Sealed
11150        );
11151        assert_eq!(
11152            effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
11153            Sealed
11154        );
11155        assert_eq!(
11156            effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
11157            Sealed
11158        );
11159        assert_eq!(
11160            effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
11161            Scoped
11162        );
11163        assert_eq!(
11164            effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
11165            Advisory
11166        );
11167    }
11168
11169    #[test]
11170    fn manifest_parses_each_declared_containment_class() {
11171        for class in ContainmentClass::ALL {
11172            let manifest = RuntimeManifest::from_yaml_str(&format!(
11173                r#"
11174name: cap
11175version: 0.0.1
11176capabilities:
11177  containment: {class}
11178"#
11179            ))
11180            .expect("a valid containment class parses");
11181
11182            assert_eq!(
11183                manifest.capabilities.unwrap().containment,
11184                Some(class),
11185                "capabilities.containment: {class}"
11186            );
11187        }
11188    }
11189
11190    #[test]
11191    fn manifest_without_containment_key_declares_nothing() {
11192        // Absent `capabilities:` block entirely.
11193        let manifest = RuntimeManifest::from_yaml_str(
11194            r#"
11195name: cap
11196version: 0.0.1
11197"#,
11198        )
11199        .unwrap();
11200        assert!(manifest.capabilities.is_none());
11201
11202        // Present `capabilities:` block that simply omits the key: still None, never a
11203        // silently-defaulted Advisory floor stored as if the operator had written it.
11204        let manifest = RuntimeManifest::from_yaml_str(
11205            r#"
11206name: cap
11207version: 0.0.1
11208capabilities:
11209  network:
11210    allow:
11211      - https://api.example.com
11212"#,
11213        )
11214        .unwrap();
11215        assert_eq!(manifest.capabilities.unwrap().containment, None);
11216    }
11217
11218    #[test]
11219    fn manifest_rejects_an_unknown_containment_class() {
11220        let err = RuntimeManifest::from_yaml_str(
11221            r#"
11222name: cap
11223version: 0.0.1
11224capabilities:
11225  containment: paranoid
11226"#,
11227        )
11228        .expect_err("an unknown containment class is rejected at parse time");
11229
11230        match err {
11231            RuntimeManifestError::InvalidCapabilities { field, message } => {
11232                assert_eq!(field, "capabilities.containment");
11233                assert_eq!(
11234                    message,
11235                    "must be one of: advisory, scoped, sealed; got 'paranoid'"
11236                );
11237            }
11238            other => panic!("expected InvalidCapabilities, got {other:?}"),
11239        }
11240    }
11241
11242    // ── exports.files ────────────────────────────────────────────────────────
11243
11244    fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
11245        RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
11246    }
11247
11248    fn export_error(block: &str) -> (String, String) {
11249        match exports_manifest(block).expect_err("this exports block must not parse") {
11250            RuntimeManifestError::InvalidExports { field, message } => (field, message),
11251            other => panic!("expected InvalidExports, got {other:?}"),
11252        }
11253    }
11254
11255    fn peer_fetch_error(block: &str) -> (String, String) {
11256        match exports_manifest(block).expect_err("this capabilities block must not parse") {
11257            RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
11258            other => panic!("expected InvalidCapabilities, got {other:?}"),
11259        }
11260    }
11261
11262    // ── exports.peer_files ───────────────────────────────────────────────────
11263
11264    #[test]
11265    fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
11266        assert_eq!(parse_duration_secs("90").unwrap(), 90);
11267        assert_eq!(parse_duration_secs("30s").unwrap(), 30);
11268        assert_eq!(parse_duration_secs("15m").unwrap(), 900);
11269        assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
11270        assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
11271        assert_eq!(parse_duration_secs("  2h  ").unwrap(), 7200);
11272        assert_eq!(parse_duration_secs("0").unwrap(), 0);
11273    }
11274
11275    /// Every spelling that is not the one accepted form, refused rather than guessed at: a
11276    /// manifest meaning 5 seconds and one meaning 5 minutes must not both parse.
11277    #[test]
11278    fn parse_duration_secs_rejects_every_other_spelling() {
11279        for input in [
11280            "",
11281            "  ",
11282            "5 minutes",
11283            "30S",
11284            "1H",
11285            "5min",
11286            "1.5h",
11287            "-1",
11288            "m",
11289            "h",
11290            "1h30m",
11291            "0x10",
11292            "30 s",
11293        ] {
11294            let error = parse_duration_secs(input)
11295                .expect_err(&format!("'{input}' must not parse as a duration"));
11296            assert!(
11297                error.contains(DURATION_ACCEPTED_FORM),
11298                "'{input}' must be refused with the accepted form; got: {error}"
11299            );
11300        }
11301    }
11302
11303    #[test]
11304    fn parse_duration_secs_refuses_to_overflow() {
11305        assert!(parse_duration_secs("18446744073709551615h")
11306            .unwrap_err()
11307            .contains("overflows"));
11308    }
11309
11310    #[test]
11311    fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
11312        let manifest = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
11313            .expect("a minimal peer_files block parses");
11314        let peer = manifest.exports.unwrap().peer_files.unwrap();
11315        assert_eq!(peer.root, "out/");
11316        assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
11317        assert_eq!(peer.max_bytes, 10_485_760);
11318        // Undeclared, not defaulted: the two ephemerality cases answer an absent value
11319        // differently, and substituting here would erase the difference.
11320        assert_eq!(peer.max_ttl_secs, None);
11321        assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
11322        assert_eq!(peer.effective_max_ttl_secs(), 3600);
11323    }
11324
11325    #[test]
11326    fn peer_files_accepts_every_duration_spelling() {
11327        for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
11328            let manifest = exports_manifest(&format!(
11329                "exports:\n  peer_files:\n    root: out/\n    max_ttl: {declared}\n"
11330            ))
11331            .expect("a declared max_ttl parses");
11332            let peer = manifest.exports.unwrap().peer_files.unwrap();
11333            assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
11334            assert_eq!(peer.effective_max_ttl_secs(), expected);
11335        }
11336    }
11337
11338    /// The two export blocks are separate authorisers: declaring one says nothing about the
11339    /// other, and both may be declared side by side over different subtrees.
11340    #[test]
11341    fn the_two_export_blocks_are_independent() {
11342        let both = exports_manifest(
11343            "exports:\n  files:\n    root: out/\n    mode: read-only\n  \
11344             peer_files:\n    root: out/handoff/\n",
11345        )
11346        .expect("both blocks parse together")
11347        .exports
11348        .unwrap();
11349        assert_eq!(both.files.unwrap().root, "out/");
11350        assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
11351
11352        let only_files =
11353            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
11354                .unwrap()
11355                .exports
11356                .unwrap();
11357        assert!(only_files.peer_files.is_none());
11358
11359        let only_peer = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
11360            .unwrap()
11361            .exports
11362            .unwrap();
11363        assert!(only_peer.files.is_none());
11364
11365        let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
11366        assert!(neither.files.is_none() && neither.peer_files.is_none());
11367    }
11368
11369    #[test]
11370    fn peer_files_root_must_be_relative_and_inside_the_workdir() {
11371        for root in ["../out", "/etc", "out/../../etc"] {
11372            let (field, message) =
11373                export_error(&format!("exports:\n  peer_files:\n    root: {root}\n"));
11374            assert_eq!(field, "exports.peer_files.root", "root: {root}");
11375            assert!(
11376                message.contains(EXPORT_ROOT_ACCEPTED_FORM),
11377                "root: {root}; got: {message}"
11378            );
11379        }
11380    }
11381
11382    #[test]
11383    fn peer_files_root_is_required() {
11384        let (field, message) = export_error("exports:\n  peer_files: {}\n");
11385        assert_eq!(field, "exports.peer_files.root");
11386        assert!(message.contains("is required"));
11387        assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
11388    }
11389
11390    #[test]
11391    fn peer_files_max_ttl_states_its_accepted_form() {
11392        for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
11393            let (field, message) = export_error(&format!(
11394                "exports:\n  peer_files:\n    root: out/\n    max_ttl: \"{declared}\"\n"
11395            ));
11396            assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
11397            assert!(
11398                message.contains(DURATION_ACCEPTED_FORM),
11399                "max_ttl: {declared}; got: {message}"
11400            );
11401        }
11402    }
11403
11404    /// A zero ceiling reads like a declared surface and refuses every mint. Better discovered at
11405    /// the line that wrote it than at the first `share-file` call.
11406    #[test]
11407    fn peer_files_max_ttl_rejects_zero() {
11408        let (field, message) =
11409            export_error("exports:\n  peer_files:\n    root: out/\n    max_ttl: 0\n");
11410        assert_eq!(field, "exports.peer_files.max_ttl");
11411        assert!(message.contains("greater than zero"));
11412    }
11413
11414    #[test]
11415    fn peer_files_max_bytes_states_its_accepted_form() {
11416        let (field, message) =
11417            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 10MB\n");
11418        assert_eq!(field, "exports.peer_files.max_bytes");
11419        assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
11420    }
11421
11422    #[test]
11423    fn peer_files_max_bytes_rejects_zero() {
11424        let (field, message) =
11425            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 0\n");
11426        assert_eq!(field, "exports.peer_files.max_bytes");
11427        assert!(message.contains("greater than zero"));
11428    }
11429
11430    // ── capabilities.peer_fetch ──────────────────────────────────────────────
11431
11432    #[test]
11433    fn peer_fetch_allow_carries_its_declared_destinations() {
11434        let manifest = exports_manifest(
11435            "capabilities:\n  peer_fetch:\n    allow:\n      - localhost:41234\n      \
11436             - reporting.internal\n      - https://gateway.example.com\n",
11437        )
11438        .expect("a peer_fetch block parses");
11439        assert_eq!(
11440            manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
11441            vec![
11442                "localhost:41234".to_string(),
11443                "reporting.internal".to_string(),
11444                "https://gateway.example.com".to_string(),
11445            ]
11446        );
11447    }
11448
11449    /// An empty list is a parse error rather than a silent deny: `allow: []` reads as a declared
11450    /// grant and would otherwise refuse every fetch with no line to point at.
11451    #[test]
11452    fn peer_fetch_allow_must_not_be_empty() {
11453        let (field, message) = peer_fetch_error("capabilities:\n  peer_fetch:\n    allow: []\n");
11454        assert_eq!(field, "capabilities.peer_fetch.allow");
11455        assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
11456    }
11457
11458    #[test]
11459    fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
11460        for entry in [
11461            "http://[not a host",
11462            "\"\"",
11463            "localhost:notaport",
11464            "http://example.com/path",
11465            "ftp://example.com",
11466        ] {
11467            let (field, message) = peer_fetch_error(&format!(
11468                "capabilities:\n  peer_fetch:\n    allow:\n      - {entry}\n"
11469            ));
11470            assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
11471            assert!(
11472                message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
11473                "entry: {entry}; got: {message}"
11474            );
11475        }
11476    }
11477
11478    /// `peer_fetch` is a separate authoriser and never an alias: declaring it leaves
11479    /// `network.allow` exactly as the manifest wrote it, and vice versa.
11480    #[test]
11481    fn peer_fetch_does_not_widen_network_allow() {
11482        let capabilities = exports_manifest(
11483            "capabilities:\n  network:\n    allow:\n      - api.example.com\n  \
11484             peer_fetch:\n    allow:\n      - localhost:41234\n",
11485        )
11486        .unwrap()
11487        .capabilities
11488        .unwrap();
11489        assert_eq!(
11490            capabilities.network.unwrap().allow,
11491            vec!["api.example.com".to_string()]
11492        );
11493        assert_eq!(
11494            capabilities.peer_fetch.unwrap().allow,
11495            vec!["localhost:41234".to_string()]
11496        );
11497    }
11498
11499    #[test]
11500    fn an_absent_peer_fetch_block_is_absent() {
11501        assert!(exports_manifest("capabilities: {}\n")
11502            .unwrap()
11503            .capabilities
11504            .unwrap()
11505            .peer_fetch
11506            .is_none());
11507    }
11508
11509    #[test]
11510    fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
11511        assert_eq!(parse_byte_size("0").unwrap(), 0);
11512        assert_eq!(parse_byte_size("4096").unwrap(), 4096);
11513        assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
11514        assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
11515        assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
11516        assert_eq!(parse_byte_size("  10Mi  ").unwrap(), 10 * 1024 * 1024);
11517    }
11518
11519    /// Decimal suffixes are refused rather than guessed at: a manifest meaning 10 000 000 and one
11520    /// meaning 10 485 760 must not both parse.
11521    #[test]
11522    fn parse_byte_size_rejects_every_other_spelling() {
11523        for input in [
11524            "", "  ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
11525            "1Ki1",
11526        ] {
11527            let error = parse_byte_size(input)
11528                .expect_err(&format!("'{input}' must not parse as a byte size"));
11529            assert!(
11530                error.contains(BYTE_SIZE_ACCEPTED_FORM),
11531                "'{input}' must be refused with the accepted form; got: {error}"
11532            );
11533        }
11534    }
11535
11536    #[test]
11537    fn parse_byte_size_refuses_to_overflow() {
11538        assert!(parse_byte_size("18446744073709551615Gi")
11539            .unwrap_err()
11540            .contains("overflows"));
11541    }
11542
11543    #[test]
11544    fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
11545        let manifest =
11546            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
11547                .expect("a minimal exports block parses");
11548        let files = manifest.exports.unwrap().files.unwrap();
11549        assert_eq!(files.root, "out/");
11550        assert_eq!(files.mode, ExportMode::ReadOnly);
11551        assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
11552        assert_eq!(files.max_bytes, 10_485_760);
11553    }
11554
11555    #[test]
11556    fn exports_files_accepts_both_byte_spellings() {
11557        for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
11558            let manifest = exports_manifest(&format!(
11559                "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: {declared}\n"
11560            ))
11561            .expect("a declared max_bytes parses");
11562            assert_eq!(
11563                manifest.exports.unwrap().files.unwrap().max_bytes,
11564                expected,
11565                "max_bytes: {declared}"
11566            );
11567        }
11568    }
11569
11570    /// An `exports:` block with no `files:` is not a resource plane. It parses, and the plane
11571    /// stays undeclared — which is the deny case, not a defaulted-open one.
11572    #[test]
11573    fn an_exports_block_without_files_declares_no_plane() {
11574        let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
11575        assert!(manifest.exports.unwrap().files.is_none());
11576    }
11577
11578    #[test]
11579    fn an_absent_exports_block_is_absent() {
11580        let manifest = exports_manifest("").expect("a manifest without exports parses");
11581        assert!(manifest.exports.is_none());
11582    }
11583
11584    #[test]
11585    fn exports_files_root_must_be_relative_and_inside_the_workdir() {
11586        for root in ["../out", "/etc", "out/../../etc", "''"] {
11587            let (field, message) = export_error(&format!(
11588                "exports:\n  files:\n    root: {root}\n    mode: read-only\n"
11589            ));
11590            assert_eq!(field, "exports.files.root", "root: {root}");
11591            assert!(
11592                message.contains("must be a relative path inside the workdir"),
11593                "root: {root}; got: {message}"
11594            );
11595        }
11596    }
11597
11598    #[test]
11599    fn exports_files_root_is_required() {
11600        let (field, message) = export_error("exports:\n  files:\n    mode: read-only\n");
11601        assert_eq!(field, "exports.files.root");
11602        assert_eq!(
11603            message,
11604            "is required and must be a relative path inside the workdir"
11605        );
11606    }
11607
11608    #[test]
11609    fn exports_files_mode_is_required_and_read_only() {
11610        let (field, message) = export_error("exports:\n  files:\n    root: out/\n");
11611        assert_eq!(field, "exports.files.mode");
11612        assert_eq!(message, "is required and must be 'read-only'");
11613
11614        let (field, message) =
11615            export_error("exports:\n  files:\n    root: out/\n    mode: read-write\n");
11616        assert_eq!(field, "exports.files.mode");
11617        assert_eq!(message, "'read-write' must be 'read-only'");
11618    }
11619
11620    #[test]
11621    fn exports_files_max_bytes_states_its_accepted_form() {
11622        let (field, message) = export_error(
11623            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 10MB\n",
11624        );
11625        assert_eq!(field, "exports.files.max_bytes");
11626        assert_eq!(
11627            message,
11628            "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
11629        );
11630    }
11631
11632    /// A zero ceiling would refuse every read while reading like a declared export, so it is
11633    /// refused where it is written rather than at the first request.
11634    #[test]
11635    fn exports_files_max_bytes_rejects_zero() {
11636        let (field, message) = export_error(
11637            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 0\n",
11638        );
11639        assert_eq!(field, "exports.files.max_bytes");
11640        assert!(message.contains("must be greater than zero"), "{message}");
11641    }
11642
11643    /// A leading `./` is a relative path like any other and is not an escape.
11644    #[test]
11645    fn exports_files_root_accepts_a_current_dir_prefix() {
11646        let manifest =
11647            exports_manifest("exports:\n  files:\n    root: ./out\n    mode: read-only\n")
11648                .expect("./out is a legal root");
11649        assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
11650    }
11651
11652    // ── Unrecognized manifest keys (W-SEC-019) ────────────────────────────────
11653
11654    fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
11655        RuntimeManifest::from_yaml_str(yaml)
11656            .expect("fixture must parse")
11657            .unknown_keys
11658            .into_iter()
11659            .map(|key| (key.key, key.block_path, key.nearest_known))
11660            .collect()
11661    }
11662
11663    /// The typo the whole warning exists for: a hyphen where the key takes an underscore, named
11664    /// with the block that held it and with the key it should have been.
11665    #[test]
11666    fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
11667        assert_eq!(
11668            unknown_keys_of(
11669                "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    read-only:\n      - tests\n"
11670            ),
11671            vec![(
11672                "read-only".to_string(),
11673                "capabilities.filesystem".to_string(),
11674                Some("read_only".to_string())
11675            )]
11676        );
11677    }
11678
11679    /// A manifest exercising every layer the walk descends through — capsule-wide capabilities,
11680    /// a per-artifact block, shell, network and an artifact entry — reports nothing. A warning
11681    /// that fires on correct input is noise, and noise is ignored.
11682    #[test]
11683    fn a_manifest_of_recognized_keys_reports_nothing() {
11684        assert!(unknown_keys_of(
11685            r#"
11686name: cap
11687version: 0.1.0
11688mur_version: "0.2.0"
11689artifacts:
11690  - name: notes-tool
11691    version: 0.1.0
11692    runtime: tool
11693    capabilities:
11694      filesystem:
11695        scope: notes
11696      network:
11697        allow:
11698          - example.com:443
11699capabilities:
11700  filesystem:
11701    read_only:
11702      - tests
11703      - bench/fixtures
11704    workdir_exec: false
11705  shell:
11706    allow:
11707      - git
11708      - python3
11709  network:
11710    allow:
11711      - example.com:443
11712    unix_sockets: false
11713"#
11714        )
11715        .is_empty());
11716    }
11717
11718    /// A per-artifact capability block reports under its artifact's index, so an operator is not
11719    /// sent to the capsule-wide block for a key they wrote on one entry.
11720    #[test]
11721    fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
11722        assert_eq!(
11723            unknown_keys_of(
11724                "name: cap\nversion: 0.1.0\nartifacts:\n  - name: t\n    version: 0.1.0\n    \
11725                 runtime: tool\n    capabilities:\n      shell:\n        allow:\n          - git\n        \
11726                 allwo:\n          - git\n"
11727            ),
11728            vec![(
11729                "allwo".to_string(),
11730                "artifacts[0].capabilities.shell".to_string(),
11731                Some("allow".to_string())
11732            )]
11733        );
11734    }
11735
11736    /// A top-level key with no near neighbour reports an empty path and no suggestion — the two
11737    /// facts the emitter needs to word it as a possibly-newer key rather than a misspelling.
11738    #[test]
11739    fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
11740        assert_eq!(
11741            unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
11742            vec![("quantum_teleport".to_string(), String::new(), None)]
11743        );
11744    }
11745
11746    /// The minimum publishable delegated sub-capsule — `runtime:` plus `execution:` — reports
11747    /// nothing. Every registrant of a delegating capsule must be published, so every one of them
11748    /// carries these keys; warning on them would make the diagnostic unreadable exactly where it
11749    /// is needed most.
11750    #[test]
11751    fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
11752        assert!(unknown_keys_of(
11753            "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
11754        )
11755        .is_empty());
11756    }
11757
11758    /// All four keys `mur build` and `mur publish` read beyond `name:` and `version:`, together.
11759    #[test]
11760    fn every_build_side_key_is_recognized() {
11761        assert!(unknown_keys_of(
11762            "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
11763             wasm\nrequires_files:\n  - tool.wasm\n  - assets/logo.png\n"
11764        )
11765        .is_empty());
11766    }
11767
11768    /// `W-SEC-019` still covers every key no command reads: one sitting beside the four
11769    /// build-side keys reports, and reports alone.
11770    #[test]
11771    fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
11772        assert_eq!(
11773            unknown_keys_of(
11774                "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
11775                 static\nrequires_files:\n  - capsule.wasm\nquantum_teleport: true\n"
11776            ),
11777            vec![("quantum_teleport".to_string(), String::new(), None)]
11778        );
11779    }
11780
11781    /// The four keys are recognized and then dropped: they add no refusal and no run-time meaning,
11782    /// so a manifest carrying them parses to what it parses to without them. A wrong-shaped value
11783    /// is build's business to reject, not the run path's.
11784    #[test]
11785    fn the_build_side_keys_change_nothing_the_run_path_reads() {
11786        let body = "artifacts:\n  - name: notes-tool\n    version: 0.1.0\n    runtime: tool\n\
11787                    \x20 - name: murmur-driver-anthropic\n    version: 0.1.0\n    runtime: \
11788                    driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n\
11789                    capabilities:\n  filesystem:\n    read_only:\n      - tests\n\
11790                    inference:\n  transport: http\n  model: test-model\n  driver:\n    \
11791                    artifact: murmur-driver-anthropic\n";
11792        let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
11793            .expect("fixture must parse");
11794        let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
11795            "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n  \
11796             any: shape\nrequires_files: not-a-list\n{body}"
11797        ))
11798        .expect("a build-side key of any shape never refuses a manifest");
11799
11800        assert!(with_build_keys.unknown_keys.is_empty());
11801        assert!(
11802            !plain.artifacts.is_empty()
11803                && plain.capabilities.is_some()
11804                && plain.inference.is_some(),
11805            "the fixture must populate the blocks the comparison below rests on, or it \
11806             compares absence against absence"
11807        );
11808        assert_eq!(with_build_keys.name, plain.name);
11809        assert_eq!(with_build_keys.version, plain.version);
11810        assert_eq!(with_build_keys.artifacts, plain.artifacts);
11811        assert_eq!(with_build_keys.capabilities, plain.capabilities);
11812        assert_eq!(with_build_keys.inference, plain.inference);
11813    }
11814
11815    /// The overflow map captures the key without consuming the block: the rest of it still parses
11816    /// and still applies. A capsule with an unrecognized key launches.
11817    #[test]
11818    fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
11819        let manifest = RuntimeManifest::from_yaml_str(
11820            "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    scope: notes\n    \
11821             read-only:\n      - tests\n",
11822        )
11823        .expect("an unrecognized key never refuses a manifest");
11824        assert_eq!(
11825            manifest
11826                .capabilities
11827                .as_ref()
11828                .and_then(|caps| caps.filesystem.as_ref())
11829                .and_then(|fs| fs.scope.as_deref()),
11830            Some("notes")
11831        );
11832        assert_eq!(manifest.unknown_keys.len(), 1);
11833    }
11834
11835    /// Every block on the way down is walked, not just the capability sub-blocks.
11836    #[test]
11837    fn nested_blocks_outside_capabilities_are_walked_too() {
11838        let keys = unknown_keys_of(
11839            "name: cap\nversion: 0.1.0\ncontext:\n  max_tokns: 100\nexports:\n  files:\n    \
11840             root: out\n    mode: read-only\n    max_byts: 10\n",
11841        );
11842        assert_eq!(
11843            keys,
11844            vec![
11845                (
11846                    "max_tokns".to_string(),
11847                    "context".to_string(),
11848                    Some("max_tokens".to_string())
11849                ),
11850                (
11851                    "max_byts".to_string(),
11852                    "exports.files".to_string(),
11853                    Some("max_bytes".to_string())
11854                ),
11855            ]
11856        );
11857    }
11858
11859    /// Source-derived, so a `Raw*` struct added later cannot skip the overflow map, and a field
11860    /// added to one cannot skip its `KNOWN_KEYS`: the suggester would then never propose it.
11861    ///
11862    /// Reads this file's own text rather than a hand-written list for the same reason the
11863    /// `CapabilityPolicy` coverage test in `capsule-runtime` does — a list maintained by hand
11864    /// records what someone remembered, not what the code says.
11865    #[test]
11866    fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
11867        let source = include_str!("runtime_manifest.rs");
11868        let lines: Vec<&str> = source.lines().collect();
11869
11870        let mut checked = 0usize;
11871        for (start, line) in lines.iter().enumerate() {
11872            let Some(name) = line
11873                .strip_prefix("struct Raw")
11874                .and_then(|rest| rest.strip_suffix(" {"))
11875            else {
11876                continue;
11877            };
11878            let name = format!("Raw{name}");
11879            let end = (start + 1..lines.len())
11880                .find(|index| lines[*index] == "}")
11881                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
11882            let body = &lines[start + 1..end];
11883
11884            assert!(
11885                body.contains(&"    #[serde(flatten)]")
11886                    && body.contains(&"    unknown: UnknownKeys,"),
11887                "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
11888                 so a key it does not recognize would be dropped instead of reported"
11889            );
11890
11891            let declared = serde_field_names(body);
11892            let known = known_keys_of(source, &name);
11893            assert_eq!(
11894                declared, known,
11895                "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
11896                 suggester would propose the wrong set for that block"
11897            );
11898            checked += 1;
11899        }
11900        assert!(
11901            checked >= 30,
11902            "expected every Raw* struct to be scanned, found only {checked}"
11903        );
11904    }
11905
11906    /// Source-derived for the same reason its sibling above is: a `Raw*`-typed field added to a
11907    /// block and left unwalked reports none of the keys inside that block, and reports nothing
11908    /// about having done so. The scan is keyed on the *parent's field*, not on the child's type,
11909    /// because `inference.driver` and `inference.provider` are the same type — a check keyed on
11910    /// the type would call `provider` covered because `driver` is walked.
11911    #[test]
11912    fn every_raw_struct_descends_into_the_blocks_it_owns() {
11913        let source = include_str!("runtime_manifest.rs");
11914        let lines: Vec<&str> = source.lines().collect();
11915
11916        let mut structs = 0usize;
11917        let mut children = 0usize;
11918        for (start, line) in lines.iter().enumerate() {
11919            let Some(name) = line
11920                .strip_prefix("struct Raw")
11921                .and_then(|rest| rest.strip_suffix(" {"))
11922            else {
11923                continue;
11924            };
11925            let name = format!("Raw{name}");
11926            let end = (start + 1..lines.len())
11927                .find(|index| lines[*index] == "}")
11928                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
11929            let owned = block_typed_fields(&lines[start + 1..end]);
11930            structs += 1;
11931
11932            if owned.is_empty() {
11933                continue;
11934            }
11935            let block = raw_block_impl_of(source, &name);
11936            assert!(
11937                block.contains("fn walk_children"),
11938                "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
11939                 `walk_children`, so every key written inside {} would go unreported",
11940                if owned.len() == 1 { "" } else { "s" },
11941                owned.join(", "),
11942                if owned.len() == 1 { "it" } else { "them" }
11943            );
11944            for field in &owned {
11945                assert!(
11946                    mentions_field(&block, field),
11947                    "{name}'s `walk_children` never touches `self.{field}`, so every key written \
11948                     inside that block would go unreported — descend into it with \
11949                     `collect_block(.., &child_path(path, \"{field}\"), out)`"
11950                );
11951                children += 1;
11952            }
11953        }
11954        assert!(
11955            structs >= 30,
11956            "expected every Raw* struct to be scanned, found only {structs}"
11957        );
11958        assert!(
11959            children >= 31,
11960            "expected every nested block field to be scanned, found only {children}"
11961        );
11962    }
11963
11964    /// Whether `impl` reads `self.<field>` as that whole field, not as the head of a longer one:
11965    /// a plain substring test lets a struct owning both `foo` and `foo_bar` walk only `foo_bar`
11966    /// and still be called covered, which is the silent pass this scan exists to deny.
11967    fn mentions_field(impl_body: &str, field: &str) -> bool {
11968        let needle = format!("self.{field}");
11969        impl_body.match_indices(&needle).any(|(at, _)| {
11970            impl_body[at + needle.len()..]
11971                .chars()
11972                .next()
11973                .is_none_or(|next| !next.is_alphanumeric() && next != '_')
11974        })
11975    }
11976
11977    /// The fields of one struct body whose declared type names another `Raw*` type, in
11978    /// declaration order. Comment and attribute lines are skipped, so a doc comment that mentions
11979    /// a `Raw*` type by name is not read as a field.
11980    fn block_typed_fields(body: &[&str]) -> Vec<String> {
11981        let mut fields = Vec::new();
11982        for line in body {
11983            let trimmed = line.trim();
11984            if trimmed.starts_with("//") || trimmed.starts_with("#[") {
11985                continue;
11986            }
11987            let Some((field, declared)) = line
11988                .strip_prefix("    ")
11989                .and_then(|rest| rest.split_once(':'))
11990            else {
11991                continue;
11992            };
11993            if field.is_empty()
11994                || !field
11995                    .chars()
11996                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
11997            {
11998                continue;
11999            }
12000            if declared.contains("Raw") {
12001                fields.push(field.to_string());
12002            }
12003        }
12004        fields
12005    }
12006
12007    /// The text of `impl RawBlock for <name>`, cut at the next line that is exactly `}` at column
12008    /// 0 so one impl's body cannot be read as the next one's.
12009    fn raw_block_impl_of(source: &str, name: &str) -> String {
12010        let header = format!("impl RawBlock for {name} {{");
12011        let body = source
12012            .split_once(&header)
12013            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
12014            .1;
12015        body.split_once("\n}\n")
12016            .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
12017            .0
12018            .to_string()
12019    }
12020
12021    /// The walk's own result, taken straight off a deserialized `RawRuntimeManifest`.
12022    ///
12023    /// Deliberately not routed through [`RuntimeManifest::from_yaml_str`]: that validates, and a
12024    /// fixture that names every block at once would have to satisfy every semantic rule as well
12025    /// to reach the walk. What is under test here is which blocks are descended into, which the
12026    /// validator has no part in.
12027    fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
12028        let raw: RawRuntimeManifest = serde_yaml::from_str(yaml).expect("fixture must parse");
12029        collect_unknown_keys(&raw)
12030            .into_iter()
12031            .map(|key| (key.key, key.block_path))
12032            .collect()
12033    }
12034
12035    /// Every block the manifest type owns, each carrying one key no field of it claims, reported
12036    /// once and at its own dotted path. A block added after this one is held to the same rule by
12037    /// `every_raw_struct_descends_into_the_blocks_it_owns`; this is what "descended into" means.
12038    #[test]
12039    fn every_block_reports_its_own_unknown_key_at_its_own_path() {
12040        let keys = raw_unknown_keys(
12041            r#"
12042name: every-block
12043version: 0.1.0
12044probe_root: 1
12045artifacts:
12046  - name: t
12047    version: 0.1.0
12048    runtime: tool
12049    probe_artifact: 1
12050    capabilities:
12051      probe_artifact_capabilities: 1
12052      shell:
12053        probe_artifact_shell: 1
12054capabilities:
12055  probe_capabilities: 1
12056  network:
12057    probe_network: 1
12058  peer_fetch:
12059    probe_peer_fetch: 1
12060  filesystem:
12061    probe_filesystem: 1
12062  shell:
12063    probe_shell: 1
12064    interpreter_runtime:
12065      - binary: python3
12066        probe_interpreter_runtime: 1
12067        dirs:
12068          - path: /usr/lib/python3
12069            probe_interpreter_runtime_dir: 1
12070    staged_runtime:
12071      - binary: rg
12072        probe_staged_runtime: 1
12073  spawn:
12074    probe_spawn: 1
12075  env:
12076    probe_env: 1
12077  limits:
12078    probe_limits: 1
12079  resources:
12080    probe_resources: 1
12081  state:
12082    probe_state: 1
12083  task_io:
12084    probe_task_io: 1
12085  conversation:
12086    probe_conversation: 1
12087  install:
12088    probe_install: 1
12089inference:
12090  probe_inference: 1
12091  driver:
12092    probe_driver: 1
12093  provider:
12094    probe_provider: 1
12095  compaction:
12096    probe_compaction: 1
12097context:
12098  probe_context: 1
12099observability:
12100  probe_observability: 1
12101  eval:
12102    probe_eval: 1
12103    scorers:
12104      - type: exact_match
12105        probe_scorer: 1
12106trace:
12107  probe_trace: 1
12108network:
12109  probe_network_config: 1
12110lifecycle:
12111  probe_lifecycle: 1
12112exports:
12113  probe_exports: 1
12114  files:
12115    probe_files: 1
12116  peer_files:
12117    probe_peer_files: 1
12118control:
12119  probe_control: 1
12120"#,
12121        );
12122
12123        let expected: Vec<(String, String)> = [
12124            ("probe_root", ""),
12125            ("probe_artifact", "artifacts[0]"),
12126            ("probe_artifact_capabilities", "artifacts[0].capabilities"),
12127            ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
12128            ("probe_capabilities", "capabilities"),
12129            ("probe_network", "capabilities.network"),
12130            ("probe_peer_fetch", "capabilities.peer_fetch"),
12131            ("probe_filesystem", "capabilities.filesystem"),
12132            ("probe_shell", "capabilities.shell"),
12133            (
12134                "probe_interpreter_runtime",
12135                "capabilities.shell.interpreter_runtime[0]",
12136            ),
12137            (
12138                "probe_interpreter_runtime_dir",
12139                "capabilities.shell.interpreter_runtime[0].dirs[0]",
12140            ),
12141            (
12142                "probe_staged_runtime",
12143                "capabilities.shell.staged_runtime[0]",
12144            ),
12145            ("probe_spawn", "capabilities.spawn"),
12146            ("probe_env", "capabilities.env"),
12147            ("probe_limits", "capabilities.limits"),
12148            ("probe_resources", "capabilities.resources"),
12149            ("probe_state", "capabilities.state"),
12150            ("probe_task_io", "capabilities.task_io"),
12151            ("probe_conversation", "capabilities.conversation"),
12152            ("probe_install", "capabilities.install"),
12153            ("probe_inference", "inference"),
12154            ("probe_driver", "inference.driver"),
12155            ("probe_provider", "inference.provider"),
12156            ("probe_compaction", "inference.compaction"),
12157            ("probe_context", "context"),
12158            ("probe_observability", "observability"),
12159            ("probe_eval", "observability.eval"),
12160            ("probe_scorer", "observability.eval.scorers[0]"),
12161            ("probe_trace", "trace"),
12162            ("probe_network_config", "network"),
12163            ("probe_lifecycle", "lifecycle"),
12164            ("probe_exports", "exports"),
12165            ("probe_files", "exports.files"),
12166            ("probe_peer_files", "exports.peer_files"),
12167            ("probe_control", "control"),
12168        ]
12169        .into_iter()
12170        .map(|(key, path)| (key.to_string(), path.to_string()))
12171        .collect();
12172
12173        assert_eq!(keys, expected);
12174    }
12175
12176    /// The names serde matches on for one struct body: field names in declaration order, with a
12177    /// `#[serde(rename = "...")]` field under its renamed spelling, and the overflow map itself
12178    /// excluded because it claims no name of its own.
12179    fn serde_field_names(body: &[&str]) -> Vec<String> {
12180        let mut names = Vec::new();
12181        let mut rename: Option<String> = None;
12182        for line in body {
12183            let trimmed = line.trim();
12184            if trimmed.starts_with("#[") {
12185                if let Some(rest) = trimmed.split("rename = \"").nth(1) {
12186                    if !trimmed.contains("rename_all") {
12187                        rename = rest.split('"').next().map(str::to_string);
12188                    }
12189                }
12190                continue;
12191            }
12192            if trimmed.starts_with("//") {
12193                continue;
12194            }
12195            let Some(field) = line
12196                .strip_prefix("    ")
12197                .and_then(|rest| rest.split(':').next())
12198            else {
12199                continue;
12200            };
12201            if field.is_empty()
12202                || !field
12203                    .chars()
12204                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
12205            {
12206                continue;
12207            }
12208            if field == "unknown" {
12209                rename = None;
12210                continue;
12211            }
12212            names.push(rename.take().unwrap_or_else(|| field.to_string()));
12213        }
12214        names
12215    }
12216
12217    /// The `KNOWN_KEYS` array `impl RawBlock for <name>` declares, read out of the source text so
12218    /// the assertion compares two independent readings of the same file rather than one value
12219    /// against itself.
12220    fn known_keys_of(source: &str, name: &str) -> Vec<String> {
12221        let header = format!("impl RawBlock for {name} {{");
12222        let body = source
12223            .split_once(&header)
12224            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
12225            .1;
12226        let array = body
12227            .split_once("KNOWN_KEYS: &'static [&'static str] = &[")
12228            .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
12229            .1
12230            .split_once("];")
12231            .expect("KNOWN_KEYS array is unterminated")
12232            .0;
12233        array
12234            .split('"')
12235            .skip(1)
12236            .step_by(2)
12237            .map(str::to_string)
12238            .collect()
12239    }
12240}
12241
12242#[cfg(test)]
12243mod control_tests {
12244    use super::*;
12245
12246    const TOOL_ENTRY: &str = "  - name: card-api\n    version: 0.1.0\n    runtime: tool\n    gateway:\n      endpoint: https://cards.example.com\n      api_key: ${CARD_TOKEN}\n";
12247
12248    /// An `http` agent capsule whose driver is keyed by `${DRIVER_KEY}` and whose `card-api` tool
12249    /// is keyed by `${CARD_TOKEN}`, with `control` spliced in at the top level.
12250    fn agent(transport: &str, control: &str) -> String {
12251        let driver = if transport == "http" {
12252            "  - name: drv\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.example.com\n      api_key: ${DRIVER_KEY}\n"
12253        } else {
12254            "  - name: drv\n    version: 0.1.0\n    runtime: driver\n"
12255        };
12256        format!(
12257            "name: cap\nversion: 0.0.1\nartifacts:\n{driver}{TOOL_ENTRY}inference:\n  transport: {transport}\n  model: m\n  driver:\n    artifact: drv\n{control}"
12258        )
12259    }
12260
12261    fn refusal(yaml: &str) -> (String, String) {
12262        match RuntimeManifest::from_yaml_str(yaml) {
12263            Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
12264            other => panic!("expected InvalidControl, got {other:?}"),
12265        }
12266    }
12267
12268    #[test]
12269    fn control_absent_empty_and_empty_lists_load_as_none() {
12270        for control in [
12271            "",
12272            "control: {}\n",
12273            "control:\n",
12274            "control:\n  settings: []\n  secrets: []\n",
12275        ] {
12276            let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
12277                .unwrap_or_else(|err| panic!("{control:?}: {err}"));
12278            assert_eq!(manifest.control, None, "{control:?}");
12279        }
12280    }
12281
12282    #[test]
12283    fn control_parses_settings_and_secrets_in_manifest_order() {
12284        let yaml = agent(
12285            "http",
12286            "control:\n  settings: [inference.max_tokens]\n  secrets: [CARD_TOKEN]\n",
12287        );
12288        for manifest in [
12289            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
12290            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
12291        ] {
12292            let control = manifest.control.unwrap();
12293            assert_eq!(
12294                control.settings,
12295                vec![ControllableSetting::InferenceMaxTokens]
12296            );
12297            assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
12298            assert!(control.declares_secret("CARD_TOKEN"));
12299            assert!(!control.declares_secret("DRIVER_KEY"));
12300        }
12301    }
12302
12303    #[test]
12304    fn control_setting_wire_names_round_trip() {
12305        for setting in ControllableSetting::ALL {
12306            assert_eq!(
12307                ControllableSetting::from_wire_name(setting.wire_name()),
12308                Some(*setting)
12309            );
12310        }
12311        assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
12312    }
12313
12314    /// Every refusal names `control.settings` or `control.secrets` as its field and quotes the
12315    /// entry it refused.
12316    #[test]
12317    fn control_manifest_refusals_name_the_field() {
12318        let cases: &[(&str, &str, &str, &str)] = &[
12319            (
12320                "http",
12321                "control:\n  settings: [inference.model]\n",
12322                "control.settings",
12323                "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
12324            ),
12325            (
12326                "process",
12327                "control:\n  settings: [inference.max_tokens]\n",
12328                "control.settings",
12329                "'inference.max_tokens' is controllable only under inference.transport: http",
12330            ),
12331            (
12332                "http",
12333                "control:\n  settings: [inference.max_tokens, inference.max_tokens]\n",
12334                "control.settings",
12335                "'inference.max_tokens' is listed more than once",
12336            ),
12337            (
12338                "http",
12339                "control:\n  secrets: [CARD_TOKEN, CARD_TOKEN]\n",
12340                "control.secrets",
12341                "'CARD_TOKEN' is listed more than once",
12342            ),
12343            (
12344                "http",
12345                "control:\n  secrets: [card-token]\n",
12346                "control.secrets",
12347                "'card-token' is not a credential name",
12348            ),
12349            (
12350                "http",
12351                "control:\n  secrets: [OTHER_TOKEN]\n",
12352                "control.secrets",
12353                "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
12354            ),
12355            (
12356                "http",
12357                "control:\n  secrets: [DRIVER_KEY]\n",
12358                "control.secrets",
12359                "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.drv.gateway.api_key)",
12360            ),
12361            (
12362                "http",
12363                "control:\n  settings: inference.max_tokens\n",
12364                "control.settings",
12365                "must be a list of names",
12366            ),
12367            (
12368                "http",
12369                "control:\n  secrets: [1]\n",
12370                "control.secrets",
12371                "must be a list of names",
12372            ),
12373        ];
12374        for (transport, control, field, message) in cases {
12375            let (got_field, got_message) = refusal(&agent(transport, control));
12376            assert_eq!(&got_field, field, "{control:?}");
12377            assert!(
12378                got_message.starts_with(message),
12379                "{control:?}: {got_message}"
12380            );
12381            let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
12382                .unwrap_err()
12383                .to_string();
12384            assert!(
12385                rendered.contains(&format!("invalid control config for '{field}'")),
12386                "{rendered}"
12387            );
12388        }
12389    }
12390
12391    #[test]
12392    fn control_on_a_capsule_with_no_inference_is_refused() {
12393        let yaml = format!(
12394            "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n  secrets: [CARD_TOKEN]\n"
12395        );
12396        let (field, message) = refusal(&yaml);
12397        assert_eq!(field, "control");
12398        assert!(message.contains("no inference: block"), "{message}");
12399    }
12400
12401    #[test]
12402    fn control_unknown_key_is_reported_once_at_its_block() {
12403        let manifest = RuntimeManifest::from_yaml_str(&agent(
12404            "http",
12405            "control:\n  settings: [inference.max_tokens]\n  agent_may_call: true\n",
12406        ))
12407        .unwrap();
12408        let keys: Vec<(&str, &str)> = manifest
12409            .unknown_keys
12410            .iter()
12411            .map(|key| (key.key.as_str(), key.block_path.as_str()))
12412            .collect();
12413        assert_eq!(keys, vec![("agent_may_call", "control")]);
12414        assert_eq!(
12415            manifest.control.unwrap().settings,
12416            vec![ControllableSetting::InferenceMaxTokens]
12417        );
12418    }
12419}
12420
12421#[cfg(test)]
12422mod driver_choice_tests {
12423    use super::*;
12424
12425    const DRV: &str = "  - name: drv\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.example.com\n      api_key: ${DRIVER_KEY}\n";
12426    const ALT: &str = "  - name: alt\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://alt.example.com\n      api_key: ${ALT_KEY}\n";
12427    const ALT_NO_GATEWAY: &str = "  - name: alt\n    version: 0.1.0\n    runtime: driver\n";
12428    const TOOL: &str = "  - name: card-api\n    version: 0.1.0\n    runtime: tool\n";
12429    const GPT: &str = "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: alt\n";
12430    const HAIKU: &str =
12431        "    - name: haiku\n      model: haiku\n      driver:\n        artifact: drv\n";
12432    const SWITCHABLE: &str = "control:\n  settings: [inference.driver]\n";
12433
12434    /// An `http` capsule on `drv` with model `m`, `artifacts` after `drv`, `alternates` as the
12435    /// `inference.alternates` list body (empty for no key), and `control` at the top level.
12436    fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
12437        let alternates = if alternates.is_empty() {
12438            String::new()
12439        } else {
12440            format!("  alternates:\n{alternates}")
12441        };
12442        format!(
12443            "name: cap\nversion: 0.0.1\nartifacts:\n{DRV}{artifacts}inference:\n  transport: http\n  model: m\n  driver:\n    artifact: drv\n{alternates}{control}"
12444        )
12445    }
12446
12447    fn refusal(yaml: &str) -> (String, String) {
12448        match RuntimeManifest::from_yaml_str(yaml) {
12449            Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
12450            | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
12451            other => panic!("expected an inference or control refusal, got {other:?}"),
12452        }
12453    }
12454
12455    #[test]
12456    fn driver_choice_alternates_parse_in_manifest_order() {
12457        let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
12458        for manifest in [
12459            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
12460            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
12461        ] {
12462            let inference = manifest.inference.unwrap();
12463            assert_eq!(
12464                inference.alternates,
12465                vec![
12466                    InferenceAlternate {
12467                        name: "gpt".to_string(),
12468                        model: "gpt-5".to_string(),
12469                        driver: "alt".to_string(),
12470                    },
12471                    InferenceAlternate {
12472                        name: "haiku".to_string(),
12473                        model: "haiku".to_string(),
12474                        driver: "drv".to_string(),
12475                    },
12476                ]
12477            );
12478            let control = manifest.control.unwrap();
12479            assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
12480            assert!(control.agent_settings.is_empty());
12481        }
12482    }
12483
12484    #[test]
12485    fn driver_choice_absent_alternates_are_empty() {
12486        let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
12487        assert!(manifest.inference.unwrap().alternates.is_empty());
12488    }
12489
12490    #[test]
12491    fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
12492        let manifest = RuntimeManifest::from_yaml_str(&capsule(
12493            ALT,
12494            GPT,
12495            "control:\n  agent_settings: [inference.driver]\n",
12496        ))
12497        .unwrap();
12498        let control = manifest.control.unwrap();
12499        assert!(control.settings.is_empty());
12500        assert_eq!(
12501            control.agent_settings,
12502            vec![ControllableSetting::InferenceDriver]
12503        );
12504        assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
12505        assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
12506        assert!(control.secrets.is_empty());
12507    }
12508
12509    #[test]
12510    fn driver_choice_wire_name_round_trips() {
12511        assert_eq!(
12512            ControllableSetting::from_wire_name("inference.driver"),
12513            Some(ControllableSetting::InferenceDriver)
12514        );
12515        assert_eq!(
12516            ControllableSetting::InferenceDriver.wire_name(),
12517            "inference.driver"
12518        );
12519        assert_eq!(
12520            ControllableSetting::AGENT_SETTABLE,
12521            &[ControllableSetting::InferenceDriver]
12522        );
12523    }
12524
12525    /// An alternate's driver may carry `gateway:`, which a driver entry nothing names may not.
12526    #[test]
12527    fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
12528        RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
12529            .expect("an alternate's driver carries its own gateway");
12530        match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
12531            Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
12532                assert!(message.contains("inference.alternates"), "{message}")
12533            }
12534            other => panic!("expected InvalidArtifact, got {other:?}"),
12535        }
12536    }
12537
12538    /// Two models of one provider: the alternate shares the primary's driver and gateway.
12539    #[test]
12540    fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
12541        let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
12542        assert_eq!(manifest.inference.unwrap().alternates[0].driver, "drv");
12543    }
12544
12545    #[test]
12546    fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
12547        let manifest = RuntimeManifest::from_yaml_str(&capsule(
12548            ALT,
12549            GPT,
12550            "control:\n  settings: [inference.driver]\n  secrets: [ALT_KEY]\n",
12551        ))
12552        .unwrap();
12553        assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
12554        let (field, message) = refusal(&capsule(
12555            ALT,
12556            GPT,
12557            "control:\n  settings: [inference.driver]\n  secrets: [DRIVER_KEY]\n",
12558        ));
12559        assert_eq!(field, "control.secrets");
12560        assert!(
12561            message.contains("keys the configured inference driver's gateway"),
12562            "{message}"
12563        );
12564    }
12565
12566    #[test]
12567    fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
12568        let alternate = "    - name: gpt\n      model: gpt-5\n      temperature: 1\n      driver:\n        artifact: alt\n";
12569        let manifest =
12570            RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
12571        let keys: Vec<(&str, &str)> = manifest
12572            .unknown_keys
12573            .iter()
12574            .map(|key| (key.key.as_str(), key.block_path.as_str()))
12575            .collect();
12576        assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
12577    }
12578
12579    /// Every refusal the alternates and the driver setting can draw, each naming its field and
12580    /// rendering as the manifest error `E-MAN-003` maps from.
12581    #[test]
12582    fn driver_choice_manifest_refusals_name_the_field() {
12583        let alternate = |name: &str, model: &str, driver: &str| {
12584            format!("    - name: {name}\n      model: {model}\n      driver:\n        artifact: {driver}\n")
12585        };
12586        let process = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: drv\n    version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  driver:\n    artifact: drv\n";
12587        let cases: Vec<(String, &str, &str)> = vec![
12588            (
12589                format!("{process}  alternates:\n{GPT}"),
12590                "inference.alternates",
12591                "is not valid with transport: process",
12592            ),
12593            (
12594                format!("{process}control:\n  settings: [inference.driver]\n"),
12595                "control.settings",
12596                "'inference.driver' is controllable only under inference.transport: http",
12597            ),
12598            (
12599                capsule(ALT, &alternate("primary", "gpt-5", "alt"), SWITCHABLE),
12600                "inference.alternates[0].name",
12601                "'primary' is reserved",
12602            ),
12603            (
12604                capsule(
12605                    ALT,
12606                    &format!("{}{}", alternate("gpt", "gpt-5", "alt"), alternate("gpt", "gpt-4", "alt")),
12607                    SWITCHABLE,
12608                ),
12609                "inference.alternates[1].name",
12610                "'gpt' names more than one alternate",
12611            ),
12612            (
12613                capsule(ALT, &alternate("GPT", "gpt-5", "alt"), SWITCHABLE),
12614                "inference.alternates[0].name",
12615                "'GPT' is not a driver choice name",
12616            ),
12617            (
12618                capsule(ALT, &alternate("5gpt", "gpt-5", "alt"), SWITCHABLE),
12619                "inference.alternates[0].name",
12620                "'5gpt' is not a driver choice name",
12621            ),
12622            (
12623                capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "alt"), SWITCHABLE),
12624                "inference.alternates[0].name",
12625                "is not a driver choice name",
12626            ),
12627            (
12628                capsule(ALT, "    - name: gpt\n      model: \"\"\n      driver:\n        artifact: alt\n", SWITCHABLE),
12629                "inference.alternates[0].model",
12630                "missing required field",
12631            ),
12632            (
12633                capsule(ALT, "    - name: gpt\n      model: gpt-5\n", SWITCHABLE),
12634                "inference.alternates[0].driver.artifact",
12635                "missing required field",
12636            ),
12637            (
12638                capsule("", &alternate("gpt", "gpt-5", "nowhere"), SWITCHABLE),
12639                "inference.alternates[0].driver.artifact",
12640                "artifact 'nowhere' is not declared in artifacts:",
12641            ),
12642            (
12643                capsule(TOOL, &alternate("gpt", "gpt-5", "card-api"), SWITCHABLE),
12644                "inference.alternates[0].driver.artifact",
12645                "artifact 'card-api' is declared with runtime: tool",
12646            ),
12647            (
12648                capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "alt"), SWITCHABLE),
12649                "inference.alternates[0].driver.artifact",
12650                "artifact 'alt' declares no gateway.endpoint",
12651            ),
12652            (
12653                capsule(
12654                    ALT,
12655                    "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: alt\n        config:\n          thinking: enabled\n",
12656                    SWITCHABLE,
12657                ),
12658                "inference.alternates[0].driver.config",
12659                "is not valid on an alternate; inference.driver.config applies to every declared driver, and per-driver settings belong in that driver's artifacts[].config",
12660            ),
12661            (
12662                capsule("", &alternate("same", "m", "drv"), SWITCHABLE),
12663                "inference.alternates[0]",
12664                "'same' is driver 'drv' with model 'm', which is the primary choice",
12665            ),
12666            (
12667                capsule(
12668                    ALT,
12669                    &format!("{}{}", alternate("gpt", "gpt-5", "alt"), alternate("gpt-too", "gpt-5", "alt")),
12670                    SWITCHABLE,
12671                ),
12672                "inference.alternates[1]",
12673                "'gpt-too' is driver 'alt' with model 'gpt-5', which alternate 'gpt' already is",
12674            ),
12675            (
12676                capsule(ALT, GPT, ""),
12677                "inference.alternates",
12678                "declares alternates that nothing could ever select",
12679            ),
12680            (
12681                capsule(ALT, GPT, "control:\n  settings: [inference.max_tokens]\n"),
12682                "inference.alternates",
12683                "declares alternates that nothing could ever select",
12684            ),
12685            (
12686                capsule("", "", SWITCHABLE),
12687                "control.settings",
12688                "'inference.driver' selects among inference.alternates, and this capsule declares none",
12689            ),
12690            (
12691                capsule("", "", "control:\n  agent_settings: [inference.driver]\n"),
12692                "control.agent_settings",
12693                "'inference.driver' selects among inference.alternates, and this capsule declares none",
12694            ),
12695            (
12696                capsule(ALT, GPT, "control:\n  agent_settings: [inference.max_tokens]\n"),
12697                "control.agent_settings",
12698                "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
12699            ),
12700            (
12701                capsule(
12702                    ALT,
12703                    GPT,
12704                    "control:\n  agent_settings: [inference.driver, inference.driver]\n",
12705                ),
12706                "control.agent_settings",
12707                "'inference.driver' is listed more than once",
12708            ),
12709            (
12710                capsule(ALT, GPT, "control:\n  agent_settings: inference.driver\n"),
12711                "control.agent_settings",
12712                "must be a list of names",
12713            ),
12714        ];
12715        for (yaml, field, message) in &cases {
12716            let (got_field, got_message) = refusal(yaml);
12717            assert_eq!(&got_field, field, "{yaml}");
12718            assert!(got_message.contains(message), "{yaml}\n{got_message}");
12719            let rendered = RuntimeManifest::from_yaml_str(yaml)
12720                .unwrap_err()
12721                .to_string();
12722            assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
12723        }
12724    }
12725}
12726
12727#[cfg(test)]
12728mod network_authentication_tests {
12729    use super::*;
12730
12731    fn network_authentication_of(
12732        block: &str,
12733    ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
12734        RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
12735            .map(|manifest| manifest.network.and_then(|network| network.authentication))
12736    }
12737
12738    fn network_authentication_refusal(block: &str) -> (String, String) {
12739        match network_authentication_of(block) {
12740            Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
12741            other => panic!("expected InvalidNetworkConfig, got {other:?}"),
12742        }
12743    }
12744
12745    #[test]
12746    fn network_authentication_with_credentials_parses_sorted_by_name() {
12747        let auth = network_authentication_of(
12748            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12749             watcher:\n        scopes: [tasks/get, stream/watch]\n      \
12750             reader:\n        scopes: [resources/files]\n",
12751        )
12752        .unwrap()
12753        .expect("declared");
12754        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
12755        assert_eq!(
12756            auth.credentials,
12757            vec![
12758                DoorCredential {
12759                    name: "reader".to_string(),
12760                    scopes: vec!["resources/files".to_string()],
12761                },
12762                DoorCredential {
12763                    name: "watcher".to_string(),
12764                    // Declared order, not sorted.
12765                    scopes: vec!["tasks/get".to_string(), "stream/watch".to_string()],
12766                },
12767            ]
12768        );
12769    }
12770
12771    #[test]
12772    fn network_authentication_scheme_alone_parses() {
12773        let auth = network_authentication_of("network:\n  authentication:\n    scheme: bearer\n")
12774            .unwrap()
12775            .expect("declared");
12776        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
12777        assert!(auth.credentials.is_empty());
12778    }
12779
12780    #[test]
12781    fn network_authentication_absent_is_a_public_door() {
12782        assert_eq!(network_authentication_of("").unwrap(), None);
12783        assert_eq!(
12784            network_authentication_of("network:\n  internal_port: 8080\n").unwrap(),
12785            None
12786        );
12787        let manifest = RuntimeManifest::from_yaml_str(
12788            "name: cap\nversion: 0.1.0\nnetwork:\n  internal_port: 8080\n",
12789        )
12790        .unwrap();
12791        assert_eq!(
12792            manifest.network,
12793            Some(NetworkConfig {
12794                internal_port: Some(8080),
12795                authentication: None,
12796            })
12797        );
12798    }
12799
12800    #[test]
12801    fn network_authentication_without_a_scheme_is_refused() {
12802        let (field, message) = network_authentication_refusal(
12803            "network:\n  authentication:\n    credentials:\n      w:\n        scopes: [tasks/get]\n",
12804        );
12805        assert_eq!(field, "network.authentication.scheme");
12806        assert!(message.contains("required"), "{message}");
12807    }
12808
12809    #[test]
12810    fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
12811        for scheme in ["mtls", "Bearer", "basic"] {
12812            let (field, message) = network_authentication_refusal(&format!(
12813                "network:\n  authentication:\n    scheme: {scheme}\n"
12814            ));
12815            assert_eq!(field, "network.authentication.scheme");
12816            assert!(message.contains("bearer"), "{message}");
12817            assert!(message.contains(scheme), "{message}");
12818        }
12819    }
12820
12821    #[test]
12822    fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
12823        let long = format!("a{}", "b".repeat(32));
12824        for name in [
12825            "operator",
12826            "Watcher",
12827            "1st",
12828            "_x",
12829            "-x",
12830            "wat.cher",
12831            long.as_str(),
12832        ] {
12833            let (field, message) = network_authentication_refusal(&format!(
12834                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12835                 '{name}':\n        scopes: [tasks/get]\n"
12836            ));
12837            assert_eq!(field, "network.authentication.credentials", "{name}");
12838            assert!(message.contains(name), "{message}");
12839        }
12840        let longest = format!("a{}", "b".repeat(31));
12841        for name in ["w", "a-b_c9", longest.as_str()] {
12842            network_authentication_of(&format!(
12843                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12844                 {name}:\n        scopes: [tasks/get]\n"
12845            ))
12846            .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
12847        }
12848    }
12849
12850    #[test]
12851    fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
12852        let credential = |body: &str| {
12853            format!(
12854                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12855                 watcher:\n{body}"
12856            )
12857        };
12858        let field = "network.authentication.credentials.watcher.scopes";
12859
12860        let (got, message) = network_authentication_refusal(&credential("        {}\n"));
12861        assert_eq!(got, field);
12862        assert!(message.contains("required"), "{message}");
12863
12864        let (got, message) = network_authentication_refusal(&credential("        scopes: []\n"));
12865        assert_eq!(got, field);
12866        assert!(message.contains("no scope"), "{message}");
12867
12868        let (got, message) =
12869            network_authentication_refusal(&credential("        scopes: [tasks/list]\n"));
12870        assert_eq!(got, field);
12871        assert!(message.contains("tasks/list"), "{message}");
12872        assert!(message.contains("resources/files"), "{message}");
12873
12874        let (got, message) = network_authentication_refusal(&credential(
12875            "        scopes: [agent/getAuthenticatedExtendedCard]\n",
12876        ));
12877        assert_eq!(got, field);
12878        assert!(
12879            message.contains("agent/getAuthenticatedExtendedCard"),
12880            "{message}"
12881        );
12882
12883        let (got, message) = network_authentication_refusal(&credential(
12884            "        scopes: [tasks/get, stream/watch, tasks/get]\n",
12885        ));
12886        assert_eq!(got, field);
12887        assert!(message.contains("more than once"), "{message}");
12888    }
12889
12890    #[test]
12891    fn network_authentication_accepts_every_door_scope() {
12892        let auth = network_authentication_of(&format!(
12893            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12894             everything:\n        scopes: [{}]\n",
12895            DOOR_SCOPES.join(", ")
12896        ))
12897        .unwrap()
12898        .unwrap();
12899        assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
12900        assert_eq!(OPERATOR_CREDENTIAL, "operator");
12901    }
12902
12903    #[test]
12904    fn network_authentication_is_refused_beside_spawn_allow() {
12905        let (field, message) = network_authentication_refusal(
12906            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
12907             allow: [worker]\n",
12908        );
12909        assert_eq!(field, "network.authentication");
12910        assert!(message.contains("capabilities.spawn.allow"), "{message}");
12911        assert!(message.contains("token"), "{message}");
12912        let error = RuntimeManifest::from_yaml_str(
12913            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n\
12914             capabilities:\n  spawn:\n    allow: [worker]\n",
12915        )
12916        .unwrap_err()
12917        .to_string();
12918        assert!(error.contains("network.authentication"), "{error}");
12919        assert!(error.contains("capabilities.spawn.allow"), "{error}");
12920
12921        // An empty allow-list delegates nothing, so it does not conflict.
12922        network_authentication_of(
12923            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
12924             allow: []\n",
12925        )
12926        .unwrap();
12927    }
12928
12929    #[test]
12930    fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
12931        let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
12932            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n    \
12933             schemes: x\n    credentials:\n      watcher:\n        scopes: [tasks/get]\n        \
12934             scope: [tasks/get]\n",
12935        )
12936        .unwrap()
12937        .unknown_keys
12938        .into_iter()
12939        .map(|key| (key.key, key.block_path, key.nearest_known))
12940        .collect();
12941        assert_eq!(
12942            keys,
12943            vec![
12944                (
12945                    "schemes".to_string(),
12946                    "network.authentication".to_string(),
12947                    Some("scheme".to_string())
12948                ),
12949                (
12950                    "scope".to_string(),
12951                    "network.authentication.credentials.watcher".to_string(),
12952                    Some("scopes".to_string())
12953                ),
12954            ]
12955        );
12956    }
12957}