Skip to main content

capsule_runtime/
runtime.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    path::{Path, PathBuf},
5    sync::{
6        atomic::{AtomicBool, AtomicU64, Ordering},
7        Arc, Mutex,
8    },
9    time::Instant,
10};
11
12use murmur_artifact::{
13    current_platform, is_credential_shaped_env_name, native_binary_verdict,
14    parse_hook_config_from_yaml, parse_tool_implementation_from_yaml, read_lockfile,
15    security_warning_link, verify_sha256, write_lockfile_atomic, AfterTask, ApiKeyReference,
16    ArtifactImplementation, ArtifactRuntime, ContextConfig, ConversationMode, HookBinding,
17    InferenceConfig, InterpreterRuntimeGrant, LifecycleConfig, LockedSha256, LockfileError,
18    MurmurLock, NativeBinaryVerdict, Registry, RegistryError, RuntimeType, TaskAcceptance,
19    LOCK_VERSION, MANIFEST_FILENAME, PACKED_MANIFEST_ENTRY, W_SEC_003, W_SEC_006, W_SEC_007,
20    W_SEC_008, W_SEC_009, W_SEC_011, W_SEC_013, W_SEC_014, W_SEC_015, W_SEC_016, W_SEC_017,
21    W_SEC_018, W_SEC_020, W_SEC_022, W_SEC_023, W_SEC_024, W_SEC_025, W_SEC_026, W_SEC_027,
22};
23use serde_yaml::Value;
24use wasmtime::{
25    component::{Component, HasSelf, Linker, ResourceTable},
26    Config, Engine, Store,
27};
28use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
29use wasmtime_wasi_http::{
30    p2::{
31        bindings::http::types::ErrorCode as WasiHttpErrorCode,
32        body::HyperOutgoingBody,
33        types::{HostFutureIncomingResponse, OutgoingRequestConfig},
34        HttpResult, WasiHttpCtxView, WasiHttpHooks, WasiHttpView,
35    },
36    WasiHttpCtx,
37};
38
39use crate::{
40    a2a::{IncomingTask, TaskRegistry, TaskState},
41    agent::{self, AgentLoopExit},
42    artifact::{extract_manifest_yaml, extract_native_binary, extract_root_wasm, extract_skill_md},
43    artifact_config::ARTIFACT_CONFIG_ENV,
44    bindings::host::murmur::{
45        self, artifact_manager::manage, message::send, tool_registry::invoke,
46    },
47    cgroup,
48    containment::{achieved_containment_class, check_containment_floor},
49    delegation::SpawnerHandle,
50    detached::{
51        self, demotion_tool_result, AbandonedDisposition, AbandonedWork, DetachPolicy,
52        DetachedRegistry, DetachedReport,
53    },
54    errors::RuntimeError,
55    hooks::{
56        dispatch_stage, HookEnvVars, HookEvent, HookRuntime, HookSeed, ResolvedCall,
57        SessionContextData, ShellDispatchInfo, TaskReopen,
58    },
59    identity::{self, CapsuleIdentity},
60    inference_credential::{config_holds_credential, InferenceCredential},
61    inference_gateway::InferenceGateway,
62    inference_import::HookInferenceCtx,
63    lanes::LaneQueue,
64    limits::{classify_guest_failure, EpochTicker, ExecutionLimiter, GuestFailure},
65    murmur_md,
66    network_policy::{
67        effective_tool_network_rules, parse_network_allow_rules, resolve_scoped_dir,
68        validate_filesystem_scope, HookCapabilityGrant, NetworkAllowRule, RequestTarget,
69        ToolCapabilityGrant,
70    },
71    origin::{stamp_for_peer, TaskOrigin, TaskProvenance, TrustClass},
72    otel::OtelEmitter,
73    outgoing,
74    protected_paths::{ProtectedPathRefusal, ProtectedPaths},
75    registration::SessionOutcome,
76    resources, running, sandbox,
77    sealed::UsernsGrant,
78    shell::{
79        build_shell_env, build_wasi_env_allowlist, is_shell_interpreter, run_shell,
80        shell_tool_manifest_yaml, split_shell_words, ShellOutcome, ShellResult,
81    },
82    spawn_credential::SpawnCredential,
83    spend::{MachineLedger, SpendMeter},
84    state_store::STATE_PREOPEN_NAME,
85    streaming::{
86        emit_chunk_sse, emit_sse, emit_thinking_chunk_sse, SseBroadcast, SseEventBuffer,
87        StreamStatus, TaskStatusUpdateEvent,
88    },
89    tool_annotations::ToolAnnotationMap,
90    trace::TraceWriter,
91    types::{
92        ArtifactRequest, CapabilityPolicy, DispatchOutcome, InstalledArtifactSummary, LaunchResult,
93        ResolvedLockArtifact, ResumeMode, ResumeRequest, StageRequest, StagedHookArtifact,
94        StagedSession,
95    },
96};
97
98/// Versioned instance export name a guest built against the semver'd
99/// `murmur:capsule@0.1.0` WIT package carries. This is the only name the host
100/// resolves — the legacy unversioned fallback was removed after the dual-accept
101/// runtime shipped (see `wit/VERSIONING.md`).
102const WIT_CAPSULE_IFACE_VERSIONED: &str = "murmur:capsule/run@0.1.0";
103/// Versioned instance export name a guest built against `murmur:tool@0.1.0`
104/// carries. Only name the host resolves; see `WIT_CAPSULE_IFACE_VERSIONED`.
105const WIT_TOOL_IFACE_VERSIONED: &str = "murmur:tool/run@0.1.0";
106
107/// The host provides its guest-facing *import* interfaces under the versioned
108/// instance name only. The legacy unversioned provisions were dropped after the
109/// dual-accept runtime shipped; a guest importing only the
110/// unversioned name now fails to link. See `wit/VERSIONING.md`.
111const WIT_TOOL_REGISTRY_IFACE: &str = "murmur:tool-registry/invoke@0.1.0";
112const WIT_TEXT_CHUNKS_IFACE: &str = "murmur:text/chunks@0.1.0";
113const WIT_TASK_IFACE: &str = "murmur:task/task@0.1.0";
114
115/// How long an agent session's teardown may run after the first `SIGTERM` before the process
116/// exits with status 143 regardless.
117///
118/// Longer than the async-hook drain budget (`ASYNC_HOOK_DRAIN_TIMEOUT` in `hooks.rs`, 15 s), so a
119/// drain that stays inside its own bound is never cut short by this one. A second `SIGTERM`
120/// exits at once without waiting for either.
121const TERMINATE_TEARDOWN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(20);
122
123/// Resolve a guest interface instance export by its versioned name. Returns
124/// `None` when the versioned name is absent, so a component that exports only
125/// the legacy unversioned name (or no recognizable name) surfaces as the
126/// missing-export error at the call site. See `wit/VERSIONING.md`.
127fn resolve_versioned_iface<T>(
128    instance: &wasmtime::component::Instance,
129    store: &mut Store<T>,
130    versioned: &str,
131) -> Option<wasmtime::component::ComponentExportIndex> {
132    instance.get_export_index(&mut *store, None, versioned)
133}
134
135/// Run one task's agent loop, honoring `on-task-end` `reopen-task` control decisions.
136///
137/// Fires `on-task-end` after every attempt (via [`HookRuntime::dispatch_task_end`]). If
138/// a blocking hook returns `reopen-task(reason)` and both budgets still allow it — fewer
139/// than `max_task_reopens` (the manifest's `lifecycle.max_task_reopens`) reopens used AND
140/// cumulative task turns still below `inference.max_turns` — the task's
141/// `accessible_workdir/task.md` is rewritten as the original content plus every reopen's
142/// feedback so far, a `task_reopened` trace record is written, and the loop runs again.
143/// Reopening shares one cumulative turn budget with the original attempt: each attempt is
144/// handed only `max_turns - task_turns()` turns, so the whole task can never exceed the
145/// capsule's turn ceiling.
146///
147/// Writes the terminal `task_end` record (carrying the final `reopen_count`) itself, and
148/// the terminal `on-task-end` dispatch is simply the loop's last one. Returns the task's
149/// final result: the last attempt's own result when a hook was satisfied (or none was
150/// bound), or `Err` when a hook still wanted to reopen but the reopen budget or turn
151/// ceiling was reached — so every existing `.is_err()` branch at the call sites treats an
152/// exhausted reopen as a failed task. In that case the terminal record's `exit_status` is
153/// `"reopen_budget_exhausted"`; otherwise it is the last attempt's `"ok"`/`"failed"`.
154///
155/// `seed` is whatever the task's single `on-task-start` dispatch proposed, handed to every
156/// attempt so a reopened task starts from the same context as its first run.
157///
158/// `agent_task_id` is what [`agent::run_agent_loop`] receives (governs A2A SSE emission);
159/// `trace_task_id` is the id used for the `task_start`/`task_reopened`/`task_end` records
160/// and the `on-task-end` hook event. The two coincide on the A2A path and differ on the
161/// backward-compat `task.md` paths, which pass `agent_task_id = None`.
162#[allow(clippy::too_many_arguments)]
163async fn run_task_with_reopens(
164    state: &mut CapsuleStoreState,
165    workdir: &Path,
166    inference: &InferenceConfig,
167    max_task_reopens: u32,
168    system_prompt: Option<String>,
169    run_config: agent::AgentRunConfig,
170    hooks: &mut HookRuntime,
171    trace: &mut TraceWriter,
172    otel: &mut OtelEmitter,
173    agent_task_id: Option<String>,
174    sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
175    accessible_workdir: &Path,
176    capsule_name: &str,
177    capsule_version: &str,
178    mode: ConversationMode,
179    context_id: Option<String>,
180    trace_task_id: &str,
181    seed: Option<HookSeed>,
182    // This task's cancel flag, or `None` on the `task.md` paths, which run no A2A task.
183    cancel: Option<crate::cancel::CancelSignal>,
184) -> Result<AgentLoopExit, RuntimeError> {
185    let task_md_path = accessible_workdir.join("task.md");
186    // Original task content, captured once before any feedback is appended, so repeated
187    // reopens re-inject a fresh copy of every feedback item rather than compounding.
188    let original_task = tokio::fs::read_to_string(&task_md_path)
189        .await
190        .unwrap_or_default();
191    // Every reopen's (hook_name, reason) so far — all re-injected on each reopen.
192    let mut feedback: Vec<(String, String)> = Vec::new();
193    let mut reopens_used: u32 = 0;
194    // Exactly the bytes the next attempt's agent loop will be handed, tracked alongside the
195    // `task.md` writes below so `murmur:task-io/read`'s `as-given` form is what the model saw
196    // rather than a re-read of a file whose path is a convention.
197    let mut as_given = original_task.clone();
198
199    loop {
200        // This function owns the task's scope: nothing else puts a task in scope, which is why
201        // the "no A2A message arrived" bypass path — a direct `run_agent_loop` call that
202        // dispatches no `on-task-end` — correctly reports `no-task` to a hook.
203        hooks.begin_task_attempt(original_task.clone(), as_given.clone());
204
205        // Reopening never grants turns past `max_turns`: hand this attempt only the turns
206        // still unspent by prior attempts of the same task. On the first attempt
207        // `task_turns()` is 0 (reset by the preceding `write_task_start`), so it gets the
208        // full ceiling.
209        let remaining_turns = inference.max_turns.saturating_sub(trace.task_turns());
210        let mut attempt_inference = inference.clone();
211        attempt_inference.max_turns = remaining_turns;
212
213        let result = agent::run_agent_loop(
214            state,
215            workdir,
216            &attempt_inference,
217            system_prompt.clone(),
218            run_config.clone(),
219            hooks,
220            trace,
221            otel,
222            agent_task_id.clone(),
223            sse.clone(),
224            accessible_workdir,
225            capsule_name,
226            capsule_version,
227            mode.clone(),
228            context_id.clone(),
229            // Cloned per attempt rather than moved into the first: a reopened task re-runs
230            // the same task, so every attempt must start from the same context the hook
231            // proposed. The hook is dispatched once, at task start, and is not asked again.
232            seed.clone(),
233            cancel.clone(),
234        )
235        .await;
236
237        // Stands in for work between `task_canceled` and `task_end` — a slow `on-task-end` hook —
238        // so a test can hold a capsule in that window. Absent from release builds.
239        #[cfg(debug_assertions)]
240        {
241            if let Some(ms) = std::env::var("MURMUR_DEBUG_TASK_END_DELAY_MS")
242                .ok()
243                .and_then(|value| value.parse::<u64>().ok())
244            {
245                tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
246            }
247        }
248
249        // The attempt's own terminal outcome, not a coarse ok/failed: an agent loop that
250        // burned its turn budget reports `max_turns_reached`, and this is the only record
251        // that keeps it.
252        let exit_str = match &result {
253            Ok(exit) => exit.as_str(),
254            Err(_) => "failed",
255        };
256
257        // Let the `on-task-end` hooks inspect this attempt and decide whether to reopen.
258        let reopen = hooks
259            .dispatch_task_end(trace_task_id.to_string(), exit_str.to_string())
260            .await;
261
262        // A cancelled attempt is not reopened. The hook still saw it — `on-task-end` is
263        // dispatched above with exit status `canceled` — but re-running work a person just
264        // stopped is the defect this refusal exists to prevent.
265        let canceled = matches!(result, Ok(AgentLoopExit::Canceled));
266        match reopen.filter(|_| !canceled) {
267            Some(TaskReopen { hook_name, reason }) => {
268                // A hook wants more. Honor it only if a reopen remains in the budget AND
269                // turns remain under the ceiling; otherwise the request is exhausted and
270                // ends the task non-silently.
271                let budget_ok = reopens_used < max_task_reopens;
272                let turns_ok = trace.task_turns() < inference.max_turns;
273                if budget_ok && turns_ok {
274                    reopens_used += 1;
275                    feedback.push((hook_name.clone(), reason.clone()));
276                    let _ = trace
277                        .write_task_reopened(trace_task_id, &hook_name, &reason, reopens_used)
278                        .await;
279                    // Rewrite task.md as original + all feedback so far; the resumed
280                    // attempt picks it up through its normal `read_task`, so neither
281                    // transport's message-building code needs to change.
282                    let rewritten = build_reopen_task_md(&original_task, &feedback);
283                    if let Err(e) = tokio::fs::write(&task_md_path, rewritten.as_bytes()).await {
284                        eprintln!(
285                            "[capsule-runtime] failed to inject reopen feedback into task.md: {e}"
286                        );
287                    }
288                    as_given = rewritten;
289                    continue;
290                }
291                // Budget or turn ceiling reached while a hook still wanted to reopen: end
292                // the task as a distinct, non-silent failure rather than an ordinary
293                // completion. `Err` keeps every existing `.is_err()` downstream branch.
294                let _ = trace
295                    .write_task_end(trace_task_id, "reopen_budget_exhausted", reopens_used)
296                    .await;
297                hooks.end_task();
298                return Err(RuntimeError::AgentLoopFailed(format!(
299                    "task reopen budget exhausted after {reopens_used} reopen(s): hook \
300                     '{hook_name}' still requested another reopen"
301                )));
302            }
303            None => {
304                // No hook asked to reopen — this attempt is terminal.
305                let _ = trace
306                    .write_task_end(trace_task_id, exit_str, reopens_used)
307                    .await;
308                hooks.end_task();
309                return result;
310            }
311        }
312    }
313}
314
315/// Compose the reopened task's `task.md`: the original task content followed by a clearly
316/// delimited feedback section for every reopen so far, each naming the hook that produced
317/// it. Used by [`run_task_with_reopens`].
318fn build_reopen_task_md(original: &str, feedback: &[(String, String)]) -> String {
319    let mut out = original.trim_end().to_string();
320    out.push_str(
321        "\n\n---\n\n# Reopen feedback\n\nThe previous attempt was not accepted. Address the \
322         following feedback, then continue.\n",
323    );
324    for (i, (hook_name, reason)) in feedback.iter().enumerate() {
325        out.push_str(&format!(
326            "\n## Reopen {} — from hook `{}`\n\n{}\n",
327            i + 1,
328            hook_name,
329            reason.trim()
330        ));
331    }
332    out
333}
334
335/// Live-delivery buffer: only needs to cover the lag between fastest and slowest
336/// currently-connected reader.
337const SSE_BROADCAST_CAPACITY: usize = 128;
338
339/// Replay buffer: covers observers joining mid-task. Sized to handle the longest
340/// expected task turn without eviction. Can be tuned independently of broadcast capacity.
341/// Candidate for `LifecycleConfig` if operator tuning is needed in a future slice.
342const SSE_REPLAY_CAPACITY: usize = 512;
343
344/// Whether any staged hook can receive the `on-compaction` lifecycle event. Mirrors the
345/// binding match the runtime uses when it actually dispatches compaction, so MURMUR.md's
346/// "compaction configured" status reflects the real dispatch path rather than an artifact name.
347fn has_compaction_hook(hooks: &[StagedHookArtifact]) -> bool {
348    hooks.iter().any(|h| {
349        matches!(
350            h.config.binding,
351            HookBinding::OnCompaction | HookBinding::All
352        )
353    })
354}
355
356/// Whether `mur run --resume` can do what it was asked, checked at staging so a launch that
357/// cannot continue anything never creates a session directory.
358///
359/// Two ways to be unlaunchable, and each is refused rather than degraded: `compact` with nothing
360/// bound to `on-compaction` has nothing to produce the summary, and a context with no record on
361/// disk has nothing to continue. Silently falling back to `full`, or starting fresh, would both
362/// be indistinguishable to the operator from a resume that worked.
363fn check_resume_launchable(
364    resume: &ResumeRequest,
365    context_id: Option<&str>,
366    context: Option<&ContextConfig>,
367    capsule_name: &str,
368    inference: Option<&InferenceConfig>,
369    hooks: &[StagedHookArtifact],
370) -> Result<(), RuntimeError> {
371    if resume.mode == ResumeMode::Compact && !has_compaction_hook(hooks) {
372        return Err(RuntimeError::ResumeCompactionHookMissing);
373    }
374    // A resume that reached staging with no context id resolved nothing, so there is no record to
375    // look for; the placeholder keeps the refusal's wording honest about that.
376    let context_id = context_id.unwrap_or("<unresolved>").to_string();
377    let missing = |reason: String| RuntimeError::ResumeRecordMissing {
378        session: resume.from_session.clone(),
379        context_id: context_id.clone(),
380        reason,
381    };
382    // The same three ways `resolve_conversation_root` returns `None`, plus the two this check
383    // adds: a capsule with no `inference:` block at all, and a record path that resolves but
384    // holds no file.
385    let Some(inference) = inference else {
386        return Err(missing(
387            "the capsule declares no inference block and keeps no conversation record".to_string(),
388        ));
389    };
390    if inference.transport == "process" {
391        return Err(missing(
392            "the capsule declares inference.transport: process, whose CLI owns its own \
393             conversation, and kept no conversation record"
394                .to_string(),
395        ));
396    }
397    let Some(record) = crate::conversation::resolve_record_name(context, capsule_name) else {
398        return Err(missing(
399            "the capsule declares context.record: off and kept no conversation record".to_string(),
400        ));
401    };
402    let root = crate::conversation::record_root(&record).map_err(&missing)?;
403    let path = crate::conversation::record_file(&root, &context_id);
404    if !path.is_file() {
405        return Err(missing(format!(
406            "no conversation record at {}",
407            path.display()
408        )));
409    }
410    Ok(())
411}
412
413/// Resolves and verifies all artifacts, compiles components, and prepares session state.
414///
415/// This function is intentionally separate from `launch_session` so policy/capability
416/// checks can be layered in the seam between stage and launch without mixing concerns.
417pub fn stage_session(
418    registry: Arc<dyn Registry>,
419    request: StageRequest,
420) -> Result<StagedSession, RuntimeError> {
421    validate_capability_policy(&request.capability_policy)?;
422    // Before any registry pull, component compile or workdir creation: if this host cannot
423    // back the declared floor, refuse rather than launch something weaker than was asked for.
424    // `achieved` comes from a live kernel probe only — the manifest never gets a vote in what
425    // the host is reported to provide.
426    // `achieved` is the host's live kernel probe, capped by the one manifest property that can
427    // lower it: `capabilities.filesystem.workdir_exec`. The manifest still gets no vote in what the
428    // host is *reported* to provide — the cap can only ever subtract (see
429    // `containment::achieved_containment_class`).
430    let workdir_exec = request.capability_policy.workdir_exec_allowed;
431    // The host is probed exactly once per session, right here, and every later consumer — the
432    // refusal below, the `ScopeReport` recorded in the trace, and the tier `launch_session`
433    // installs — reads this one value. A second probe could read differently from the first (an
434    // AppArmor profile loaded, a container's capabilities changed), and the trace would then
435    // describe a session that never ran under what it claims.
436    let host_probe = sandbox::HostProbe::probe();
437    let enforcement_tier = host_probe.tier();
438    // Derived only so the refusal can name *which* part of the sealed mechanism is missing —
439    // the AppArmor profile, `CAP_SYS_ADMIN` inside a container, or the kernel itself.
440    let sealed_blocker = host_probe.sealed_blocker();
441    let achieved_containment = achieved_containment_class(enforcement_tier, workdir_exec);
442    check_containment_floor(
443        request.declared_containment_floor,
444        achieved_containment,
445        sealed_blocker,
446        workdir_exec,
447    )?;
448    // The complete grant set this session is about to run under, in exactly the shape
449    // `mur run --explain-scope --json` prints — same builder, same policy, same declared floor,
450    // and the same probe taken above. Computed once here rather than at trace-open time so
451    // the record cannot drift from the decision that let the session start.
452    let exports_files = request
453        .exports
454        .as_ref()
455        .and_then(|exports| exports.files.clone());
456    let exports_peer_files = request
457        .exports
458        .as_ref()
459        .and_then(|exports| exports.peer_files.clone());
460    // Resolved before the report and before any per-artifact staging, so a malformed store name
461    // refuses the launch on the same terms as an unmeetable containment floor: nothing pulled,
462    // nothing created, nothing instantiated. Resolution only — `stage_artifact_grant` below is
463    // what actually creates a directory, and only for an artifact whose entry declared one.
464    let state_stores = crate::state_store::state_store_reports(
465        request
466            .artifacts
467            .iter()
468            .map(|artifact| (artifact.name.as_str(), artifact.capabilities.as_ref())),
469        &request.capsule_name,
470    )?;
471    warn_on_inert_capsule_wide_state(request.capability_policy.state_declared);
472    warn_on_inert_capsule_wide_conversation(request.capability_policy.conversation_declared);
473    // Both halves of a record path, checked here for the reason a state store name is: a value
474    // that cannot be one directory segment refuses the launch before anything is pulled, created
475    // or instantiated, naming the key the operator wrote it under.
476    if let Some(record) = request
477        .context
478        .as_ref()
479        .filter(|context| context.record)
480        .and_then(|context| context.record_store.as_deref())
481    {
482        crate::conversation::validate_record_segment("context.record_store", record)?;
483    }
484    if let Some(context_id) = request.context_id.as_deref() {
485        crate::conversation::validate_record_segment("--context", context_id)?;
486    }
487    // An artifact may not claim a name the runtime answers itself. Checked here, ahead of the
488    // artifact loop, so the refusal names the collision rather than whatever the registry would
489    // have said about a name nothing can legally publish under: no resolve, no pull and no hash
490    // verification happens for a manifest that declares one.
491    check_no_reserved_tool_names(
492        request
493            .artifacts
494            .iter()
495            .map(|artifact| artifact.name.as_str()),
496    )?;
497    // A grant the credential backstop empties before any guest is built is refused on the same
498    // terms, ahead of every resolve and pull: the manifest yields, the backstop does not.
499    check_env_allow_reaches_guests(&request.capability_policy)?;
500    // Resolved here for the same reason `state_stores` above is: a malformed `config:` block
501    // refuses the launch before any registry pull, workdir creation or component instantiation,
502    // and through the identical function `mur run --explain-scope` calls on the identical inputs.
503    let configured_artifacts = crate::artifact_config::configured_artifact_names(
504        request
505            .artifacts
506            .iter()
507            .map(|artifact| (artifact.name.as_str(), artifact.config.as_ref())),
508    )?;
509    // Resolved through the identical function `mur run --explain-scope` and `mur doctor` call on
510    // the identical inputs, so all three describe one preopen set. An escaping scope refuses the
511    // launch here, before any registry pull or workdir creation, on the same terms a malformed
512    // store name does.
513    let preopens =
514        crate::network_policy::preopen_reports(request.artifacts.iter().map(|artifact| {
515            (
516                artifact.name.as_str(),
517                &artifact.runtime,
518                artifact.capabilities.as_ref(),
519            )
520        }))?;
521    let scope_report = crate::containment::scope_report_for_tier(
522        &request.capability_policy,
523        request.declared_containment_floor,
524        enforcement_tier,
525        sealed_blocker,
526        host_probe.userns_grant(),
527        request.exports.as_ref(),
528        state_stores,
529        configured_artifacts,
530        preopens,
531        // Not established at stage time: whether the declared `io.max` ceiling applies is known
532        // only once the cgroup scope has been created, which `launch_session` does. Left at
533        // `NotProbed` here so the staged report claims nothing, and overwritten from
534        // `prepare_scope`'s real write outcome before `session_start` is written.
535        crate::cgroup::IoMaxReport::default(),
536    );
537    // Asked here, beside the containment floors and before any registry pull or workdir creation:
538    // an ephemeral capsule's teardown is what bounds every handle it minted, and `after_task:
539    // sleep` withdraws that bound on purpose. Once withdrawn, the declared lifetime is the only
540    // one there is, so it has to be declared and it has to be short.
541    check_persistent_handle_ttl(
542        exports_peer_files.as_ref(),
543        &resolve_lifecycle(
544            request.lifecycle.clone(),
545            request.lifecycle_override.as_ref(),
546        ),
547    )?;
548    // A second, independent floor question, deliberately asked right here next to the first: not
549    // "can this host back what was declared?" but "did the capsule declare enough for what it
550    // asks for?". A `staged_runtime` grant needs a composed root to be staged into, and one is
551    // only built for a capsule that declared `sealed` — so this refuses on the declared floor
552    // alone and never consults the host probe above.
553    crate::staged_runtime::check_staged_runtime_floor(
554        &request.capability_policy.shell_staged_runtime,
555        request.declared_containment_floor,
556    )?;
557    // The same question as the line above, asked of the other half of the same gap. That one
558    // catches a grant declared at too low a floor; this one catches a `sealed` capsule that
559    // declared no grant at all for a `shell.allow` entry that provably needs one — a `#!` script,
560    // whose ELF/DT_NEEDED closure is empty, so the staging that makes an ELF binary work stages
561    // nothing at all of what the script imports. Same declared-floor-only gating, same
562    // pre-registry-pull position, same "name every offender once" refusal shape.
563    crate::reachability::check_interpreted_entrypoints_reachable(
564        &request.capability_policy,
565        request.declared_containment_floor,
566    )?;
567    // A third refusal, in the same pre-staging seam and for the same fail-closed reason, but
568    // about a mechanism that sits outside the containment ladder entirely: a capsule that can
569    // spawn a native subprocess needs a network namespace to put it in, because that namespace —
570    // not a syscall filter — is now what makes `capabilities.network.allow` mean anything for
571    // that subprocess. See `RuntimeError::EgressNamespaceUnavailable` for why this refuses even
572    // when the allowlist is empty.
573    //
574    // Narrower than `cgroup::requires_process_bounding` on purpose, not by oversight: that check
575    // also fires for a capsule with a *native-implementation artifact* and neither `shell.allow`
576    // nor `spawn.allow`, but `has_native_artifact` comes from `staged.installed_artifacts`, which
577    // only exists after staging resolves the registry — i.e. after this very check has to have
578    // already run, per the "before any registry pull" rule above. A native-artifact-only capsule
579    // therefore does not get this clean refusal on a host that cannot build the namespace; it
580    // instead surfaces as a raw `io::Error` out of `create_capsule_netns`'s `pre_exec` failure
581    // when that artifact is actually launched.
582    crate::network_namespace::check_egress_namespace(
583        !request.capability_policy.shell_allow.is_empty()
584            || !request.capability_policy.spawn_allow.is_empty(),
585        crate::network_namespace::detect_egress_namespace_blocker(),
586    )?;
587    // Lowered here, in the same pre-registry-pull seam and for the same reason a state store name
588    // is: an entry that cannot be a workdir subtree refuses the launch before anything is pulled,
589    // created or instantiated, so no call is ever checked against a rule the runtime could not
590    // build. This is also the only place it is built — the dispatch check reads this value, never
591    // the declared strings.
592    let protected_paths =
593        ProtectedPaths::from_declared(&request.capability_policy.read_only_paths)?;
594    // Capsule-ceiling-level, not per-artifact: `interpreter_runtime` lives on the capsule's own
595    // top-level `capabilities.shell`, so warn here (before the per-artifact staging loop) rather
596    // than in `stage_artifact_grant`.
597    warn_on_interpreter_runtime_grants(&request.capability_policy.shell_interpreter_runtime);
598    // Same seam, same reason: a capsule-wide declaration whose cost the operator should see stated
599    // once, before anything else happens. Ordered after the refusals above so a manifest that is
600    // going to be rejected outright is not first warned about.
601    warn_on_workdir_exec(workdir_exec);
602    // Ordered beside the other capsule-wide declarations whose cost the operator should read
603    // before the session starts, and after the refusals above so a manifest that will be rejected
604    // is not first warned about.
605    warn_on_advisory_read_only(
606        &request.capability_policy.read_only_paths,
607        &request.capability_policy.shell_allow,
608    );
609    // A host posture rather than a manifest declaration, but stated in the same place and for the
610    // same reason: this session is about to record an achieved class that a weakened host and the
611    // shipped profile can both produce, and the operator should be told which one they are on
612    // before reading the result. Never a refusal — see `warn_on_userns_restriction_disabled_host_wide`.
613    // Read off the report rather than re-probed, so the warning and the record cannot disagree.
614    warn_on_userns_restriction_disabled_host_wide(scope_report.userns_grant);
615    // The non-fatal half of the reachability check above. A compiler driver's helper binaries
616    // (`cc1`, `as`, `ld`, `collect2`) are exec'd by the driver itself and sit outside its own
617    // DT_NEEDED closure, inside the fixed sealed tree that is deliberately bound without the
618    // Landlock Execute right — so `cc` starts and the first real compile does not finish. This
619    // warns rather than refuses because the probe behind it is a heuristic about one driver
620    // family; see `reachability::warn_on_unreachable_toolchain_helpers`, which prints each
621    // `W-SEC-012` line, and each `W-SEC-029` line for a driver it could not run, itself so
622    // `mur doctor` and this call site cannot state them differently.
623    crate::reachability::warn_on_unreachable_toolchain_helpers(
624        &request.capability_policy,
625        request.declared_containment_floor,
626    );
627
628    let engine = build_engine()?;
629    // Start ticking before the first guest runs: `dispatch_stage` below invokes on-stage
630    // hooks, which are already subject to the epoch deadline.
631    let epoch_ticker = EpochTicker::spawn(&engine);
632    let lock_expectations = request.lock_expectations.map(|entries| {
633        entries
634            .into_iter()
635            .map(|entry| (entry.name.clone(), entry))
636            .collect::<HashMap<_, _>>()
637    });
638
639    let mut resolved_lock_artifacts = Vec::with_capacity(request.artifacts.len());
640    let mut installed_artifacts = Vec::with_capacity(request.artifacts.len());
641    let mut installed_manifests = Vec::with_capacity(request.artifacts.len());
642    let mut tool_components = HashMap::with_capacity(request.artifacts.len());
643    // Only tools/drivers that declare a `capabilities:` block get an entry; everything else
644    // stays absent and therefore runs on the unclamped ceiling.
645    let mut artifact_grants: HashMap<String, ToolCapabilityGrant> = HashMap::new();
646    let mut hook_components = Vec::new();
647    // The ceiling every per-artifact network grant is clamped against. Re-parsed here rather
648    // than at launch because narrowing is lowered at staging time; `validate_capability_policy`
649    // above already proved these entries parse.
650    let ceiling_network_allow_rules =
651        parse_network_allow_rules(&request.capability_policy.network_allow)?;
652    // The capsule operator's own name for this capsule, borrowed for the length of the staging
653    // loop below (which borrows `request.artifacts`). It is what an artifact entry's
654    // `capabilities.state` defaults its store name to.
655    let capsule_name = request.capsule_name.clone();
656    // (name, binary_bytes) for native tool artifacts — installed after workdir creation
657    let mut native_binaries: Vec<(String, Vec<u8>)> = Vec::new();
658    // (name, skill_md_bytes) for skill artifacts — installed after workdir creation
659    let mut skill_files: Vec<(String, Vec<u8>)> = Vec::new();
660
661    for artifact in &request.artifacts {
662        // Local-source skill: resolve skill.md directly from the filesystem and skip the
663        // registry/lock pipeline entirely. Validated as skill-only at manifest parse time.
664        if let Some(source) = &artifact.source {
665            let skill_md = load_local_skill_md(&request.manifest_dir, source)?;
666            skill_files.push((artifact.name.clone(), skill_md));
667            installed_artifacts.push(InstalledArtifactSummary {
668                name: artifact.name.clone(),
669                version: artifact.version.clone(),
670                runtime: artifact.runtime.clone(),
671                implementation: None,
672            });
673            continue;
674        }
675
676        let (resolved_version, expected_hash) = match &lock_expectations {
677            Some(expected_by_name) => {
678                let expected = expected_by_name.get(&artifact.name).ok_or_else(|| {
679                    RuntimeError::LockMissingEntry {
680                        name: artifact.name.clone(),
681                    }
682                })?;
683
684                if artifact.version != expected.resolved_version {
685                    return Err(RuntimeError::LockVersionMismatch {
686                        name: artifact.name.clone(),
687                        requested: artifact.version.clone(),
688                        pinned: expected.resolved_version.clone(),
689                    });
690                }
691
692                (
693                    expected.resolved_version.clone(),
694                    Some(expected.sha256.clone()),
695                )
696            }
697            None => (artifact.version.clone(), None),
698        };
699
700        // Always pass current_platform(). LocalRegistry and RemoteRegistry both implement a
701        // fallback: if no platform-specific file exists they return the generic file. This
702        // means WASM artifacts (which have no platform file) resolve transparently, while
703        // native artifacts get their correct platform variant. Nexus must preserve this
704        // fallback behaviour — if it ever becomes strict (error on no platform match), WASM
705        // resolution will break.
706        let resolved = registry
707            .resolve_with_platform(&artifact.name, &resolved_version, Some(current_platform()))
708            .map_err(|err| map_registry_error(&artifact.name, &resolved_version, err))?;
709
710        verify_sha256(
711            &artifact.name,
712            &resolved_version,
713            &resolved.bytes,
714            &resolved.sha256,
715        )
716        .map_err(|_| RuntimeError::artifact_integrity_failed(&artifact.name, &resolved_version))?;
717
718        // Which platform this payload gets pinned under in `murmur.lock`. A native binary is a
719        // different payload per platform, so its hash is only ever the hash for this host; a
720        // WASM component or a skill is the same bytes everywhere and is pinned once. Read from
721        // the payload's own recorded runtime, the same question `pull` asks.
722        let resolved_platform =
723            (resolved.meta.runtime == RuntimeType::Native).then(|| current_platform().to_string());
724
725        if let Some(expected) = expected_hash {
726            if resolved.sha256 != expected {
727                return Err(RuntimeError::artifact_integrity_failed(
728                    &artifact.name,
729                    &resolved_version,
730                ));
731            }
732        }
733
734        let manifest_yaml =
735            extract_manifest_yaml(&artifact.name, &resolved_version, &resolved.bytes)?;
736
737        installed_manifests.push((artifact.name.clone(), manifest_yaml.clone()));
738
739        let mut artifact_implementation: Option<ArtifactImplementation> = None;
740
741        match artifact.runtime {
742            ArtifactRuntime::Tool => {
743                let implementation = parse_tool_implementation_from_yaml(&manifest_yaml);
744                artifact_implementation = Some(implementation.clone());
745                match implementation {
746                    ArtifactImplementation::Native => {
747                        // A native tool is a host subprocess, not a WASI guest: it never
748                        // reaches `invoke_tool_component`, so nothing would apply a
749                        // per-artifact grant to it. Say so rather than let the block read
750                        // as enforced.
751                        warn_on_unenforceable_native_capabilities(
752                            &artifact.name,
753                            artifact.capabilities.as_ref(),
754                        );
755                        // Same hazard, one layer over: config is delivered in the per-artifact
756                        // WASI environment, which a host subprocess never has.
757                        warn_on_inert_native_config(&artifact.name, artifact.config.as_ref());
758                        let binary = extract_native_binary(
759                            &artifact.name,
760                            &resolved_version,
761                            &resolved.bytes,
762                        )?;
763                        native_binaries.push((artifact.name.clone(), binary));
764                    }
765                    ArtifactImplementation::Wasm => {
766                        stage_artifact_grant(
767                            artifact,
768                            &ceiling_network_allow_rules,
769                            &capsule_name,
770                            &mut artifact_grants,
771                        )?;
772                        let tool_wasm =
773                            extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
774                        let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
775                            RuntimeError::ToolComponentCompile {
776                                name: artifact.name.clone(),
777                                version: resolved_version.clone(),
778                                message: err.to_string(),
779                            }
780                        })?;
781                        tool_components.insert(artifact.name.clone(), tool_component);
782                    }
783                }
784            }
785            ArtifactRuntime::Driver => {
786                // Same call as the WASM-tool arm above, and deliberately so: a driver is
787                // staged into `tool_components` and dispatched through
788                // `invoke_tool_component` like any tool, so narrowing needs no
789                // driver-specific enforcement anywhere downstream.
790                stage_artifact_grant(
791                    artifact,
792                    &ceiling_network_allow_rules,
793                    &capsule_name,
794                    &mut artifact_grants,
795                )?;
796                let tool_wasm =
797                    extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
798                let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
799                    RuntimeError::ToolComponentCompile {
800                        name: artifact.name.clone(),
801                        version: resolved_version.clone(),
802                        message: err.to_string(),
803                    }
804                })?;
805                tool_components.insert(artifact.name.clone(), tool_component);
806            }
807            ArtifactRuntime::Hook => {
808                let hook_config = parse_hook_config_from_yaml(&manifest_yaml).map_err(|e| {
809                    RuntimeError::Runtime(format!(
810                        "hook {}@{} invalid config: {e}",
811                        artifact.name, resolved_version
812                    ))
813                })?;
814                let hook_wasm =
815                    extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
816                let hook_component = Component::new(&engine, hook_wasm).map_err(|err| {
817                    RuntimeError::ToolComponentCompile {
818                        name: artifact.name.clone(),
819                        version: resolved_version.clone(),
820                        message: err.to_string(),
821                    }
822                })?;
823                // The grant comes from `artifact` — the operator's own manifest entry for
824                // this hook — and never from `manifest_yaml`, the hook's bundled manifest
825                // parsed just above for its behavioral contract. A hook pulled from a
826                // registry therefore cannot widen what the host lets it do. Deriving here
827                // (rather than at instantiation) means a malformed grant fails staging,
828                // before any hook component runs.
829                let mut grant =
830                    HookCapabilityGrant::derive(artifact.capabilities.as_ref(), &capsule_name)?;
831                // Same division as `stage_artifact_grant`: `derive` validated the name and stays
832                // pure, and the directory is created here, on the staging path, once.
833                if let Some(store) = grant.state_store.as_deref() {
834                    grant.state_dir = Some(crate::state_store::ensure_state_store(store)?);
835                }
836                // Operator-sourced like `grant` itself, and lowered onto the grant rather than
837                // into `HookEnvVars`, which is session-wide: the grant is what dispatch already
838                // looks up per hook, so this is what scopes the value to the declaring hook.
839                grant.config_json = artifact
840                    .config
841                    .as_ref()
842                    .map(|config| {
843                        crate::artifact_config::lower_artifact_config(&artifact.name, config)
844                    })
845                    .transpose()?;
846                warn_on_inert_hook_capabilities(&artifact.name, artifact.capabilities.as_ref());
847                hook_components.push(StagedHookArtifact {
848                    name: artifact.name.clone(),
849                    version: resolved_version.clone(),
850                    component: hook_component,
851                    config: hook_config,
852                    grant,
853                    // Operator-sourced like `grant`, for the same reason: how much of the
854                    // agent's telemetry may be dropped to keep the loop moving is the
855                    // operator's call, not the hook author's.
856                    on_overflow: artifact.on_overflow,
857                });
858            }
859            ArtifactRuntime::Skill => {
860                let skill_md =
861                    extract_skill_md(&artifact.name, &resolved_version, &resolved.bytes)?;
862                skill_files.push((artifact.name.clone(), skill_md));
863            }
864        }
865
866        resolved_lock_artifacts.push(ResolvedLockArtifact {
867            name: artifact.name.clone(),
868            resolved_version: resolved_version.clone(),
869            sha256: resolved.sha256,
870            platform: resolved_platform,
871        });
872        installed_artifacts.push(InstalledArtifactSummary {
873            name: artifact.name.clone(),
874            version: resolved_version,
875            runtime: artifact.runtime.clone(),
876            implementation: artifact_implementation,
877        });
878    }
879
880    // Minted before the spend meter rather than beside the workdir it names: the ledger records it.
881    let session_id = generate_session_id();
882
883    // Before `dispatch_stage` runs any on-stage hook and before the session directory exists: a
884    // machine ceiling that cannot be kept, or a `transport: http` driver that does not say how its
885    // provider takes the key (it could only be run by handing it the key), refuses the launch here
886    // rather than at the first turn.
887    let spend = stage_spend_meter(
888        request.inference.as_ref(),
889        request.machine_tokens_per_day,
890        &session_id,
891    )?;
892    let inference_gateway = stage_inference_gateway(
893        request.inference.as_ref(),
894        request.credentials_file.as_deref(),
895        &installed_manifests,
896        &installed_artifacts,
897        &spend,
898    )?;
899
900    // Asked as soon as the hook artifacts are staged and their bindings are known, and before
901    // the session directory is created: a resume that cannot continue anything must leave no
902    // `ses_*` directory behind for the next `--resume @1` to name.
903    if let Some(ref resume) = request.resume {
904        check_resume_launchable(
905            resume,
906            request.context_id.as_deref(),
907            request.context.as_ref(),
908            &request.capsule_name,
909            request.inference.as_ref(),
910            &hook_components,
911        )?;
912    }
913
914    // For agent capsules (inference configured, empty WASM bytes) skip component compilation.
915    // For script capsules, compile the WASM component.
916    let capsule_component =
917        if request.inference.is_some() && request.capsule_component_bytes.is_empty() {
918            None
919        } else if request.capsule_component_bytes.is_empty() {
920            return Err(RuntimeError::CapsuleCompile(
921                "capsule component bytes are required for non-agent capsules".to_string(),
922            ));
923        } else {
924            Some(
925                Component::new(&engine, &request.capsule_component_bytes)
926                    .map_err(|err| RuntimeError::CapsuleCompile(err.to_string()))?,
927            )
928        };
929
930    let (workdir, accessible_workdir) = if let Some(ref user_dir) = request.workdir {
931        let user_dir = if user_dir.is_absolute() {
932            user_dir.clone()
933        } else {
934            std::env::current_dir()
935                .map_err(|e| RuntimeError::Runtime(format!("failed to get cwd: {e}")))?
936                .join(user_dir)
937        };
938        if !user_dir.exists() || !user_dir.is_dir() {
939            return Err(RuntimeError::Runtime(format!(
940                "workdir '{}' does not exist or is not a directory",
941                user_dir.display()
942            )));
943        }
944        let session_dir = user_dir.join(".murmur").join(&session_id);
945        (session_dir, user_dir)
946    } else {
947        let dir = request.manifest_dir.join("workdir").join(&session_id);
948        let accessible = dir.clone();
949        (dir, accessible)
950    };
951    // Before the workdir is created and before anything is staged into it: a declared export
952    // whose root already resolves outside the accessible workdir must refuse the launch, not be
953    // discovered one served file at a time.
954    if let Some(ref export) = exports_files {
955        crate::resource_plane::check_export_root(&accessible_workdir, export)?;
956    }
957    if let Some(ref export) = exports_peer_files {
958        crate::resource_plane::check_peer_files_root(&accessible_workdir, export)?;
959    }
960
961    fs::create_dir_all(workdir.join("tools")).map_err(|source| RuntimeError::CreateWorkdir {
962        path: workdir.display().to_string(),
963        source,
964    })?;
965
966    for (name, manifest_yaml) in &installed_manifests {
967        write_tool_manifest(&workdir, name, manifest_yaml)?;
968    }
969
970    install_native_binaries(&workdir, native_binaries)?;
971    install_skill_files(&workdir, skill_files)?;
972
973    // Write generic manifests for any shell binary not already covered by a custom manifest.
974    write_shell_tool_manifests(&workdir, &request.capability_policy.shell_allow)?;
975
976    // The two peer-handoff tools, written on exactly the terms the shell manifests above are:
977    // a synthetic `tools/<name>/murmur.yaml` that `build_tool_inventory` picks up unchanged,
978    // paired with a dispatch branch in `dispatch_agent_tool_async`. Each is written **only** when
979    // its grant is declared, so an undeclared capsule's model never sees the tool exists.
980    write_peer_handoff_tool_manifests(
981        &workdir,
982        exports_peer_files.is_some(),
983        !request.capability_policy.peer_fetch_allow.is_empty(),
984    )?;
985
986    // And the delegation tool, on the same terms again. Its schema is built rather than fixed:
987    // the `capsule` property's `enum` is this capsule's own `capabilities.spawn.allow`, so the
988    // model is offered the names the operator granted and cannot name anything else.
989    write_delegate_task_tool_manifest(&workdir, &request.capability_policy.spawn_allow)?;
990
991    // And the plan tool, on the same terms once more. Its schema is fixed rather than built: a
992    // plan reaches this session's own tools, so there is no granted list to fold into it.
993    write_submit_plan_tool_manifest(&workdir, request.capability_policy.plan_submit)?;
994
995    // Read once, here, from the manifests just staged: the schema is fixed before the session
996    // starts, so an annotation is the tool author's statement and never a call-time choice. A
997    // capsule that declared nothing read-only reads no schema at all — the analyser it would feed
998    // never runs.
999    let tool_annotations = if protected_paths.is_empty() {
1000        ToolAnnotationMap::default()
1001    } else {
1002        warn_on_unannotated_tool_schemas(&installed_manifests);
1003        ToolAnnotationMap::from_workdir(&workdir)
1004    };
1005
1006    // Dispatch on-stage hooks synchronously now that manifests are in place.
1007    let stage_env = HookEnvVars::default();
1008    dispatch_stage(
1009        &engine,
1010        &workdir,
1011        &hook_components,
1012        request.capability_policy.shell_allow.clone(),
1013        &stage_env,
1014        request.capability_policy.hook_limits(),
1015    )?;
1016
1017    // Write MURMUR.md now for agent sessions so that tooling that inspects the workdir after
1018    // staging (e.g. tests, debuggers) can read it. launch_session overwrites this file after
1019    // port binding with the complete identity including capsule_url.
1020    if let Some(ref inference) = request.inference {
1021        let partial_identity = CapsuleIdentity {
1022            capsule_name: request.capsule_name.clone(),
1023            capsule_version: request.capsule_version.clone(),
1024            session_id: session_id.clone(),
1025            capsule_url: String::new(),
1026        };
1027        murmur_md::write_murmur_md(
1028            &workdir,
1029            Some(inference),
1030            request.context.as_ref(),
1031            has_compaction_hook(&hook_components),
1032            &request.capability_policy,
1033            &partial_identity,
1034        );
1035    }
1036
1037    // When --workdir is used, copy the project manifest to the accessible workdir so the agent
1038    // can reference it by relative path from ".".
1039    if request.workdir.is_some() {
1040        let src = request.manifest_dir.join(MANIFEST_FILENAME);
1041        let dst = accessible_workdir.join(MANIFEST_FILENAME);
1042        if src.exists() && !dst.exists() {
1043            if let Err(e) = fs::copy(&src, &dst) {
1044                eprintln!(
1045                    "[capsule-runtime] warning: failed to copy {MANIFEST_FILENAME} to workdir: {e}"
1046                );
1047            }
1048        }
1049    }
1050
1051    // Resolve lifecycle: manifest config + any override
1052    let lifecycle = resolve_lifecycle(request.lifecycle, request.lifecycle_override.as_ref());
1053
1054    Ok(StagedSession {
1055        session_id,
1056        workdir,
1057        accessible_workdir,
1058        manifest_dir: request.manifest_dir,
1059        capsule_name: request.capsule_name,
1060        capsule_version: request.capsule_version,
1061        capsule_url: String::new(), // set by launch_session after port binding
1062        resolved_lock_artifacts,
1063        installed_artifacts,
1064        inference: request.inference,
1065        inference_gateway,
1066        spend,
1067        system_prompt_overridden: request.system_prompt_overridden,
1068        context: request.context,
1069        context_id: request.context_id,
1070        resume: request.resume,
1071        engine,
1072        capsule_component,
1073        tool_components,
1074        artifact_grants,
1075        hook_components,
1076        allowlisted_tools: request.allowlisted_tools,
1077        // The combined floor (manifest + workspace config + `--containment`) replaces the
1078        // manifest-only value the policy was built with, so every later reader — including
1079        // `ShellEnforcement::resolve`, which decides whether this session installs a composed
1080        // root — sees the class that was actually asked for.
1081        capability_policy: CapabilityPolicy {
1082            containment_floor: request.declared_containment_floor,
1083            ..request.capability_policy
1084        },
1085        otel_endpoint: request.otel_endpoint,
1086        eval_config_json: request.eval_config_json,
1087        case_id: request.case_id,
1088        dataset_id: request.dataset_id,
1089        lifecycle,
1090        trace_capture: request
1091            .trace
1092            .as_ref()
1093            .map(|t| t.capture)
1094            .unwrap_or_default(),
1095        trace_retain: request.trace.as_ref().and_then(|t| t.retain),
1096        host_probe,
1097        protected_paths,
1098        tool_annotations,
1099        bind_addr: request.bind_addr,
1100        internal_port: request.internal_port,
1101        declared_containment_floor: request.declared_containment_floor,
1102        scope_report,
1103        exports_files,
1104        exports_peer_files,
1105        registry,
1106        _epoch_ticker: epoch_ticker,
1107        // Minted by the daemon at registration, which `launch_session` performs — it names a
1108        // session id, and staging is what mints one.
1109        spawn_credential: None,
1110        spawn_grant: request.spawn_grant,
1111        // The one read of `MURMUR_SPAWNER` inside a session. `mur run` validates it earlier and
1112        // discards the value, so a handle that cannot be read refuses the launch before a session
1113        // directory exists; this is where the value itself enters the session.
1114        spawner: SpawnerHandle::from_env()?,
1115    })
1116}
1117
1118/// Instantiates the staged capsule and executes it.
1119///
1120/// Agent capsules (inference configured) run the built-in native Rust loop.
1121/// Script capsules (WASM component present) instantiate and call `murmur:capsule/run#run()`.
1122///
1123/// Leaves the process's `SIGTERM` disposition alone: a caller that runs several sessions in one
1124/// process, or does more work after this returns, keeps the default of ending at once. A caller
1125/// whose process is the session uses [`launch_session_handling_sigterm`] instead.
1126pub fn launch_session(
1127    staged: StagedSession,
1128    on_url: impl FnOnce(&str),
1129) -> Result<LaunchResult, RuntimeError> {
1130    launch(staged, on_url, false)
1131}
1132
1133/// [`launch_session`] for a process whose lifetime is this one session.
1134///
1135/// On unix an agent session takes `SIGTERM` over for the rest of the process: the first signal
1136/// cancels every live task and ends the session through its normal teardown, a second exits at
1137/// once with status 143, and [`TERMINATE_TEARDOWN_DEADLINE`] after the first the process exits
1138/// with status 143 whatever it is doing. Those exits end the whole process, so nothing that
1139/// outlives the session may share it. A script capsule, or a platform without `SIGTERM`, launches
1140/// exactly as [`launch_session`] does.
1141pub fn launch_session_handling_sigterm(
1142    staged: StagedSession,
1143    on_url: impl FnOnce(&str),
1144) -> Result<LaunchResult, RuntimeError> {
1145    launch(staged, on_url, true)
1146}
1147
1148fn launch(
1149    mut staged: StagedSession,
1150    on_url: impl FnOnce(&str),
1151    handle_sigterm: bool,
1152) -> Result<LaunchResult, RuntimeError> {
1153    let network_allow_rules = parse_network_allow_rules(&staged.capability_policy.network_allow)?;
1154
1155    // Before any WASM is instantiated and before any subprocess is bounded: a session that can
1156    // delegate announces itself to the daemon that will referee those delegations, and takes the
1157    // credential it will present. A session that cannot delegate does none of this — it opens no
1158    // connection, needs no daemon, and is unaffected by there being none.
1159    let mut roost_session = RoostSession::register(&mut staged)?;
1160
1161    // Beside the registration and on the same terms: a session that was delegated to must be
1162    // able to say how it ended, so the handle is read before anything is instantiated and the
1163    // report is a guard rather than a line at each success return. A session nobody delegated
1164    // reads an absent variable and does nothing further.
1165    let mut delegation = DelegationReport::open(&staged)?;
1166
1167    // --- Host-process bounding, before any WASM is instantiated ------------------------------
1168    //
1169    // A capsule that can reach a native subprocess by any route (`shell.allow`, `spawn.allow`,
1170    // or a native-implementation artifact) needs a cgroup scope around that process tree. On
1171    // Linux, failing to get one is fatal here — refusing the launch is strictly better than
1172    // running the tree with no aggregate memory/pids/cpu ceiling, and it must happen before
1173    // instantiation so no subprocess is ever spawned unbounded. Off Linux there is no cgroup to
1174    // get, so `prepare_scope` returns `None` and the gap is reported as `W-SEC-010` instead.
1175    let has_native_artifact = staged.installed_artifacts.iter().any(|artifact| {
1176        matches!(
1177            artifact.implementation,
1178            Some(murmur_artifact::ArtifactImplementation::Native)
1179        )
1180    });
1181    let requires_process_bounding =
1182        cgroup::requires_process_bounding(&staged.capability_policy, has_native_artifact);
1183    let prepared_scope = cgroup::prepare_scope(
1184        requires_process_bounding,
1185        &staged.capability_policy.resources,
1186        &staged.session_id,
1187        &staged.workdir,
1188    )
1189    .map_err(|reason| RuntimeError::CgroupDelegationUnavailable { reason })?;
1190    // Written into the staged report here, immediately after the write that decided it and ahead
1191    // of every `staged.scope_report.clone()` that feeds a `session_start` event — the agent path
1192    // and the script path both clone it later in this function. `stage_session` could not fill it:
1193    // the answer does not exist until the scope has been created.
1194    staged.scope_report.io_max = prepared_scope.io_max.clone();
1195    // `io.max` is the one cgroup limit a host is not refused for, so a declared ceiling that did
1196    // not apply has to be *said* here or the manifest and the scope report both go on implying it.
1197    cgroup::warn_for_unenforced_io_max(&staged.workdir, &prepared_scope.io_max);
1198    let cgroup_scope = prepared_scope.scope;
1199    let workdir_guard = Some(resources::WorkdirGuard::spawn(
1200        &staged.workdir,
1201        staged.capability_policy.resources.workdir_max_bytes,
1202    ));
1203
1204    let shell_enforcement = sandbox::ShellEnforcement::resolve(
1205        &staged.capability_policy,
1206        staged.declared_containment_floor,
1207        staged.host_probe,
1208    )
1209    .map_err(RuntimeError::Runtime)?
1210    .with_host_bounding(cgroup_scope, workdir_guard);
1211    let inference_env = staged
1212        .inference
1213        .as_ref()
1214        .map(|inference| inference_env_pairs(inference, staged.inference_gateway.as_deref()))
1215        .unwrap_or_default();
1216    let inference_gateway = staged.inference_gateway.clone();
1217    let spend = Arc::clone(&staged.spend);
1218
1219    if let Some(ref inference) = staged.inference {
1220        let workdir = staged.workdir.clone();
1221        let system_prompt = resolve_system_prompt(&staged.manifest_dir, &workdir, inference)?;
1222        let compaction_system_prompt =
1223            resolve_compaction_system_prompt(&staged.manifest_dir, inference.compaction.as_ref())?;
1224        let session_id = staged.session_id.clone();
1225        let accessible_workdir = staged.accessible_workdir.clone();
1226        let context_window = resolve_context_window(staged.context.as_ref());
1227        let seed_budget = staged
1228            .context
1229            .as_ref()
1230            .map(|c| c.seed_budget)
1231            .unwrap_or(murmur_artifact::DEFAULT_SEED_BUDGET);
1232        // Computed once for the whole launch: the ceiling depends only on the manifest, and
1233        // every `on-task-start` in the session is measured against the same number.
1234        let seed_budget_tokens = agent::seed_budget_tokens(context_window, seed_budget);
1235
1236        let run_config = agent::AgentRunConfig {
1237            context_window,
1238            compaction_threshold: inference
1239                .compaction
1240                .as_ref()
1241                .and_then(|c| c.threshold)
1242                .unwrap_or(0.98),
1243            compaction_model: inference.compaction.as_ref().and_then(|c| c.model.clone()),
1244            compaction_system_prompt,
1245            compaction_dump_summaries: inference
1246                .compaction
1247                .as_ref()
1248                .and_then(|c| c.dump_summaries)
1249                .unwrap_or(false),
1250            max_output_tokens: inference
1251                .max_tokens
1252                .unwrap_or(agent::DEFAULT_MAX_OUTPUT_TOKENS),
1253            seed_budget,
1254            seed_overflow_margin: staged
1255                .context
1256                .as_ref()
1257                .map(|c| c.seed_overflow_margin)
1258                .unwrap_or(murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN),
1259            conversation_root: resolve_conversation_root(
1260                staged.context.as_ref(),
1261                &staged.capsule_name,
1262                inference,
1263                &workdir,
1264            ),
1265            // Ownership is claimed only by a capsule that declares a record policy: without one
1266            // there is nothing retention needs the header for, and an upgrading capsule's record
1267            // keeps the exact bytes it had.
1268            record_owner: staged
1269                .context
1270                .as_ref()
1271                .and_then(|context| context.retain)
1272                .map(|_| staged.capsule_name.clone()),
1273            resume: staged.resume.as_ref().map(|resume| resume.mode),
1274        };
1275
1276        // --- Identity and HTTP server setup ---
1277
1278        let rt = tokio::runtime::Builder::new_multi_thread()
1279            .enable_all()
1280            .build()
1281            .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
1282
1283        let (tcp_listener, external_port) = rt.block_on(identity::bind_local_port(
1284            &staged.bind_addr,
1285            staged.internal_port,
1286        ))?;
1287
1288        let capsule_url = format!("localhost:{external_port}");
1289        staged.capsule_url = capsule_url.clone();
1290
1291        // Built while `staged` is still whole. It is written, and the URL announced, inside the
1292        // task `LocalSet` below, once the door is being served.
1293        let running_record = running_record_for(&staged, &session_id, &capsule_url);
1294
1295        // Taken over from the default disposition, when the caller owns the process, before the
1296        // door is announced, so a `SIGTERM` sent by anyone who has seen the URL is held for the
1297        // task loop's handler rather than ending the process with no teardown. A signal that
1298        // arrives before the handler is spawned is delivered to it then.
1299        #[cfg(not(unix))]
1300        let _ = handle_sigterm;
1301        #[cfg(unix)]
1302        let sigterm = if !handle_sigterm {
1303            None
1304        } else {
1305            let _runtime = rt.enter();
1306            match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
1307                Ok(sigterm) => Some(sigterm),
1308                Err(e) => {
1309                    eprintln!(
1310                        "[capsule-runtime] could not install a SIGTERM handler; SIGTERM will end this session without its teardown: {e}"
1311                    );
1312                    None
1313                }
1314            }
1315        };
1316
1317        let capsule_identity = CapsuleIdentity {
1318            capsule_name: staged.capsule_name.clone(),
1319            capsule_version: staged.capsule_version.clone(),
1320            session_id: session_id.clone(),
1321            capsule_url: capsule_url.clone(),
1322        };
1323
1324        // Inject identity env vars into all WASI contexts for this session.
1325        let mut all_env = inference_env.clone();
1326        all_env.push((
1327            "MURMUR_CAPSULE_NAME".to_string(),
1328            staged.capsule_name.clone(),
1329        ));
1330        all_env.push((
1331            "MURMUR_CAPSULE_VERSION".to_string(),
1332            staged.capsule_version.clone(),
1333        ));
1334        all_env.push(("MURMUR_SESSION_ID".to_string(), session_id.clone()));
1335        all_env.push(("MURMUR_CAPSULE_URL".to_string(), capsule_url.clone()));
1336
1337        murmur_md::write_murmur_md(
1338            &workdir,
1339            Some(inference),
1340            staged.context.as_ref(),
1341            has_compaction_hook(&staged.hook_components),
1342            &staged.capability_policy,
1343            &capsule_identity,
1344        );
1345
1346        sandbox::warn_for_enforcement_tier(
1347            shell_enforcement.tier,
1348            &workdir,
1349            &staged.capability_policy,
1350        );
1351        sandbox::warn_for_missing_aggregate_bounding(
1352            &workdir,
1353            requires_process_bounding,
1354            shell_enforcement.cgroup_scope.is_some(),
1355        );
1356        warn_for_unreachable_delegation_outcomes(
1357            &workdir,
1358            !staged.capability_policy.spawn_allow.is_empty(),
1359            &staged.lifecycle,
1360        );
1361        warn_for_unreachable_shell_completions(
1362            &workdir,
1363            !staged.capability_policy.shell_allow.is_empty(),
1364            &staged.lifecycle,
1365        );
1366
1367        let agent_card = identity::build_agent_card(
1368            &capsule_identity,
1369            &staged.installed_artifacts,
1370            &staged.capability_policy,
1371        );
1372        let agent_card_json = agent_card.to_string();
1373
1374        // --- Lifecycle config ---
1375        let effective_lifecycle = staged.lifecycle.clone();
1376        let conversation_mode = effective_lifecycle.conversation_mode.clone();
1377        // `mur run --context <id>`: the id every `task.md` task of this launch runs under, so two
1378        // runs given the same one share one conversation record. Validated at staging; `None`
1379        // mints a fresh id per task, as it always has.
1380        let supplied_context_id = staged.context_id.clone();
1381        // Provenance for `session_start`, taken before `staged.resume` is consumed below: which
1382        // session this launch continues, and the launch-scoped context it runs under. Both are
1383        // `None` on an ordinary launch, and `context_id` is `None` whenever each task mints its
1384        // own — `task_start` carries the id a task actually ran under either way.
1385        let trace_resumed_from = staged
1386            .resume
1387            .as_ref()
1388            .map(|resume| resume.from_session.clone());
1389        // The same value, read again below by the reconciliation step, which needs the directory
1390        // name after `TraceWriter::open` has taken the one above.
1391        let reconcile_from_session = trace_resumed_from.clone();
1392        let trace_context_id = supplied_context_id.clone();
1393        // Lineage for `session_start`, from the handle the spawning capsule's launcher injected.
1394        // Both `None` for a capsule nobody delegated, and both omitted from the record then.
1395        let trace_spawned_by = staged
1396            .spawner
1397            .as_ref()
1398            .map(|handle| handle.session_id.clone());
1399        let trace_spawn_delegation_id = staged
1400            .spawner
1401            .as_ref()
1402            .map(|handle| handle.delegation_id.clone());
1403        let queue_capacity = match effective_lifecycle.task_acceptance {
1404            TaskAcceptance::Queue => effective_lifecycle.queue_depth,
1405            _ => 1,
1406        };
1407
1408        // --- A2A task registry and incoming channel ---
1409        let task_registry: Arc<Mutex<TaskRegistry>> = Arc::new(Mutex::new(TaskRegistry::new(
1410            effective_lifecycle.queue_depth,
1411            effective_lifecycle.task_acceptance.clone(),
1412        )));
1413        let (task_tx, mut task_rx) = tokio::sync::mpsc::channel::<IncomingTask>(queue_capacity);
1414
1415        // Demoted shell commands and the channel their completions come back on. Unbounded, so
1416        // the OS thread running a detached command never blocks handing its result over.
1417        let (detached, mut completion_rx) = DetachedRegistry::new();
1418
1419        // Delegations in flight. Built here rather than with the store state because the A2A door
1420        // reads it too: a cancel names every child still running, and the door is spawned first.
1421        let live_delegations = Arc::new(crate::cancel::LiveDelegations::new());
1422
1423        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1424
1425        // SSE broadcast channel and replay buffer for SSE clients
1426        let (sse_tx, _) =
1427            tokio::sync::broadcast::channel::<std::sync::Arc<String>>(SSE_BROADCAST_CAPACITY);
1428        let sse_buffer = std::sync::Arc::new(Mutex::new(SseEventBuffer::new(SSE_REPLAY_CAPACITY)));
1429
1430        let capsule_name = staged.capsule_name.clone();
1431        let capabilities = capability_names(&staged.capability_policy);
1432        // Computed once at stage time, not re-derived here: `session_start` records what this
1433        // session ran with, and the staged report is the single place that value already lives.
1434        // It also carries the declared/achieved classes and `workdir_exec` the event's own
1435        // top-level fields are written from.
1436        let effective_grants = staged.scope_report.clone();
1437        // The resource plane is built from a host path, a declared export, an achieved class, a
1438        // counter and a trace handle — nothing the agent loop owns and nothing a completed task
1439        // leaves behind. That is what a later reader-only launch mode over an existing workdir
1440        // would need, and no more.
1441        let exports_files = staged.exports_files.clone();
1442        let resource_containment = staged.scope_report.achieved_containment;
1443        let resource_accessible_workdir = accessible_workdir.clone();
1444
1445        // The peer plane's minting key: 32 random bytes, generated here and only when
1446        // `exports.peer_files` is declared, held in memory for this session and destroyed with it.
1447        // Never written to disk and never placed in an environment variable — teardown is the
1448        // revocation mechanism, so there must be nothing left to reload.
1449        let exports_peer_files = staged.exports_peer_files.clone();
1450        let peer_mint_key = match exports_peer_files {
1451            Some(_) => Some(std::sync::Arc::new(
1452                crate::peer_handoff::PeerMintKey::generate().map_err(RuntimeError::Runtime)?,
1453            )),
1454            None => None,
1455        };
1456        // Both sides derive the audience from the fetching capsule's own advertised identity, so
1457        // what this capsule asserts on a redeem it issues is the same string a peer would read
1458        // off the card it publishes.
1459        let own_peer_audience = crate::peer_handoff::own_audience(&capsule_identity);
1460        let peer_fetch_rules =
1461            parse_network_allow_rules(&staged.capability_policy.peer_fetch_allow)?;
1462
1463        // The delegating side of the same registration `RoostSession` opened. Built for every
1464        // registered session, because the credential is what a delegation is made with and a
1465        // session holds one exactly when the daemon minted it — the `delegate-task` manifest was
1466        // written from the same declaration, so the tool and the plane appear together.
1467        let delegation_plane = roost_session.endpoint().map(|(roost_url, credential)| {
1468            std::sync::Arc::new(
1469                crate::delegation_plane::DelegationPlane::new(
1470                    roost_url.to_string(),
1471                    credential,
1472                    accessible_workdir.clone(),
1473                    session_id.clone(),
1474                    std::time::Duration::from_secs(effective_lifecycle.delegation_deadline_secs),
1475                    Arc::clone(&staged.registry),
1476                    staged.capability_policy.env_allow.clone(),
1477                )
1478                // This capsule's own A2A door, bound just above: where a started delegation's
1479                // completion is posted, and what makes `DelegationPlane::start` available at all.
1480                .reporting_to(format!("http://{capsule_url}")),
1481            )
1482        });
1483
1484        // Capture staged fields that move into the async block
1485        let hook_components = staged.hook_components;
1486        let tool_components = staged.tool_components;
1487        let artifact_grants = staged.artifact_grants;
1488        let allowlisted_tools = staged.allowlisted_tools.clone();
1489        let installed_artifacts = staged.installed_artifacts;
1490        let engine = staged.engine.clone();
1491        let capability_policy = staged.capability_policy.clone();
1492        let protected_paths = staged.protected_paths.clone();
1493        let tool_annotations = staged.tool_annotations.clone();
1494        let shell_enforcement_for_state = shell_enforcement.clone();
1495        let otel_endpoint = staged.otel_endpoint;
1496        let eval_config_json = staged.eval_config_json;
1497        let case_id = staged.case_id;
1498        let dataset_id = staged.dataset_id;
1499        let capsule_version = staged.capsule_version.clone();
1500        let inference_model = inference.model.clone();
1501        // Non-empty only for the WASM-driver transport; a `transport: process`
1502        // capsule has no driver artifact, so a hook's `run-inference` correctly
1503        // reports "no driver configured" rather than silently doing nothing.
1504        let inference_driver_name = inference
1505            .driver
1506            .as_ref()
1507            .map(|d| d.artifact.clone())
1508            .filter(|a| !a.is_empty());
1509        let trace_capture = staged.trace_capture;
1510        // Retention inputs, taken before `staged` and `run_config` are moved into the agent loop.
1511        // Both are `None` on a capsule that declared no `retain:` block, and `None` deletes
1512        // nothing, ever.
1513        let trace_retain = staged.trace_retain;
1514        let context_retain = staged.context.as_ref().and_then(|context| context.retain);
1515        let retention_conversation_root = run_config.conversation_root.clone();
1516        // The trace records the *resolved* prompt — what `resolve_system_prompt` returned, before
1517        // `build_augmented_system_prompt` prepends the `[Capsule]` block — so a reader compares
1518        // what the manifest (or `--system-prompt`) actually said, not the runtime's framing of it.
1519        let trace_system_prompt = system_prompt.clone();
1520        let system_prompt_overridden = staged.system_prompt_overridden;
1521        let registry_for_pull = Arc::clone(&staged.registry);
1522        let lock_path_for_pull = staged.manifest_dir.join("murmur.lock");
1523
1524        // task.md must live where the agent's own tools are preopened (accessible_workdir),
1525        // not the internal `.murmur/<session_id>` bookkeeping dir (workdir) — otherwise this
1526        // pre-seed check misses a `--task`-written file and the agent's own read of task.md
1527        // 404s even after a task was delivered.
1528        let workdir_task_md = accessible_workdir.join("task.md");
1529
1530        // Extra copies retained for the LaunchResult returned after block_on consumes the others.
1531        let session_id_ret = session_id.clone();
1532        let workdir_ret = workdir.clone();
1533
1534        // --- Agent loop inside a LocalSet (handles !Send WasiCtx) ---
1535        let loop_result: Result<(), RuntimeError> = rt.block_on(async move {
1536            let mut trace = TraceWriter::open(
1537                &workdir,
1538                session_id.clone(),
1539                capsule_name.clone(),
1540                capsule_version.clone(),
1541                inference_model.clone(),
1542                capabilities.clone(),
1543                effective_grants,
1544                trace_capture,
1545                trace_system_prompt,
1546                system_prompt_overridden,
1547                trace_resumed_from,
1548                trace_context_id,
1549                trace_spawned_by,
1550                trace_spawn_delegation_id,
1551            )
1552            .await
1553            .map_err(|e| RuntimeError::AgentLoopFailed(format!("failed to open trace.jsonl: {e}")))?;
1554            trace.set_spend_ceilings(spend.session_ceiling(), spend.machine_ceiling());
1555
1556            // The session frame is written once per launch, around the task loop, so it frames
1557            // the `on-session-start`/`on-session-end` hook pair. It goes in before anything
1558            // else can write to the file: `session_start`'s `event_id` is the root of the trace's
1559            // event tree, and the resource plane — opened next, and served concurrently from the
1560            // moment the listener accepts — names that id as its `parent_id` on every line.
1561            //
1562            // Both transports derive `tools_declared` from this same inventory, so one call
1563            // site serves both.
1564            let tools_declared: Vec<String> =
1565                agent::inventory::build_tool_inventory(&workdir, inference.system_prompt_artifact.as_deref())
1566                    .iter()
1567                    .filter_map(|t| t.get("name").and_then(serde_json::Value::as_str))
1568                    .map(str::to_string)
1569                    .collect();
1570            let inference_credential = inference_gateway
1571                .as_ref()
1572                .and_then(|gateway| gateway.credential())
1573                .cloned();
1574            trace.set_credential_source(
1575                inference_credential
1576                    .as_ref()
1577                    .map_or("none", |credential| credential.source().trace_name()),
1578            );
1579            trace
1580                .write_session_start(inference.max_turns, tools_declared)
1581                .await
1582                .map_err(|e| RuntimeError::AgentLoopFailed(format!("trace write failed: {e}")))?;
1583
1584            // Retention runs here and nowhere else: the session node exists from the line above,
1585            // so every deletion has a parent to hang off, and a policy that only runs when an
1586            // operator remembers to invoke a command does not run.
1587            apply_retention(
1588                &mut trace,
1589                &workdir,
1590                &session_id,
1591                trace_retain.as_ref(),
1592                context_retain.as_ref(),
1593                retention_conversation_root.as_deref(),
1594                &capsule_name,
1595                supplied_context_id.as_deref(),
1596            )
1597            .await;
1598
1599            // A second handle to the same trace.jsonl, not a borrow of the writer above: the
1600            // motivating read happens after `session_end`, when the agent loop's writer is gone.
1601            // A trace that cannot be opened must not make the plane unserveable — the read is
1602            // still refused or served correctly, it is only unrecorded.
1603            let resource_trace = crate::trace::ResourceTraceAppender::open(
1604                &workdir,
1605                session_id.clone(),
1606                trace.session_event_id().to_string(),
1607            )
1608            .await
1609            .ok()
1610            .map(std::sync::Arc::new);
1611            // The credential records a rotation or rejection through the same kind of handle, and
1612            // for the same reason: the gateway sends from a task the loop's writer cannot reach,
1613            // and the record should land when the request does.
1614            if let (Some(credential), Some(appender)) = (&inference_credential, &resource_trace) {
1615                credential.attach_trace(Arc::clone(appender));
1616            }
1617            // The same file again, for the same reason and on the same terms: a plan's steps run
1618            // on blocking threads, so they cannot be lent the agent loop's own writer. A trace
1619            // that cannot be opened leaves a plan unrecorded and still runs it.
1620            let plan_trace = crate::trace::PlanTraceAppender::open(
1621                &workdir,
1622                session_id.clone(),
1623                trace.session_event_id().to_string(),
1624            )
1625            .ok()
1626            .map(std::sync::Arc::new);
1627            let resource_plane = std::sync::Arc::new(crate::resource_plane::ResourcePlane::new(
1628                &resource_accessible_workdir,
1629                exports_files.as_ref(),
1630                resource_containment,
1631                task_registry.lock().unwrap().resource_generation(),
1632                resource_trace.clone(),
1633            ));
1634            // Always built, declared or not: an undeclared capsule still has to record the redeem
1635            // it refused, and its declared half is `None` only because there is nothing to serve.
1636            // The key exists exactly when the export does — both come from the same declaration.
1637            let peer_plane = std::sync::Arc::new(crate::peer_handoff::PeerPlane::new(
1638                &resource_accessible_workdir,
1639                exports_peer_files
1640                    .as_ref()
1641                    .zip(peer_mint_key.as_ref())
1642                    .map(|(export, key)| (export, std::sync::Arc::clone(key))),
1643                session_id.clone(),
1644                resource_containment,
1645                task_registry.lock().unwrap().resource_generation(),
1646                resource_trace.clone(),
1647            ));
1648
1649            let mut otel = OtelEmitter::new(
1650                otel_endpoint.clone(),
1651                &workdir,
1652                capsule_name.clone(),
1653                capsule_version.clone(),
1654            );
1655
1656            let local = tokio::task::LocalSet::new();
1657            local
1658                .run_until(async move {
1659                    // Spawn HTTP server as a local task (uses spawn_local for !Send compat)
1660                    let server_handle =
1661                        tokio::task::spawn_local(identity::serve_http(
1662                            tcp_listener,
1663                            shutdown_rx,
1664                            agent_card_json,
1665                            Arc::clone(&task_registry),
1666                            task_tx,
1667                            effective_lifecycle.task_acceptance.clone(),
1668                            sse_tx.clone(),
1669                            Arc::clone(&sse_buffer),
1670                            conversation_mode.clone(),
1671                            std::sync::Arc::clone(&resource_plane),
1672                            std::sync::Arc::clone(&peer_plane),
1673                            session_id.clone(),
1674                            Some(Arc::clone(&detached)),
1675                            Arc::clone(&live_delegations),
1676                        ));
1677
1678                    // Read before `capability_policy` moves into the store state below. Hooks
1679                    // get the same resource caps as every other guest but their own, lower
1680                    // deadline default — see `CapabilityPolicy::hook_limits`.
1681                    let hook_limits = capability_policy.hook_limits();
1682
1683                    // Build CapsuleStoreState ONCE — reused across all task iterations
1684                    let mut state = CapsuleStoreState {
1685                        // Agent capsules have no WASM component of their own, so this state
1686                        // never backs a `Store` and this limiter is never registered. It is
1687                        // the per-tool limiters built in `dispatch_tool_async` (from
1688                        // `capability_policy.limits`) that bound this path's guests.
1689                        limits: capability_policy.limits.limiter(),
1690                        table: ResourceTable::new(),
1691                        // The capsule's own store is the ceiling itself, never narrowed:
1692                        // per-artifact grants apply to staged tools/drivers, not to the
1693                        // agent loop's own context.
1694                        wasi: build_wasi_ctx(
1695                            &accessible_workdir,
1696                            None,
1697                            // Nor does a state store: it too is granted per artifact, and the
1698                            // capsule holds no artifact grant.
1699                            None,
1700                            // Nor a config block, for the same reason — `config:` is declared on
1701                            // an artifact entry and the capsule is not one.
1702                            None,
1703                            &all_env,
1704                            &capability_policy,
1705                        )?,
1706                        http: WasiHttpCtx::new(),
1707                        http_hooks: NetworkPolicyHooks {
1708                            network_allow_rules: network_allow_rules.clone(),
1709                            inference_gateway: None,
1710                        },
1711                        network_allow_rules,
1712                        peer_fetch_rules,
1713                        peer_plane: Some(std::sync::Arc::clone(&peer_plane)),
1714                        peer_own_audience: own_peer_audience,
1715                        peer_trace: resource_trace,
1716                        delegation: delegation_plane,
1717                        plan_trace,
1718                        plan_counter: AtomicU64::new(0),
1719                        inference_env: all_env,
1720                        inference_gateway,
1721                        spend,
1722                        engine: engine.clone(),
1723                        workdir: workdir.clone(),
1724                        accessible_workdir: accessible_workdir.clone(),
1725                        tool_components,
1726                        artifact_grants,
1727                        allowlisted_tools,
1728                        installed_artifacts,
1729                        session_id: session_id.clone(),
1730                        pending_a2a_events: Vec::new(),
1731                        capability_policy,
1732                        protected_paths,
1733                        tool_annotations,
1734                        shell_enforcement: shell_enforcement_for_state,
1735                        current_traceparent: None,
1736                        current_task_provenance: None,
1737                        current_context_id: None,
1738                        live_delegations: Arc::clone(&live_delegations),
1739                        detached: Some(Arc::clone(&detached)),
1740                        shell_grace_secs: effective_lifecycle.shell_grace_secs,
1741                        a2a_task_registry: Some(Arc::clone(&task_registry)),
1742                        a2a_sse: Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
1743                        a2a_task_id: None,
1744                        input_timeout_secs: effective_lifecycle.input_timeout_secs,
1745                        a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
1746                        a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
1747                        registry: registry_for_pull,
1748                        lock_path: lock_path_for_pull,
1749                        driver_continuation_id: None,
1750                        driver_continuation_context_id: None,
1751                        driver_continuation_acked_len: 0,
1752                    };
1753
1754                    // Backing for the hooks' `murmur:runtime/inference` import.
1755                    // Sourced from `state` (built directly above) so a hook's
1756                    // `run-inference` runs the *same* driver component, under the
1757                    // same capability policy and network allowlist, as the agent
1758                    // loop's own turns. `None` when no driver artifact is staged.
1759                    let hook_inference = inference_driver_name
1760                        .as_ref()
1761                        .and_then(|driver_name| {
1762                            state
1763                                .tool_components
1764                                .get(driver_name)
1765                                .map(|component| (driver_name.clone(), component.clone()))
1766                        })
1767                        .map(|(driver_name, driver_component)| {
1768                            // Same grant `dispatch_tool_async` would apply to this driver, so
1769                            // a hook's `run-inference` cannot route around its narrowing.
1770                            let driver_grant = state.artifact_grants.get(&driver_name).cloned();
1771                            Arc::new(HookInferenceCtx {
1772                                driver_name,
1773                                driver_component,
1774                                model: inference_model.clone(),
1775                                engine: state.engine.clone(),
1776                                accessible_workdir: state.accessible_workdir.clone(),
1777                                workdir: state.workdir.clone(),
1778                                inference_env: state.inference_env.clone(),
1779                                capability_policy: state.capability_policy.clone(),
1780                                network_allow_rules: state.network_allow_rules.clone(),
1781                                driver_grant,
1782                                inference_gateway: state.inference_gateway.clone(),
1783                                spend: Arc::clone(&state.spend),
1784                                records: std::sync::Mutex::new(Vec::new()),
1785                                spend_refusals: std::sync::Mutex::new(Vec::new()),
1786                            })
1787                        });
1788
1789                    // Create hooks ONCE — session_start fires once per capsule lifetime
1790                    let mut hooks = HookRuntime::new(
1791                        &engine,
1792                        &workdir,
1793                        &accessible_workdir,
1794                        hook_components,
1795                        SessionContextData {
1796                            capsule_name: capsule_name.clone(),
1797                            capsule_version: capsule_version.clone(),
1798                            session_id: session_id.clone(),
1799                            model: inference_model.clone(),
1800                            capabilities: capabilities.clone(),
1801                        },
1802                        HookEnvVars {
1803                            otel_endpoint: otel_endpoint.as_deref(),
1804                            eval_config_json: eval_config_json.as_deref(),
1805                            case_id: case_id.as_deref(),
1806                            dataset_id: dataset_id.as_deref(),
1807                        },
1808                        hook_limits,
1809                        hook_inference,
1810                        run_config.conversation_root.clone(),
1811                    )
1812                    .await?;
1813
1814                    // on-session-start fires ONCE per launch, before the task loop —
1815                    // regardless of task_acceptance. For queue capsules this is the single
1816                    // session boundary that the per-task on-task-start events nest inside.
1817                    hooks.emit(&workdir, HookEvent::SessionStart).await;
1818
1819                    // The session is addressable from here, and this is where it says so. A
1820                    // caller that sees the URL reads the record and then probes the door, so the
1821                    // two halves of the order are both load-bearing:
1822                    //
1823                    //   - the record exists before `on_url` announces the URL, or `mur ps` run
1824                    //     on the announcement finds nothing to list;
1825                    //   - the URL is announced only once `serve_http` is spawned on this
1826                    //     `LocalSet` and `session_start` is in the trace. The listener's backlog
1827                    //     accepts a connection from the moment it is bound, and until
1828                    //     `serve_http` is polled nothing reads it, so a probe sent into that
1829                    //     window waits out its read deadline and reports a live session as
1830                    //     unreachable. `serve_http` is first polled at the next `.await`, the
1831                    //     task loop's wait for work at the latest.
1832                    //
1833                    // A guard rather than a line at each return, bound in this future so it lives
1834                    // until the task loop has ended: a record that outlived its session would
1835                    // resolve an address onto a port nothing holds. Only the agent path reaches
1836                    // here; a script capsule binds nothing and is never addressable.
1837                    let _running_record = write_running_record(&running_record, &workdir);
1838                    on_url(&capsule_url);
1839
1840                    // Seeded rather than assigned on every exit path: an iteration that only
1841                    // closes out work already in a lane can end without a result of its own, and
1842                    // then the last real task's outcome is the launch's outcome.
1843                    let mut final_loop_result: Result<AgentLoopExit, RuntimeError> =
1844                        Ok(AgentLoopExit::Ok);
1845
1846                    // Set once the loop has stopped taking new work and is running only what is
1847                    // already in a lane. A task the runtime generated for itself never crossed
1848                    // the peer door, so `task_acceptance` does not gate it: without this,
1849                    // `single` and `none` would run the `task.md` task and leave a reconciled
1850                    // loss report unread with its marker already written, which is the one
1851                    // outcome reconciliation exists to prevent. Neither channel is read here, so
1852                    // nothing new can arrive and the loop still ends after one pass.
1853                    let mut closing_out = false;
1854
1855                    // Tasks taken off the channel but not yet started. It outlives one iteration
1856                    // because a task drained while another was running has to still be here when
1857                    // that one finishes.
1858                    let mut lanes = LaneQueue::new();
1859
1860                    // Event ids for the final status a task cancelled before it started emits.
1861                    // High, like `request-input`'s, so they never collide with the agent loop's
1862                    // own per-attempt counter, which restarts at 0 for every task.
1863                    let mut queued_cancel_event_id: u64 = u64::MAX / 8;
1864
1865                    // Demoted commands the resumed-from session never accounted for. Only a
1866                    // resume does this, and it costs one read of a file `--resume` has already
1867                    // read; a launch that resumes nothing does no work here at all.
1868                    //
1869                    // An unmatched `shell_detached` can only mean the teardown sweep below never
1870                    // ran, because that sweep writes `shell_abandoned` for everything outstanding
1871                    // on every clean exit. The one over-report is a graceful exit whose own
1872                    // `write_shell_abandoned` failed, which reads here as unplanned death; for
1873                    // accounting that is the right direction to be wrong in.
1874                    if let (Some(from_session), Some(sessions_root)) =
1875                        (reconcile_from_session.as_deref(), workdir.parent())
1876                    {
1877                        if let Some(report) =
1878                            crate::detached_reconcile::reconcile_prior_session(
1879                                sessions_root,
1880                                from_session,
1881                                &session_id,
1882                                &task_context_id(supplied_context_id.as_deref()),
1883                            )
1884                            .await
1885                        {
1886                            enqueue_detached_report(
1887                                DetachedReport::Lost(report),
1888                                &task_registry,
1889                                &mut lanes,
1890                                &mut trace,
1891                            )
1892                            .await;
1893                        }
1894                    }
1895
1896                    // Raised once, by the first `SIGTERM`. Every wait below that is not already a
1897                    // task's own cancel races it, and the loop takes no new work once it is up, so
1898                    // the session falls through to the same teardown a clean exit runs.
1899                    let terminating = crate::cancel::CancelSignal::new();
1900                    #[cfg(unix)]
1901                    if let Some(mut sigterm) = sigterm {
1902                        let task_registry = Arc::clone(&task_registry);
1903                        let terminating = terminating.clone();
1904                        tokio::spawn(async move {
1905                            if sigterm.recv().await.is_none() {
1906                                return;
1907                            }
1908                            // Cancelled before the signal is raised, so an agent loop that wakes
1909                            // on either finds its task already `Canceled`.
1910                            let _ = task_registry.lock().unwrap().cancel_every_live();
1911                            terminating.cancel();
1912                            eprintln!(
1913                                "[capsule-runtime] SIGTERM received — cancelling live tasks and ending the session"
1914                            );
1915                            std::thread::spawn(|| {
1916                                std::thread::sleep(TERMINATE_TEARDOWN_DEADLINE);
1917                                std::process::exit(143);
1918                            });
1919                            if sigterm.recv().await.is_some() {
1920                                std::process::exit(143);
1921                            }
1922                        });
1923                    }
1924
1925                    // ── LOOP BODY STARTS HERE ──────────────────────────────
1926                    // Each iteration processes one task. Single/none modes break after
1927                    // the first iteration; queue+sleep iterates until channel closes.
1928                    'task_loop: loop {
1929                        if terminating.is_canceled() {
1930                            close_lanes_on_termination(
1931                                &mut lanes,
1932                                &task_registry,
1933                                &mut trace,
1934                                &detached,
1935                                &live_delegations,
1936                                &Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
1937                                &mut queued_cancel_event_id,
1938                            )
1939                            .await;
1940                            break 'task_loop;
1941                        }
1942                        // ── WAIT FOR NEXT TASK ──
1943                        let (incoming_lane, incoming) = if closing_out {
1944                            let active = task_registry.lock().unwrap().active_lane();
1945                            match lanes.next(active) {
1946                                Some(selected) => selected,
1947                                None => break 'task_loop,
1948                            }
1949                        } else {
1950                            match effective_lifecycle.task_acceptance {
1951                                TaskAcceptance::None => {
1952                                    // Does not accept incoming tasks; run from task.md if present
1953                                    if workdir_task_md.exists() {
1954                                        let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
1955                                        let context_id = task_context_id(supplied_context_id.as_deref());
1956                                        let bytes = tokio::fs::metadata(&workdir_task_md)
1957                                            .await
1958                                            .map(|m| m.len())
1959                                            .unwrap_or(0);
1960                                        let provenance =
1961                                            TaskProvenance::derive(TaskOrigin::User, None);
1962                                        let _ = trace
1963                                            .write_task_start(
1964                                                &task_id,
1965                                                &context_id,
1966                                                "task_md",
1967                                                provenance,
1968                                                // A `task.md` task is a person's instruction, not a
1969                                                // child reporting back.
1970                                                None,
1971                                                bytes,
1972                                            )
1973                                            .await;
1974                                        let seed = hooks
1975                                            .dispatch_task_start(
1976                                                task_id.clone(),
1977                                                context_id.clone(),
1978                                                "task_md".to_string(),
1979                                                bytes,
1980                                                seed_budget_tokens,
1981                                                u64::from(context_window),
1982                                                agent::prior_history_tokens(
1983                                                    run_config.conversation_root.as_deref(),
1984                                                    &workdir,
1985                                                    &conversation_mode,
1986                                                    Some(&context_id),
1987                                                    run_config.resume.is_some(),
1988                                                ),
1989                                            )
1990                                            .await;
1991                                        otel.begin_session(None);
1992                                        state.current_traceparent = otel.outgoing_traceparent();
1993                                        state.current_task_provenance = Some(provenance);
1994                                        state.current_context_id = Some(context_id.clone());
1995                                        // run_task_with_reopens fires on-task-end, honors any
1996                                        // reopen-task within budget, and writes the terminal
1997                                        // task_end (with reopen_count) itself.
1998                                        let result = run_task_with_reopens(
1999                                            &mut state,
2000                                            &workdir,
2001                                            inference,
2002                                            effective_lifecycle.max_task_reopens,
2003                                            system_prompt.clone(),
2004                                            run_config.clone(),
2005                                            &mut hooks,
2006                                            &mut trace,
2007                                            &mut otel,
2008                                            None,
2009                                            None,
2010                                            &accessible_workdir,
2011                                            &capsule_name,
2012                                            &capsule_version,
2013                                            conversation_mode.clone(),
2014                                            Some(context_id.clone()),
2015                                            &task_id,
2016                                            seed,
2017                                            // No A2A task, so nothing a person can address a
2018                                            // `tasks/cancel` to; only `SIGTERM` cancels it.
2019                                            Some(terminating.clone()),
2020                                        )
2021                                        .await;
2022                                        let failed = result.is_err();
2023                                        final_loop_result = result;
2024                                        if failed {
2025                                            break 'task_loop;
2026                                        }
2027                                        closing_out = true;
2028                                        continue 'task_loop;
2029                                    } else {
2030                                        closing_out = true;
2031                                        continue 'task_loop;
2032                                    }
2033                                }
2034                                TaskAcceptance::Single | TaskAcceptance::Queue => {
2035                                    if workdir_task_md.exists() {
2036                                        // Backward compat: existing task.md → single run, no A2A
2037                                        let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
2038                                        let context_id = task_context_id(supplied_context_id.as_deref());
2039                                        let bytes = tokio::fs::metadata(&workdir_task_md)
2040                                            .await
2041                                            .map(|m| m.len())
2042                                            .unwrap_or(0);
2043                                        let provenance =
2044                                            TaskProvenance::derive(TaskOrigin::User, None);
2045                                        let _ = trace
2046                                            .write_task_start(
2047                                                &task_id,
2048                                                &context_id,
2049                                                "task_md",
2050                                                provenance,
2051                                                // A `task.md` task is a person's instruction, not a
2052                                                // child reporting back.
2053                                                None,
2054                                                bytes,
2055                                            )
2056                                            .await;
2057                                        let seed = hooks
2058                                            .dispatch_task_start(
2059                                                task_id.clone(),
2060                                                context_id.clone(),
2061                                                "task_md".to_string(),
2062                                                bytes,
2063                                                seed_budget_tokens,
2064                                                u64::from(context_window),
2065                                                agent::prior_history_tokens(
2066                                                    run_config.conversation_root.as_deref(),
2067                                                    &workdir,
2068                                                    &conversation_mode,
2069                                                    Some(&context_id),
2070                                                    run_config.resume.is_some(),
2071                                                ),
2072                                            )
2073                                            .await;
2074                                        otel.begin_session(None);
2075                                        state.current_traceparent = otel.outgoing_traceparent();
2076                                        state.current_task_provenance = Some(provenance);
2077                                        state.current_context_id = Some(context_id.clone());
2078                                        let result = run_task_with_reopens(
2079                                            &mut state,
2080                                            &workdir,
2081                                            inference,
2082                                            effective_lifecycle.max_task_reopens,
2083                                            system_prompt.clone(),
2084                                            run_config.clone(),
2085                                            &mut hooks,
2086                                            &mut trace,
2087                                            &mut otel,
2088                                            None,
2089                                            None,
2090                                            &accessible_workdir,
2091                                            &capsule_name,
2092                                            &capsule_version,
2093                                            conversation_mode.clone(),
2094                                            Some(context_id.clone()),
2095                                            &task_id,
2096                                            seed,
2097                                            Some(terminating.clone()),
2098                                        )
2099                                        .await;
2100                                        let _ = trace.flush().await;
2101                                        let failed = result.is_err();
2102                                        let single = matches!(
2103                                            effective_lifecycle.task_acceptance,
2104                                            TaskAcceptance::Single
2105                                        );
2106                                        if single || failed {
2107                                            final_loop_result = result;
2108                                            if failed {
2109                                                break 'task_loop;
2110                                            }
2111                                            closing_out = true;
2112                                            continue 'task_loop;
2113                                        }
2114                                        // Queue mode: remove task.md so the next iteration
2115                                        // falls through to task_rx.recv() for queued subtasks.
2116                                        let _ = tokio::fs::remove_file(&workdir_task_md).await;
2117                                        continue 'task_loop;
2118                                    }
2119                                    // Wait for the next task from the mpsc channel.
2120                                    // queue+sleep mode waits indefinitely — no self-terminating
2121                                    // timeout. The host (mur-roost) is responsible for shutdown.
2122                                    // All other modes apply MURMUR_A2A_TIMEOUT_SECS (default 30 s).
2123                                    let is_queue_sleep =
2124                                        effective_lifecycle.can_receive_background_tasks();
2125
2126                                    loop {
2127                                        // Detached shell commands that finished are turned into
2128                                        // tasks first, so a completion delivered while the previous
2129                                        // task was running is in its lane before anything is chosen
2130                                        // — behind everything a person or a peer is waiting for.
2131                                        while let Ok(report) = completion_rx.try_recv() {
2132                                            enqueue_detached_report(
2133                                                report,
2134                                                &task_registry,
2135                                                &mut lanes,
2136                                                &mut trace,
2137                                            )
2138                                            .await;
2139                                        }
2140                                        // Everything already delivered goes into its lane before
2141                                        // anything is chosen, so the choice is made over the whole
2142                                        // backlog. A disconnected channel ends the drain and is
2143                                        // handled by the blocking wait below, which sees `None`.
2144                                        while let Ok(task) = task_rx.try_recv() {
2145                                            lanes.push(task);
2146                                        }
2147                                        if terminating.is_canceled() {
2148                                            close_lanes_on_termination(
2149                                                &mut lanes,
2150                                                &task_registry,
2151                                                &mut trace,
2152                                                &detached,
2153                                                &live_delegations,
2154                                                &Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
2155                                                &mut queued_cancel_event_id,
2156                                            )
2157                                            .await;
2158                                            break 'task_loop;
2159                                        }
2160                                        let active = task_registry.lock().unwrap().active_lane();
2161                                        if let Some(selected) = lanes.next(active) {
2162                                            break selected;
2163                                        }
2164
2165                                        let arrived = if is_queue_sleep {
2166                                            // A completion is a second thing worth waking for, so
2167                                            // the indefinite wait covers both channels. The
2168                                            // completion sender lives as long as the registry does,
2169                                            // so only `task_rx` can close, and it still ends the
2170                                            // loop when it does.
2171                                            tokio::select! {
2172                                                () = terminating.canceled() => break 'task_loop,
2173                                                arrived = task_rx.recv() => match arrived {
2174                                                    Some(task) => task,
2175                                                    None => {
2176                                                        final_loop_result = Ok(AgentLoopExit::Ok);
2177                                                        break 'task_loop;
2178                                                    }
2179                                                },
2180                                                Some(report) = completion_rx.recv() => {
2181                                                    enqueue_detached_report(
2182                                                        report,
2183                                                        &task_registry,
2184                                                        &mut lanes,
2185                                                        &mut trace,
2186                                                    )
2187                                                    .await;
2188                                                    continue;
2189                                                }
2190                                            }
2191                                        } else {
2192                                            let idle_timeout_secs: u64 =
2193                                                std::env::var("MURMUR_A2A_TIMEOUT_SECS")
2194                                                    .ok()
2195                                                    .and_then(|v| v.parse().ok())
2196                                                    .unwrap_or(30);
2197                                            // The timeout is on the whole wait, not on the task
2198                                            // channel alone: a completion is a second thing worth
2199                                            // waking for, and one that arrives inside the window
2200                                            // must not be left sitting until the window expires.
2201                                            match tokio::time::timeout(
2202                                                std::time::Duration::from_secs(idle_timeout_secs),
2203                                                async {
2204                                                    tokio::select! {
2205                                                        () = terminating.canceled() => Woke::Terminating,
2206                                                        arrived = task_rx.recv() => Woke::Task(arrived),
2207                                                        Some(report) = completion_rx.recv() => {
2208                                                            Woke::Report(report)
2209                                                        }
2210                                                    }
2211                                                },
2212                                            )
2213                                            .await
2214                                            {
2215                                                // Filed and reconsidered on the next pass around the
2216                                                // drain, alongside anything else queued.
2217                                                Ok(Woke::Report(report)) => {
2218                                                    enqueue_detached_report(
2219                                                        report,
2220                                                        &task_registry,
2221                                                        &mut lanes,
2222                                                        &mut trace,
2223                                                    )
2224                                                    .await;
2225                                                    continue;
2226                                                }
2227                                                Ok(Woke::Terminating) => break 'task_loop,
2228                                                Ok(Woke::Task(Some(task))) => task,
2229                                                Ok(Woke::Task(None)) => {
2230                                                    final_loop_result = Ok(AgentLoopExit::Ok);
2231                                                    break 'task_loop;
2232                                                }
2233                                                Err(_elapsed) => {
2234                                                    eprintln!("[capsule-runtime] no A2A message received within timeout; running with empty task");
2235                                                    otel.begin_session(None);
2236                                                    state.current_traceparent = otel.outgoing_traceparent();
2237                                                    final_loop_result = agent::run_agent_loop(
2238                                                        &mut state,
2239                                                        &workdir,
2240                                                        inference,
2241                                                        system_prompt,
2242                                                        run_config,
2243                                                        &mut hooks,
2244                                                        &mut trace,
2245                                                        &mut otel,
2246                                                        None,
2247                                                        None,
2248                                                        &accessible_workdir,
2249                                                        &capsule_name,
2250                                                        &capsule_version,
2251                                                        conversation_mode.clone(),
2252                                                        None,
2253                                                        // No task was ever put in scope on this
2254                                                        // path, so `on-task-start` never fired and
2255                                                        // there is no seed to apply.
2256                                                        None,
2257                                                        // Nor is there a task to cancel; only
2258                                                        // `SIGTERM` ends this attempt early.
2259                                                        Some(terminating.clone()),
2260                                                    )
2261                                                    .await;
2262                                                    break 'task_loop;
2263                                                }
2264                                            }
2265                                        };
2266                                        // Back around the drain: a task that landed while this one
2267                                        // was in flight is considered alongside it.
2268                                        lanes.push(arrived);
2269                                    }
2270                                }
2271                            }
2272                        };
2273
2274                        // ── ACTIVATE TASK ──
2275                        // A task cancelled while it was still `submitted` never starts: no
2276                        // `task_start`, no `on-task-start`, no request to the provider. The
2277                        // registry already holds `Canceled` and has given the queue slot back,
2278                        // so all that is left is to say so and take the next task.
2279                        let cancel_signal = {
2280                            let mut reg = task_registry.lock().unwrap();
2281                            if reg.is_canceled(&incoming.task_id) {
2282                                None
2283                            } else {
2284                                reg.start_task(
2285                                    incoming.task_id.clone(),
2286                                    incoming.context_id.clone(),
2287                                    incoming_lane,
2288                                );
2289                                Some(reg.cancel_watch(&incoming.task_id))
2290                            }
2291                        };
2292                        let Some(cancel_signal) = cancel_signal else {
2293                            record_canceled_before_start(
2294                                &incoming,
2295                                &mut trace,
2296                                &detached,
2297                                &live_delegations,
2298                                &Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
2299                                &mut queued_cancel_event_id,
2300                            )
2301                            .await;
2302                            continue 'task_loop;
2303                        };
2304                        if let Err(e) =
2305                            tokio::fs::write(&workdir_task_md, &incoming.message_text).await
2306                        {
2307                            eprintln!(
2308                                "[capsule-runtime] failed to write A2A message to task.md: {e}"
2309                            );
2310                        }
2311                        // Only a task that actually arrived over the peer door gets the
2312                        // received record; a completion the runtime produced for itself never
2313                        // crossed that boundary.
2314                        if incoming.source == crate::a2a::SOURCE_A2A {
2315                            let _ = trace
2316                                .write_a2a_task_received(
2317                                    &incoming.task_id,
2318                                    &incoming.context_id,
2319                                    &incoming.message_id,
2320                                    incoming.traceparent.as_deref(),
2321                                )
2322                                .await;
2323                        }
2324                        let _ = trace
2325                            .write_task_start(
2326                                &incoming.task_id,
2327                                &incoming.context_id,
2328                                incoming.source,
2329                                incoming.provenance,
2330                                incoming.delegation_id.as_deref(),
2331                                incoming.message_text.len() as u64,
2332                            )
2333                            .await;
2334                        // A task naming a delegation is that delegation's outcome arriving, which
2335                        // is where a `delegate-task` call that returned on start finally ends.
2336                        if let Some(delegation_id) = &incoming.delegation_id {
2337                            state.close_started_delegation(delegation_id).await;
2338                        }
2339                        let seed = hooks
2340                            .dispatch_task_start(
2341                                incoming.task_id.clone(),
2342                                incoming.context_id.clone(),
2343                                incoming.source.to_string(),
2344                                incoming.message_text.len() as u64,
2345                                seed_budget_tokens,
2346                                u64::from(context_window),
2347                                agent::prior_history_tokens(
2348                                    run_config.conversation_root.as_deref(),
2349                                    &workdir,
2350                                    &conversation_mode,
2351                                    Some(&incoming.context_id),
2352                                    run_config.resume.is_some(),
2353                                ),
2354                            )
2355                            .await;
2356
2357                        // ── RUN AGENT LOOP ──
2358                        otel.begin_session(incoming.traceparent.as_deref());
2359                        state.current_traceparent = otel.outgoing_traceparent();
2360                        state.current_task_provenance = Some(incoming.provenance);
2361                        state.current_context_id = Some(incoming.context_id.clone());
2362                        state.a2a_task_id = Some(incoming.task_id.clone());
2363                        let loop_result = run_task_with_reopens(
2364                            &mut state,
2365                            &workdir,
2366                            inference,
2367                            effective_lifecycle.max_task_reopens,
2368                            system_prompt.clone(),
2369                            run_config.clone(),
2370                            &mut hooks,
2371                            &mut trace,
2372                            &mut otel,
2373                            Some(incoming.task_id.clone()),
2374                            Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
2375                            &accessible_workdir,
2376                            &capsule_name,
2377                            &capsule_version,
2378                            conversation_mode.clone(),
2379                            Some(incoming.context_id.clone()),
2380                            &incoming.task_id,
2381                            seed,
2382                            Some(cancel_signal),
2383                        )
2384                        .await;
2385
2386                        // ── POST-LOOP SLOT UPDATE ──
2387                        // task_end (with reopen_count) and the terminal on-task-end dispatch
2388                        // already happened inside run_task_with_reopens; an exhausted reopen
2389                        // budget surfaces here as loop_result.is_err(), i.e. a failed task.
2390                        // `finish_task` refuses to overwrite an accepted cancel whatever is
2391                        // passed, so this is the loop's own reading rather than the authority:
2392                        // an attempt that reported `canceled` says so here too.
2393                        let exit_state = match &loop_result {
2394                            Ok(AgentLoopExit::Canceled) => TaskState::Canceled,
2395                            Ok(_) => TaskState::Completed,
2396                            Err(_) => TaskState::Failed,
2397                        };
2398                        let _ = trace.flush().await;
2399                        {
2400                            let mut reg = task_registry.lock().unwrap();
2401                            reg.finish_task(exit_state);
2402                            // Immediately after the terminal state, so a resource-plane read that
2403                            // lands next reports the turn these bytes belong to.
2404                            reg.advance_resource_generation();
2405                        }
2406
2407                        // A terminating session starts nothing after the task it was running:
2408                        // what is still in a lane gets its cancel recorded, and the loop ends.
2409                        if terminating.is_canceled() {
2410                            final_loop_result = loop_result;
2411                            close_lanes_on_termination(
2412                                &mut lanes,
2413                                &task_registry,
2414                                &mut trace,
2415                                &detached,
2416                                &live_delegations,
2417                                &Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
2418                                &mut queued_cancel_event_id,
2419                            )
2420                            .await;
2421                            break 'task_loop;
2422                        }
2423
2424                        // ── DECIDE WHETHER TO CONTINUE ──
2425                        // Closing out runs down whatever is already in a lane and then ends the
2426                        // loop, whatever `after_task` says: nothing can arrive to extend it.
2427                        if closing_out {
2428                            final_loop_result = loop_result;
2429                            continue 'task_loop;
2430                        }
2431                        match effective_lifecycle.after_task {
2432                            AfterTask::Exit => {
2433                                final_loop_result = loop_result;
2434                                break 'task_loop;
2435                            }
2436                            AfterTask::Sleep => {
2437                                if matches!(
2438                                    effective_lifecycle.task_acceptance,
2439                                    TaskAcceptance::Single
2440                                ) {
2441                                    // single mode always exits after one task
2442                                    final_loop_result = loop_result;
2443                                    break 'task_loop;
2444                                }
2445                                // Queue+sleep: clear task.md and wait for next task
2446                                let _ = tokio::fs::remove_file(&workdir_task_md).await;
2447                                continue 'task_loop;
2448                            }
2449                        }
2450                    }
2451                    // ── LOOP BODY ENDS HERE ────────────────────────────────
2452
2453                    // on-session-end fires ONCE per launch, after the task loop exits.
2454                    // total_turns is the whole-launch aggregate accumulated by HookRuntime
2455                    // (one per Inference event across every task). exit_status is the last
2456                    // agent loop's own terminal outcome, so a launch that ended on a driver
2457                    // error or a spent turn budget says so rather than reading `"ok"` because
2458                    // the runtime kept the session alive to report it.
2459                    let session_exit_status = match &final_loop_result {
2460                        Ok(exit) => exit.as_str(),
2461                        Err(_) => "failed",
2462                    };
2463                    let session_total_turns = hooks.total_turns();
2464                    hooks
2465                        .emit(
2466                            &workdir,
2467                            HookEvent::SessionEnd {
2468                                total_turns: session_total_turns,
2469                                exit_status: session_exit_status.to_string(),
2470                            },
2471                        )
2472                        .await;
2473
2474                    // Every async hook's queue is drained — and its worker awaited — while the
2475                    // `LocalSet` is still alive. Without this, `run_until` returning would drop
2476                    // the workers mid-call and take the session-end export with them. Bounded,
2477                    // so a wedged hook delays the exit by at most the drain budget; whatever it
2478                    // did not finish is reported through the same fault path as any other hook
2479                    // fault, which is why this runs *before* the flush below.
2480                    hooks.drain_async_hooks().await;
2481                    agent::flush_hook_dispatch_faults(&mut hooks, &mut trace).await;
2482
2483                    // Work this session started and is not waiting for. Nothing here waits on a
2484                    // detached command — that is the point of having detached it — but the loss
2485                    // of its result is recorded rather than passed over in silence: a demoted
2486                    // command lives entirely in process memory, so nothing of it survives here.
2487                    let abandoned_at_ms = crate::trace::timestamp_ms();
2488                    // Outstanding work first, then the channel. `complete` removes a work id
2489                    // before it sends, so a command finishing during this teardown is briefly in
2490                    // neither place; reading `outstanding` first and draining second means it is
2491                    // seen in one or the other rather than falling between them. A command
2492                    // caught in both is recorded once.
2493                    let mut abandoned: Vec<AbandonedWork> = Vec::new();
2494                    for work in detached.outstanding() {
2495                        abandoned.push(AbandonedWork {
2496                            work_id: work.work_id,
2497                            binary: work.binary,
2498                            command: work.command,
2499                            disposition: AbandonedDisposition::StillRunning {
2500                                running_ms: abandoned_at_ms.saturating_sub(work.started_at_ms),
2501                            },
2502                        });
2503                    }
2504                    // Work that reported after the task loop stopped reading. No task will carry
2505                    // the result, so it is discarded on the same terms as work still running — but
2506                    // the exit code, the duration and the written output log all exist, and the
2507                    // record and the report both name them rather than flattening this case into
2508                    // the other one.
2509                    while let Ok(report) = completion_rx.try_recv() {
2510                        match report {
2511                            DetachedReport::Completed(completion) => {
2512                                if abandoned
2513                                    .iter()
2514                                    .any(|work| work.work_id == completion.work_id)
2515                                {
2516                                    continue;
2517                                }
2518                                let status = completion.status();
2519                                abandoned.push(AbandonedWork {
2520                                    work_id: completion.work_id,
2521                                    binary: completion.binary,
2522                                    command: completion.command,
2523                                    disposition: AbandonedDisposition::FinishedTooLate {
2524                                        exit_code: completion.exit_code,
2525                                        duration_ms: completion.duration_ms,
2526                                        output_path: completion.output_path,
2527                                        output_bytes: completion.output_bytes,
2528                                        resource_limit: completion.resource_limit,
2529                                        wait_error: completion.error,
2530                                        status,
2531                                    },
2532                                });
2533                            }
2534                            DetachedReport::Lost(_) => {}
2535                        }
2536                    }
2537                    // The discard is stated to the operator in full, on the two surfaces that are
2538                    // not `trace.jsonl`: there is no turn left to tell the agent in, so every
2539                    // remaining surface is the operator's.
2540                    if !abandoned.is_empty() {
2541                        let report = detached::abandonment_report_text(&session_id, &abandoned);
2542                        eprintln!("{report}");
2543                        agent::append_bootstrap_log(&workdir, &report);
2544                        for work in &abandoned {
2545                            let _ = trace
2546                                .write_shell_abandoned(
2547                                    &work.work_id,
2548                                    &work.binary,
2549                                    &work.command,
2550                                    work.running_ms(),
2551                                    work.recovered(),
2552                                )
2553                                .await;
2554                        }
2555                    }
2556
2557                    let _ = trace
2558                        .write_session_end_if_not_ended(session_exit_status)
2559                        .await;
2560                    otel.emit_session_end_if_not_ended("failed").await;
2561                    trace.flush().await.map_err(|e| {
2562                        RuntimeError::AgentLoopFailed(format!("failed to flush trace: {e}"))
2563                    })?;
2564
2565                    // Signal HTTP server to shut down, then wait for it
2566                    let _ = shutdown_tx.send(());
2567                    let _ = server_handle.await;
2568
2569                    final_loop_result.map(|_| ())
2570                })
2571                .await
2572        });
2573
2574        loop_result?;
2575
2576        delegation.complete();
2577        roost_session.complete();
2578        return Ok(LaunchResult {
2579            session_id: session_id_ret,
2580            workdir: workdir_ret,
2581        });
2582    }
2583
2584    // Script capsule path — requires a compiled WASM component.
2585    let capsule_component = staged
2586        .capsule_component
2587        .ok_or(RuntimeError::CapsuleCompile(
2588            "non-agent capsule has no compiled component".to_string(),
2589        ))?;
2590
2591    let mut linker = Linker::new(&staged.engine);
2592    wasmtime_wasi::p2::add_to_linker_async(&mut linker)
2593        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2594    wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
2595        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2596
2597    // `murmur:tool-registry/invoke@0.1.0` is the one host-provided interface a
2598    // capsule guest imports. Register it by hand against the same `invoke::Host`
2599    // impl on `CapsuleStoreState` (the generated `invoke::add_to_linker` would do
2600    // the same, but this keeps the registration alongside the tool-side ones).
2601    {
2602        let iface = WIT_TOOL_REGISTRY_IFACE;
2603        linker
2604            .instance(iface)
2605            .map_err(|err| RuntimeError::Runtime(err.to_string()))?
2606            .func_wrap(
2607                "invoke",
2608                |mut store: wasmtime::StoreContextMut<'_, CapsuleStoreState>,
2609                 (tool_name, input): (String, murmur::tool::run::ToolInput)|
2610                 -> wasmtime::Result<(Result<murmur::tool::run::ToolResult, String>,)> {
2611                    Ok((invoke::Host::invoke(store.data_mut(), tool_name, input),))
2612                },
2613            )
2614            .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2615    }
2616    manage::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
2617        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2618    send::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
2619        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2620
2621    // Read before `staged.capability_policy` moves into the store state below.
2622    let capsule_limits = staged.capability_policy.limits;
2623
2624    let state = CapsuleStoreState {
2625        table: ResourceTable::new(),
2626        wasi: build_wasi_ctx(
2627            &staged.accessible_workdir,
2628            // The capsule component runs on the ceiling, not on any artifact's grant — so it gets
2629            // neither a narrowed workdir preopen, nor a state preopen, nor a config block. A
2630            // capsule cannot reach a tool's store, by construction and not by convention: it holds
2631            // no descriptor that names one.
2632            None,
2633            None,
2634            None,
2635            &inference_env,
2636            &staged.capability_policy,
2637        )?,
2638        http: WasiHttpCtx::new(),
2639        http_hooks: NetworkPolicyHooks {
2640            network_allow_rules: network_allow_rules.clone(),
2641            inference_gateway: None,
2642        },
2643        network_allow_rules,
2644        // A script capsule has no peer-handoff surface: `share-file` and `fetch-peer-file` are
2645        // agent-loop tools, and no WIT import exposes either to a wasm component. These are the
2646        // deny values rather than an omission — a future `murmur:peer-file` interface would fill
2647        // them here, from `staged.exports_peer_files` and `staged.capability_policy`.
2648        peer_fetch_rules: Vec::new(),
2649        peer_plane: None,
2650        peer_own_audience: String::new(),
2651        peer_trace: None,
2652        // Nor a plan surface: `submit-plan` is an agent-loop tool too, so a script capsule that
2653        // declares `capabilities.plan.submit` has nothing to call and nothing to record.
2654        plan_trace: None,
2655        plan_counter: AtomicU64::new(0),
2656        // Nor a delegation surface: `delegate-task` is an agent-loop tool and no WIT import
2657        // exposes delegation to a wasm component. A script capsule that declares
2658        // `capabilities.spawn.allow` still registers, and its credential is still what a
2659        // `capsule` plan step would delegate with — it simply has no tool to call.
2660        delegation: None,
2661        inference_env,
2662        inference_gateway: staged.inference_gateway.clone(),
2663        spend: Arc::clone(&staged.spend),
2664        engine: staged.engine.clone(),
2665        workdir: staged.workdir.clone(),
2666        accessible_workdir: staged.accessible_workdir.clone(),
2667        tool_components: staged.tool_components,
2668        artifact_grants: staged.artifact_grants,
2669        allowlisted_tools: staged.allowlisted_tools,
2670        installed_artifacts: staged.installed_artifacts,
2671        session_id: staged.session_id.clone(),
2672        pending_a2a_events: Vec::new(),
2673        capability_policy: staged.capability_policy,
2674        protected_paths: staged.protected_paths,
2675        tool_annotations: staged.tool_annotations,
2676        shell_enforcement: shell_enforcement.clone(),
2677        current_traceparent: None,
2678        current_task_provenance: None,
2679        current_context_id: None,
2680        live_delegations: Arc::new(crate::cancel::LiveDelegations::new()),
2681        // The script-capsule path runs no task loop, so a demoted command's completion would
2682        // have nowhere to be delivered: every command it dispatches runs to completion in the
2683        // foreground.
2684        detached: None,
2685        shell_grace_secs: 0,
2686        a2a_task_registry: None,
2687        a2a_sse: None,
2688        a2a_task_id: None,
2689        input_timeout_secs: None,
2690        a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
2691        a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
2692        registry: Arc::clone(&staged.registry),
2693        lock_path: staged.manifest_dir.join("murmur.lock"),
2694        driver_continuation_id: None,
2695        driver_continuation_context_id: None,
2696        driver_continuation_acked_len: 0,
2697        limits: capsule_limits.limiter(),
2698    };
2699
2700    let mut store = Store::new(&staged.engine, state);
2701    // Must precede instantiation: `Store::limiter` latches the instance/table/memory counts
2702    // the store enforces, and instantiation itself allocates against them.
2703    store.limiter(|state| &mut state.limits);
2704
2705    // Script capsule uses a multi-thread runtime so block_in_place works inside send::Host::send
2706    let rt = tokio::runtime::Builder::new_multi_thread()
2707        .enable_all()
2708        .build()
2709        .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
2710
2711    rt.block_on(async {
2712        // Instantiation runs the guest's own start/init code, so it gets a deadline too.
2713        store.set_epoch_deadline(capsule_limits.deadline_ticks());
2714        let instantiated = linker
2715            .instantiate_async(&mut store, &capsule_component)
2716            .await;
2717        let instance = match instantiated {
2718            Ok(instance) => instance,
2719            Err(err) => return Err(capsule_guest_error(&err, &store.data().limits)),
2720        };
2721
2722        let capsule_iface =
2723            resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED)
2724                .ok_or(RuntimeError::CapsuleExportMissing)?;
2725        let capsule_run = instance
2726            .get_export_index(&mut store, Some(&capsule_iface), "run")
2727            .and_then(|idx| instance.get_func(&mut store, idx))
2728            .ok_or(RuntimeError::CapsuleExportMissing)?;
2729
2730        let run = capsule_run
2731            .typed::<(), ()>(&store)
2732            .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2733
2734        // Fresh budget for `run` itself, so instantiation cost cannot eat into it.
2735        store.set_epoch_deadline(capsule_limits.deadline_ticks());
2736        let called = run.call_async(&mut store, ()).await;
2737        if let Err(err) = called {
2738            return Err(capsule_guest_error(&err, &store.data().limits));
2739        }
2740
2741        Ok(())
2742    })?;
2743
2744    // Drain any buffered a2a_send trace events from the capsule run.
2745    let pending = std::mem::take(&mut store.data_mut().pending_a2a_events);
2746    if !pending.is_empty() {
2747        rt.block_on(async {
2748            if let Ok(mut trace) = TraceWriter::open(
2749                &staged.workdir,
2750                staged.session_id.clone(),
2751                staged.capsule_name.clone(),
2752                staged.capsule_version.clone(),
2753                String::new(),
2754                Vec::new(),
2755                staged.scope_report.clone(),
2756                // This writer exists only to drain buffered `a2a_send` events; it writes no
2757                // record that can carry a hash or a body.
2758                murmur_artifact::TraceCapture::None,
2759                None,
2760                false,
2761                None,
2762                None,
2763                None,
2764                None,
2765            )
2766            .await
2767            {
2768                for (peer_url, message_id, task_id, context_id, traceparent, trust) in pending {
2769                    let _ = trace
2770                        .write_a2a_send(
2771                            &peer_url,
2772                            &message_id,
2773                            &task_id,
2774                            &context_id,
2775                            traceparent.as_deref(),
2776                            trust,
2777                        )
2778                        .await;
2779                }
2780                let _ = trace.flush().await;
2781            }
2782        });
2783    }
2784
2785    // Notify the caller that the capsule has started (no URL for script capsules).
2786    on_url("");
2787
2788    delegation.complete();
2789    roost_session.complete();
2790    Ok(LaunchResult {
2791        session_id: staged.session_id,
2792        workdir: staged.workdir,
2793    })
2794}
2795
2796/// This session's entry for `~/.murmur/running/`, not yet written — see [`write_running_record`].
2797///
2798/// `outlives_launcher` is derived from whether the launching process has a controlling terminal.
2799/// A capsule started in a terminal dies with that window; one started without a terminal —
2800/// `nohup … </dev/null &`, a `setsid`, a service manager — survives whoever started it.
2801fn running_record_for(
2802    staged: &StagedSession,
2803    session_id: &str,
2804    capsule_url: &str,
2805) -> running::RunningRecord {
2806    let pid = std::process::id();
2807    running::RunningRecord {
2808        session_id: session_id.to_string(),
2809        url: capsule_url.to_string(),
2810        pid,
2811        // An unreadable start time is recorded as the empty string, which no freshly read token
2812        // equals, so the record fails layer 2 on the first read instead of resolving unverified.
2813        process_start: running::process_start_token(pid).unwrap_or_default(),
2814        capsule_name: staged.capsule_name.clone(),
2815        capsule_version: staged.capsule_version.clone(),
2816        workdir: staged.workdir.clone(),
2817        outlives_launcher: !running::has_controlling_terminal(),
2818        started_at: chrono::Utc::now().to_rfc3339(),
2819    }
2820}
2821
2822/// Writes `record` into `~/.murmur/running/` and returns the guard that removes it.
2823///
2824/// `None` when the record could not be written, which warns `W-SEC-023` and changes nothing else:
2825/// the session runs and serves exactly as it would have, reachable by the URL `mur run` prints
2826/// rather than by session address.
2827fn write_running_record(
2828    record: &running::RunningRecord,
2829    workdir: &Path,
2830) -> Option<running::RunningGuard> {
2831    match running::RunningGuard::write(record) {
2832        Ok(guard) => Some(guard),
2833        Err(reason) => {
2834            let link = security_warning_link(W_SEC_023);
2835            let message = format!(
2836                "this session's record under ~/.murmur/running/ could not be written, so \
2837                 `mur watch` and `mur cancel` cannot reach it by session address — only by the \
2838                 URL it announces: {reason}"
2839            );
2840            eprintln!("[capsule-runtime] warning[{W_SEC_023}]: {message} ({link})");
2841            agent::append_bootstrap_log(
2842                workdir,
2843                &format!("[running-record] warning[{W_SEC_023}]: {message} ({link})"),
2844            );
2845            None
2846        }
2847    }
2848}
2849
2850/// This session's registration with `mur-roost`, for exactly as long as the session runs.
2851///
2852/// Registration is one call and deregistration is its mirror; holding them in a guard is what
2853/// makes the pair total. `launch_session` has one `?` per staging step and two success returns,
2854/// and a session that ended without retiring its registration would leave a credential that still
2855/// verifies and a job the daemon still reports as `running`.
2856struct RoostSession {
2857    /// `None` for every session that declares no `capabilities.spawn.allow`, which registers
2858    /// nothing and therefore has nothing to retire.
2859    registered: Option<(String, SpawnCredential)>,
2860    outcome: SessionOutcome,
2861}
2862
2863impl RoostSession {
2864    /// Registers `staged` and hands it the credential the daemon minted, or returns the refusal
2865    /// that stops the launch.
2866    fn register(staged: &mut StagedSession) -> Result<Self, RuntimeError> {
2867        // Two reasons to register, and a session with neither never opens a connection at all.
2868        //
2869        // A session that declares `capabilities.spawn.allow` must, because the daemon has to hold
2870        // its ceiling before it can referee anything it asks for. A session launched with a grant
2871        // must too, whatever it declares: presenting the approval is what marks it spent, and an
2872        // approval that is never presented would cover as many launches as a parent cared to make
2873        // from it.
2874        if staged.capability_policy.spawn_allow.is_empty() && staged.spawn_grant.is_none() {
2875            return Ok(Self {
2876                registered: None,
2877                // Never read: a session with nothing registered deregisters nothing.
2878                outcome: SessionOutcome::Failed,
2879            });
2880        }
2881
2882        let roost_url = match std::env::var("MURMUR_ROOST_URL") {
2883            Ok(value) if !value.trim().is_empty() => value.trim().to_string(),
2884            _ => {
2885                return Err(RuntimeError::SpawnRegistrationFailed {
2886                    roost_url: "<unset>".to_string(),
2887                    reason: "MURMUR_ROOST_URL is not set".to_string(),
2888                })
2889            }
2890        };
2891
2892        let credential = crate::registration::register_session(
2893            &roost_url,
2894            &staged.session_id,
2895            &staged.capsule_name,
2896            &staged.capsule_version,
2897            staged.spawn_grant.as_ref(),
2898        )?;
2899        staged.set_spawn_credential(credential.clone());
2900        Ok(Self {
2901            // A session that fails between here and a success return is reported as `failed`,
2902            // which is what a reader of `GET /status` would otherwise have to infer from silence.
2903            registered: Some((roost_url, credential)),
2904            outcome: SessionOutcome::Failed,
2905        })
2906    }
2907
2908    /// The daemon and the credential this session presents to it, for the one other thing a
2909    /// registration is for: delegating.
2910    ///
2911    /// `None` for a session that registered nothing. The credential is cloned rather than
2912    /// borrowed because the plane outlives this guard's borrow, and it is still the same closed
2913    /// type — no `Display`, no `Serialize`, redacted `Debug`.
2914    fn endpoint(&self) -> Option<(&str, SpawnCredential)> {
2915        self.registered
2916            .as_ref()
2917            .map(|(url, credential)| (url.as_str(), credential.clone()))
2918    }
2919
2920    fn complete(&mut self) {
2921        self.outcome = SessionOutcome::Complete;
2922    }
2923}
2924
2925impl Drop for RoostSession {
2926    fn drop(&mut self) {
2927        if let Some((roost_url, credential)) = self.registered.take() {
2928            crate::registration::deregister_session(&roost_url, &credential, self.outcome);
2929        }
2930    }
2931}
2932
2933/// This session's obligation to tell the capsule that delegated to it how it ended.
2934///
2935/// The mirror of [`RoostSession`], and a guard for the same reason: `launch_session` has one `?`
2936/// per staging step and two success returns, and a delegated child that ended without reporting
2937/// would leave a parent holding a delegation nothing ever closes. Defaults to
2938/// [`DelegationStatus::Error`] and is promoted by [`Self::complete`] at each success return, so
2939/// every path that is not a success reports as one that failed.
2940///
2941/// `max_turns_reached` is not distinguishable from here: the agent path collapses it into `Ok`
2942/// before the value leaves its async block, so a session that spent its turn budget reports `ok`.
2943/// The child's own trace holds the precise exit status, at a path the completion names.
2944struct DelegationReport {
2945    /// `None` for every capsule nobody delegated, which reports to nobody.
2946    handle: Option<SpawnerHandle>,
2947    capsule_name: String,
2948    capsule_version: String,
2949    session_id: String,
2950    /// The child's own directory — where `completion.json` goes, and the root the completion's
2951    /// `result_path` is relative to.
2952    accessible_workdir: PathBuf,
2953    /// This session's directory beneath it, the other place the runtime writes `out/result.txt`.
2954    session_workdir: PathBuf,
2955    started: Instant,
2956    status: crate::delegation::DelegationStatus,
2957}
2958
2959impl DelegationReport {
2960    /// This session's obligation, taken from the handle staging already read.
2961    fn open(staged: &StagedSession) -> Result<Self, RuntimeError> {
2962        Ok(Self {
2963            handle: staged.spawner.clone(),
2964            capsule_name: staged.capsule_name.clone(),
2965            capsule_version: staged.capsule_version.clone(),
2966            session_id: staged.session_id.clone(),
2967            accessible_workdir: staged.accessible_workdir.clone(),
2968            session_workdir: staged.workdir.clone(),
2969            started: Instant::now(),
2970            status: crate::delegation::DelegationStatus::Error,
2971        })
2972    }
2973
2974    fn complete(&mut self) {
2975        self.status = crate::delegation::DelegationStatus::Ok;
2976    }
2977
2978    /// Where this session's result text landed, relative to the directory the completion names.
2979    ///
2980    /// Two places, one rule: a script capsule writes into its own preopen, which is the
2981    /// accessible workdir, and the agent loop writes into this session's directory beneath it.
2982    /// `None` when neither exists — a terminal path that failed without result text legitimately
2983    /// writes no file.
2984    fn result_path(&self) -> Option<String> {
2985        let relative = Path::new("out").join("result.txt");
2986        if self.accessible_workdir.join(&relative).is_file() {
2987            return Some("out/result.txt".to_string());
2988        }
2989        let session_result = self.session_workdir.join(&relative);
2990        if session_result.is_file() {
2991            return session_result
2992                .strip_prefix(&self.accessible_workdir)
2993                .ok()
2994                .map(|path| path.to_string_lossy().replace('\\', "/"));
2995        }
2996        None
2997    }
2998}
2999
3000impl Drop for DelegationReport {
3001    /// Writes `completion.json` into this capsule's own directory and posts the notification to
3002    /// the address its parent injected. A delivery that fails is recorded in that file and on
3003    /// stderr; it never fails this session, whose work was already done.
3004    ///
3005    /// A handle that names no address writes nothing and posts nothing: the parent that made that
3006    /// delegation waits on the connection it already holds, so the child knows its parent and
3007    /// reports to nobody.
3008    fn drop(&mut self) {
3009        let Some(handle) = self.handle.take() else {
3010            return;
3011        };
3012        let Some(address) = handle.report_to.clone() else {
3013            return;
3014        };
3015        let outcome = crate::delegation::DelegationOutcome {
3016            delegation_id: handle.delegation_id.clone(),
3017            capsule_name: self.capsule_name.clone(),
3018            capsule_version: self.capsule_version.clone(),
3019            session_id: self.session_id.clone(),
3020            status: self.status,
3021            result_path: self.result_path(),
3022            workdir: self.accessible_workdir.display().to_string(),
3023            duration_ms: self
3024                .started
3025                .elapsed()
3026                .as_millis()
3027                .try_into()
3028                .unwrap_or(u64::MAX),
3029            // Reserved for a `crashed` or `terminated` outcome, neither of which a session can
3030            // report about itself.
3031            detail: None,
3032            reported_by: crate::delegation::Reporter::Child,
3033            delivered: false,
3034            delivery_error: None,
3035        };
3036        crate::delegation::report_completion(&handle, &address, outcome, &self.accessible_workdir);
3037    }
3038}
3039
3040/// Reads a manifest-relative prompt file, returning the resolved path alongside any I/O
3041/// error so the caller can map it to its own `RuntimeError` variant.
3042fn read_prompt_file(manifest_dir: &Path, path: &str) -> Result<String, (PathBuf, std::io::Error)> {
3043    let prompt_path = manifest_dir.join(path);
3044    fs::read_to_string(&prompt_path).map_err(|source| (prompt_path, source))
3045}
3046
3047fn resolve_system_prompt(
3048    manifest_dir: &Path,
3049    workdir: &Path,
3050    inference: &murmur_artifact::InferenceConfig,
3051) -> Result<Option<String>, RuntimeError> {
3052    if let Some(prompt) = inference.system_prompt.as_ref() {
3053        return Ok(Some(prompt.clone()));
3054    }
3055
3056    if let Some(path) = inference.system_prompt_file.as_ref() {
3057        return read_prompt_file(manifest_dir, path)
3058            .map(Some)
3059            .map_err(|(prompt_path, source)| RuntimeError::SystemPromptFileRead {
3060                path: prompt_path.display().to_string(),
3061                source,
3062            });
3063    }
3064
3065    if let Some(art_name) = inference.system_prompt_artifact.as_ref() {
3066        let skill_path = workdir.join("tools").join(art_name).join("skill.md");
3067        return fs::read_to_string(&skill_path).map(Some).map_err(|source| {
3068            RuntimeError::SystemPromptArtifactRead {
3069                name: art_name.clone(),
3070                source,
3071            }
3072        });
3073    }
3074
3075    Ok(None)
3076}
3077
3078/// Resolves the compaction system prompt from the two mutually exclusive manifest
3079/// sources — the inline `inference.compaction.system_prompt` string, or the contents of
3080/// the file named by `inference.compaction.system_prompt_file`, read relative to the
3081/// manifest directory. Absence stays absence: no default prompt is substituted, and the
3082/// hook receives `option::none`.
3083///
3084/// Mutual exclusion is enforced at manifest parse time, so an inline prompt winning here
3085/// is unreachable for a manifest that loaded successfully.
3086fn resolve_compaction_system_prompt(
3087    manifest_dir: &Path,
3088    compaction: Option<&murmur_artifact::CompactionConfig>,
3089) -> Result<Option<String>, RuntimeError> {
3090    let Some(compaction) = compaction else {
3091        return Ok(None);
3092    };
3093
3094    if let Some(prompt) = compaction.system_prompt.as_ref() {
3095        return Ok(Some(prompt.clone()));
3096    }
3097
3098    if let Some(path) = compaction.system_prompt_file.as_ref() {
3099        return read_prompt_file(manifest_dir, path)
3100            .map(Some)
3101            .map_err(
3102                |(prompt_path, source)| RuntimeError::CompactionSystemPromptFileRead {
3103                    path: prompt_path.display().to_string(),
3104                    source,
3105                },
3106            );
3107    }
3108
3109    Ok(None)
3110}
3111
3112fn capability_names(policy: &CapabilityPolicy) -> Vec<String> {
3113    let mut names = Vec::new();
3114    if !policy.network_allow.is_empty() {
3115        names.push("network".to_string());
3116    }
3117    if policy.filesystem_scope.is_some() {
3118        names.push("filesystem".to_string());
3119    }
3120    if !policy.shell_allow.is_empty() {
3121        names.push("shell".to_string());
3122    }
3123    names
3124}
3125
3126/// Core message for the bash+network bypass warning (finding C-7 in
3127/// `murmur-security-assessment.md`) — shared verbatim between the stderr line and the
3128/// `logs/bootstrap.log` line so the two never drift apart.
3129const BASH_NETWORK_BYPASS_WARNING: &str = "capabilities.shell.allow includes \"bash\" and \
3130capabilities.network.allow is non-empty, but network.allow does not constrain bash's own \
3131outbound connections on this platform.";
3132
3133/// Warns (non-fatal — matches the warn-and-continue convention of `murmur_md::write_murmur_md`
3134/// and the murmur.yaml-copy warning above) when `policy.shell_allow` contains the exact binary
3135/// name `"bash"` and `policy.network_allow` is non-empty. The check is an exact match on the
3136/// literal `"bash"`, not `shell::is_shell_interpreter` (which also matches `sh`/`zsh`/`fish`/
3137/// `dash`/`ksh`) — a deliberate scope limit matching C-7's own scoping, not an oversight.
3138pub(crate) fn warn_if_bash_network_bypass(workdir: &Path, policy: &CapabilityPolicy) {
3139    let has_bash = policy.shell_allow.iter().any(|binary| binary == "bash");
3140    if has_bash && !policy.network_allow.is_empty() {
3141        let link = security_warning_link(W_SEC_003);
3142        eprintln!("[capsule-runtime] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})");
3143        agent::append_bootstrap_log(
3144            workdir,
3145            &format!(
3146                "[capability-policy] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})"
3147            ),
3148        );
3149    }
3150}
3151
3152const NO_COMPLETION_LANE_WARNING: &str = "this capsule declares capabilities.spawn.allow, but \
3153its lifecycle block cannot receive a delegation's outcome: delegate-task returns as soon as the \
3154sub-capsule is running, and what the sub-capsule did arrives afterwards as a background task. \
3155Declare lifecycle.task_acceptance: queue with lifecycle.after_task: sleep, and a \
3156lifecycle.queue_depth covering how many delegations one turn issues, or every outcome this \
3157capsule delegates for will be posted to a session that has already exited.";
3158
3159/// Pure decision for the delegation-lifecycle warning, split out of
3160/// [`warn_for_unreachable_delegation_outcomes`] the same way `sandbox::aggregate_bounding_warning`
3161/// is split out of its emitter, so a test can assert it without capturing stderr.
3162///
3163/// Fires exactly where an outcome has nowhere to land: a capsule that can delegate and either
3164/// exits after its task or accepts no second one. Never a refusal — a capsule that delegates and
3165/// deliberately does not wait for the answer is legitimate, and this is what makes that a choice
3166/// rather than an accident.
3167pub(crate) fn unreachable_delegation_outcomes_warning(
3168    can_delegate: bool,
3169    lifecycle: &LifecycleConfig,
3170) -> Option<(&'static str, &'static str)> {
3171    (can_delegate && !lifecycle.can_receive_background_tasks())
3172        .then_some((W_SEC_020, NO_COMPLETION_LANE_WARNING))
3173}
3174
3175/// Fires at every launch, not just once.
3176pub(crate) fn warn_for_unreachable_delegation_outcomes(
3177    workdir: &Path,
3178    can_delegate: bool,
3179    lifecycle: &LifecycleConfig,
3180) {
3181    if let Some((code, message)) = unreachable_delegation_outcomes_warning(can_delegate, lifecycle)
3182    {
3183        let link = security_warning_link(code);
3184        eprintln!("[capsule-runtime] warning[{code}]: {message} ({link})");
3185        agent::append_bootstrap_log(
3186            workdir,
3187            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3188        );
3189    }
3190}
3191
3192const NO_SHELL_COMPLETION_LANE_WARNING: &str = "this capsule declares \
3193capabilities.shell.allow, but its lifecycle block cannot receive a background command's \
3194completion: a shell command that outruns lifecycle.shell_grace_secs is demoted to the background, \
3195and its exit code and output path arrive afterwards as a background task. Declare \
3196lifecycle.task_acceptance: queue with lifecycle.after_task: sleep, or every command this capsule \
3197demotes will be discarded at session end and reported to the operator instead of to the agent.";
3198
3199/// Pure decision for the shell-lifecycle warning, split out of
3200/// [`warn_for_unreachable_shell_completions`] on the same terms as
3201/// [`unreachable_delegation_outcomes_warning`], so a test can assert it without capturing stderr.
3202///
3203/// Fires exactly where a completion has nowhere to land: a capsule that can run shell commands and
3204/// either exits after its task or accepts no second one. Deliberately not conditioned on
3205/// `lifecycle.shell_grace_secs` — `0` demotes on the first check after the spawn, so a low grace
3206/// makes the discard more likely rather than less, and no value of it turns demotion off.
3207pub(crate) fn unreachable_shell_completions_warning(
3208    can_run_shell: bool,
3209    lifecycle: &LifecycleConfig,
3210) -> Option<(&'static str, &'static str)> {
3211    (can_run_shell && !lifecycle.can_receive_background_tasks())
3212        .then_some((W_SEC_022, NO_SHELL_COMPLETION_LANE_WARNING))
3213}
3214
3215/// Fires at every launch, not just once.
3216pub(crate) fn warn_for_unreachable_shell_completions(
3217    workdir: &Path,
3218    can_run_shell: bool,
3219    lifecycle: &LifecycleConfig,
3220) {
3221    if let Some((code, message)) = unreachable_shell_completions_warning(can_run_shell, lifecycle) {
3222        let link = security_warning_link(code);
3223        eprintln!("[capsule-runtime] warning[{code}]: {message} ({link})");
3224        agent::append_bootstrap_log(
3225            workdir,
3226            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3227        );
3228    }
3229}
3230
3231/// Warns (non-fatal, once per declared grant) for every `capabilities.shell.interpreter_runtime`
3232/// entry. Declaring one narrows an allowlisted binary's Landlock scope to specific host
3233/// directories so a path-based interpreter can reach its stdlib — but it couples the capsule to a
3234/// specific host distro/interpreter-version layout (e.g. `/usr/lib/python3.11` stops resolving the
3235/// moment the host ships Python 3.12), which the operator should see plainly. The durable fix is
3236/// the still-unbuilt staged runtime bind-mount; this grant only bridges until then.
3237///
3238/// Shared verbatim between `mur run` (from [`stage_session`]) and `mur doctor`, so both surface
3239/// the same code, wording, and doc link. Like [`warn_on_inert_hook_capabilities`], it fires before
3240/// any session workdir exists (the capsule-ceiling check runs at the top of `stage_session`, and
3241/// `doctor` never launches a session at all), so it goes to stderr only, not `logs/bootstrap.log`.
3242pub fn warn_on_interpreter_runtime_grants(grants: &[InterpreterRuntimeGrant]) {
3243    for grant in grants {
3244        let link = security_warning_link(W_SEC_009);
3245        let dirs = grant
3246            .dirs
3247            .iter()
3248            .map(|dir| {
3249                let list = if dir.list_dir { "list_dir" } else { "no-list" };
3250                format!("{} ({list})", dir.path)
3251            })
3252            .collect::<Vec<_>>()
3253            .join(", ");
3254        eprintln!(
3255            "[capsule-runtime] warning[{W_SEC_009}]: capabilities.shell.interpreter_runtime grants \
3256             '{}' host directories outside the workdir [{dirs}] — this couples the capsule to a \
3257             specific host distro/interpreter-version layout (e.g. /usr/lib/python3.11 breaks the \
3258             moment the host ships Python 3.12); the durable fix is the staged runtime bind-mount, \
3259             which this grant only bridges until ({link})",
3260            grant.binary
3261        );
3262    }
3263}
3264
3265/// Warns (non-fatal, once per session) when a capsule declares
3266/// `capabilities.filesystem.workdir_exec: true`.
3267///
3268/// This is the one grant that trades away an enforcement property rather than widening a scope:
3269/// with the workdir's Landlock `Execute` right granted, `capabilities.shell.allow` stops being
3270/// something the kernel can hold the capsule to — a binary it compiles, downloads or renames inside
3271/// its own workdir runs regardless. The declaration is legitimate (compile-and-run workflows need
3272/// it) and the class report already says `advisory`, but a class in a JSON field is easy to miss
3273/// and the reason for it is not self-evident, so it is also stated in words, once, at staging.
3274///
3275/// Shared shape with [`warn_on_interpreter_runtime_grants`]: fires before any session workdir
3276/// exists, so it goes to stderr only, not `logs/bootstrap.log`.
3277pub fn warn_on_workdir_exec(workdir_exec: bool) {
3278    if !workdir_exec {
3279        return;
3280    }
3281    let link = security_warning_link(W_SEC_011);
3282    eprintln!(
3283        "[capsule-runtime] warning[{W_SEC_011}]: capabilities.filesystem.workdir_exec is true — \
3284         the session workdir keeps its Landlock Execute right, so anything the capsule writes \
3285         there can run regardless of capabilities.shell.allow; this capsule reports containment \
3286         class 'advisory' on every host, including a Landlock-capable one ({link})"
3287    );
3288}
3289
3290/// One credential-shaped entry of `capabilities.env.allow` that reaches every WASM guest.
3291///
3292/// The judgment is made from the name alone: nothing here reads the host environment, so the
3293/// warning an operator sees reads identically on a machine that has the variable set and one that
3294/// does not. A capsule's own value is never a diagnostic's business.
3295#[derive(Debug, Clone, PartialEq, Eq)]
3296pub struct SecretShapedEnvGrant {
3297    /// The declared variable name, verbatim. Never its value.
3298    pub name: String,
3299    /// `true` when the resolved `lifecycle.after_task` is `Sleep`.
3300    pub outlives_launcher: bool,
3301}
3302
3303/// Pure decision for the `capabilities.env.allow` secret warning, split out of
3304/// [`warn_on_secret_shaped_env_grants`] on the same terms as
3305/// [`unreachable_delegation_outcomes_warning`], so a test can assert it without capturing stderr.
3306///
3307/// One entry per distinct name that [`is_credential_shaped_env_name`] flags and the credential
3308/// backstop keeps, in declaration order — a name repeated in `env.allow` is judged once, because
3309/// the grant it describes is one grant. A name the backstop drops is passed over: it is refused at
3310/// staging with `E-CAP-016` by [`check_env_allow_reaches_guests`], and the skip reads
3311/// [`crate::credential_backstop_drops`] so the two agree with what
3312/// [`crate::shell::build_wasi_env_allowlist`] passes through.
3313///
3314/// `outlives_launcher` reads `lifecycle.after_task` directly instead of
3315/// [`LifecycleConfig::can_receive_background_tasks`]: that predicate also requires
3316/// `task_acceptance: queue`, and a `single` + `sleep` capsule still outlives the task that launched
3317/// it while holding the value.
3318pub fn secret_shaped_env_grants(
3319    policy: &CapabilityPolicy,
3320    lifecycle: &LifecycleConfig,
3321) -> Vec<SecretShapedEnvGrant> {
3322    let mut grants: Vec<SecretShapedEnvGrant> = Vec::new();
3323
3324    for name in &policy.env_allow {
3325        if crate::shell::credential_backstop_drops(name, &policy.shell_strip_env) {
3326            continue;
3327        }
3328        if !is_credential_shaped_env_name(name) {
3329            continue;
3330        }
3331        if grants.iter().any(|grant| &grant.name == name) {
3332            continue;
3333        }
3334        grants.push(SecretShapedEnvGrant {
3335            name: name.clone(),
3336            outlives_launcher: lifecycle.after_task == AfterTask::Sleep,
3337        });
3338    }
3339
3340    grants
3341}
3342
3343/// The line body for one judged grant, so the two arms exist once rather than once per call site.
3344fn secret_shaped_env_grant_message(grant: &SecretShapedEnvGrant) -> String {
3345    let name = &grant.name;
3346    let held = if grant.outlives_launcher {
3347        "murmur does not broker this secret and cannot withdraw it, and lifecycle.after_task: \
3348         sleep keeps this capsule alive past the task that launched it, so it holds that value \
3349         with nothing left waiting on it"
3350    } else {
3351        "murmur does not broker this secret and cannot withdraw it: for as long as the capsule \
3352         runs, the capsule holds it"
3353    };
3354
3355    format!(
3356        "capabilities.env.allow names '{name}', a credential-shaped variable the credential \
3357         backstop does not drop — every WASM guest this capsule runs observes the host's value. \
3358         {held}"
3359    )
3360}
3361
3362/// Warns (non-fatal, once per distinct credential-shaped `capabilities.env.allow` entry) that the
3363/// capsule was handed a host secret murmur does not broker.
3364///
3365/// `capabilities.env.allow` is the one grant whose value murmur never sees, issues or revokes: an
3366/// operator names a host variable and the runtime passes it through. Without this line nothing
3367/// states that a name surviving the credential backstop reaches every WASM guest, so an operator
3368/// cannot ask which of their capsules holds what. A name the backstop drops is not reported here:
3369/// staging refuses it with `E-CAP-016`.
3370///
3371/// Never a refusal, including for the `after_task: sleep` combination: a long-lived worker holding
3372/// an operator-granted database password is an ordinary shape, and refusing it would make that
3373/// shape unbuildable. The combination gets its own sentence instead.
3374///
3375/// Shared verbatim between `mur run` and `mur doctor`, on the same terms as
3376/// [`warn_on_interpreter_runtime_grants`] — one emitter, two call sites, so the two surfaces cannot
3377/// word one grant differently. Resolves the lifecycle itself, from the manifest block and the CLI
3378/// override, so the `sleep` sentence follows `--lifecycle-after-task` as well as the manifest.
3379/// `lifecycle: None` means the manifest declared no block (`after_task` defaults to `exit`), and
3380/// `lifecycle_override: None` means the surface has no lifecycle flag to apply — which is `mur
3381/// doctor` always. Decided before any session workdir exists, so it goes to stderr only, not
3382/// `logs/bootstrap.log`.
3383pub fn warn_on_secret_shaped_env_grants(
3384    policy: &CapabilityPolicy,
3385    lifecycle: Option<&LifecycleConfig>,
3386    lifecycle_override: Option<&murmur_artifact::LifecycleOverride>,
3387) {
3388    let resolved = resolve_lifecycle(lifecycle.cloned(), lifecycle_override);
3389    for grant in secret_shaped_env_grants(policy, &resolved) {
3390        let link = security_warning_link(W_SEC_024);
3391        eprintln!(
3392            "[capsule-runtime] warning[{W_SEC_024}]: {} ({link})",
3393            secret_shaped_env_grant_message(&grant)
3394        );
3395    }
3396}
3397
3398/// Warns (non-fatal, once per matching entry) when a `transport: http` capsule names its inference
3399/// endpoint in `capabilities.network.allow`.
3400///
3401/// The runtime reaches the provider itself, so inference no longer uses the entry. It still grants
3402/// tools, subprocesses and the driver direct reach to that host, without the key, which is why this
3403/// is a warning and not a refusal. Shared between `mur run` and `mur doctor` on the same terms as
3404/// [`warn_on_secret_shaped_env_grants`], and decided before any session workdir exists, so it goes
3405/// to stderr only.
3406pub fn warn_on_inference_endpoint_in_network_allow(
3407    policy: &CapabilityPolicy,
3408    inference: Option<&InferenceConfig>,
3409) {
3410    for entry in inference_endpoint_allow_entries(&policy.network_allow, inference) {
3411        let link = security_warning_link(W_SEC_025);
3412        eprintln!(
3413            "[capsule-runtime] warning[{W_SEC_025}]: capabilities.network.allow entry '{entry}' \
3414             names the inference endpoint; inference no longer uses it — the runtime reaches the \
3415             provider itself — so the entry now only grants tools, subprocesses and the driver \
3416             direct reach to that host without the key ({link})"
3417        );
3418    }
3419}
3420
3421/// Warns (non-fatal, once) when a `transport: http` capsule's `inference.api_key` can only be read
3422/// at launch: a literal in the manifest, or a `${NAME}` that the global config's `credentials:`
3423/// map does not hold and the environment supplies.
3424///
3425/// Such a capsule keeps the key it launched with, so a key rotated with `mur config set -g
3426/// credentials.NAME` does not reach it until it restarts. Reads only whether `credentials_file`
3427/// holds the name, never prints a value, and stays silent for a `${NAME}` found nowhere — staging
3428/// refuses that one. Shared between `mur run` and `mur doctor` on the same terms as
3429/// [`warn_on_inference_endpoint_in_network_allow`].
3430pub fn warn_on_launch_only_inference_credential(
3431    inference: Option<&InferenceConfig>,
3432    credentials_file: Option<&Path>,
3433) {
3434    let Some(reference) = inference
3435        .filter(|inference| inference.transport == "http")
3436        .and_then(|inference| inference.api_key.as_ref())
3437    else {
3438        return;
3439    };
3440    let (source, name) = match reference {
3441        ApiKeyReference::Literal(_) => (
3442            "inference.api_key is written literally in murmur.yaml".to_string(),
3443            "<NAME>",
3444        ),
3445        ApiKeyReference::Environment(name) => {
3446            if credentials_file.is_some_and(|path| config_holds_credential(path, name))
3447                || std::env::var_os(name).is_none_or(|value| value.is_empty())
3448            {
3449                return;
3450            }
3451            (
3452                format!(
3453                    "inference.api_key: ${{{name}}} is read from the environment variable {name}, \
3454                     because credentials.{name} is not set in the global config"
3455                ),
3456                name.as_str(),
3457            )
3458        }
3459    };
3460    let link = security_warning_link(W_SEC_027);
3461    eprintln!(
3462        "[capsule-runtime] warning[{W_SEC_027}]: {source}, so the key is read once at launch and \
3463         this capsule cannot pick up a rotated key until it is restarted; store the key with \
3464         `mur config set -g credentials.{name} <key>` to have it re-read ({link})"
3465    );
3466}
3467
3468/// Print `W-SEC-026` when `spend.machine_tokens_per_day` is in effect and the capsule uses
3469/// `transport: process`.
3470///
3471/// That transport's CLI reaches its provider with its own credentials, so nothing it spends passes
3472/// the inference gateway and the machine ceiling does not cover it. Shared between `mur run` and
3473/// `mur doctor` on the same terms as [`warn_on_inference_endpoint_in_network_allow`]: decided
3474/// before any session workdir exists, so it goes to stderr only. Never a refusal.
3475pub fn warn_on_machine_spend_ceiling_under_process_transport(
3476    machine_tokens_per_day: Option<u64>,
3477    inference: Option<&InferenceConfig>,
3478) {
3479    if machine_tokens_per_day.is_none()
3480        || !inference.is_some_and(|inference| inference.transport == "process")
3481    {
3482        return;
3483    }
3484    let link = security_warning_link(W_SEC_026);
3485    eprintln!(
3486        "[capsule-runtime] warning[{W_SEC_026}]: spend.machine_tokens_per_day is set and this \
3487         capsule uses transport: process — the CLI reaches its provider with its own credentials, \
3488         so murmur neither counts nor limits this capsule's spend ({link})"
3489    );
3490}
3491
3492/// The `network_allow` entries whose rule matches the `transport: http` inference endpoint, in
3493/// declaration order. An entry that does not parse matches nothing: validation refuses it
3494/// elsewhere.
3495fn inference_endpoint_allow_entries<'a>(
3496    network_allow: &'a [String],
3497    inference: Option<&InferenceConfig>,
3498) -> Vec<&'a str> {
3499    let Some(endpoint) = inference
3500        .filter(|inference| inference.transport == "http")
3501        .and_then(|inference| inference.endpoint.as_deref())
3502    else {
3503        return Vec::new();
3504    };
3505    let Some(target) = endpoint
3506        .parse::<http::Uri>()
3507        .ok()
3508        .and_then(|uri| RequestTarget::from_request(&uri, uri.scheme_str() == Some("https")))
3509    else {
3510        return Vec::new();
3511    };
3512    network_allow
3513        .iter()
3514        .filter(|entry| NetworkAllowRule::parse(entry).is_ok_and(|rule| rule.matches(&target)))
3515        .map(String::as_str)
3516        .collect()
3517}
3518
3519/// Warns (non-fatal, once per allowlisted interpreter) when `capabilities.filesystem.read_only`
3520/// is declared alongside a binary that can construct a write the dispatch-time analyser cannot
3521/// see.
3522///
3523/// The analyser reads a shell call's argv and its `-c` script body. An interpreter's own file I/O
3524/// is in neither: `python3 -c "open(p,'w').write(x)"` is one opaque argument, and nothing in it
3525/// names a redirection or a write verb. The declaration still holds for every call the analyser
3526/// can read and for the whole tool path — this names the one route around the shell half rather
3527/// than leaving an operator to discover it.
3528///
3529/// Deliberately not a refusal: the pairing is legitimate and common, and the answer to it is the
3530/// kernel-backing layer, not a manifest rule. Same seam as [`warn_on_workdir_exec`] — fires at
3531/// staging, before any session workdir exists, so it goes to stderr only.
3532fn warn_on_advisory_read_only(read_only: &[String], shell_allow: &[String]) {
3533    // The same resolver `--explain-scope` and `mur doctor` report from, so the set this warns
3534    // about and the set they print `advisory against` cannot drift apart.
3535    for binary in crate::containment::read_only_advisory_for(read_only, shell_allow) {
3536        let link = security_warning_link(W_SEC_017);
3537        eprintln!(
3538            "[capsule-runtime] warning[{W_SEC_017}]: capabilities.filesystem.read_only is \
3539             declared and capabilities.shell.allow includes '{binary}', an interpreter that \
3540             can construct a write the dispatch check cannot read — the declaration is \
3541             advisory for that binary. It still holds for every tool call and for every \
3542             shell command whose write the dispatch check can identify ({link})"
3543        );
3544    }
3545}
3546
3547/// Warns (non-fatal, once per installed tool) when a capsule that declares
3548/// `capabilities.filesystem.read_only` installs a tool whose `input_schema` leaves a path-shaped
3549/// or destination-shaped property unannotated.
3550///
3551/// Such a property is judged by key name — the analyser guesses whether it is a filesystem
3552/// destination from [`crate::protected_paths::TOOL_PATH_KEYS`] and
3553/// [`crate::protected_paths::TOOL_DESTINATION_KEYS`] — and a guess is wrong in both directions: a
3554/// stored payload carrying a `{file, text}` pair is refused as a write, and a destination under an
3555/// unrecognized name is not checked. The tool's own schema can say which it is.
3556///
3557/// The decision is [`crate::tool_annotations::unannotated_path_properties`], which is per property
3558/// rather than per tool: annotating one property leaves every other one still guessed at, so the
3559/// warning names each of them. A schema-root
3560/// [`crate::tool_annotations::KEYWORD_DESTINATIONS`] list answers for the whole tool and silences
3561/// it; nothing on the capsule's side can.
3562///
3563/// Only the capsule's own installed artifacts are considered. The synthetic manifests the runtime
3564/// writes (the shell binaries, the peer-handoff tools, `delegate-task`) are not an operator's to
3565/// annotate.
3566fn warn_on_unannotated_tool_schemas(installed_manifests: &[(String, String)]) {
3567    for (tool, manifest_yaml) in installed_manifests {
3568        let Some(schema) = crate::tool_annotations::schema_from_manifest_yaml(manifest_yaml) else {
3569            continue;
3570        };
3571        let properties = crate::tool_annotations::unannotated_path_properties(&schema);
3572        if properties.is_empty() {
3573            continue;
3574        }
3575        let named = properties
3576            .iter()
3577            .map(|property| format!("'{property}'"))
3578            .collect::<Vec<_>>()
3579            .join(", ");
3580        let (noun, pronoun) = if properties.len() == 1 {
3581            ("property", "it")
3582        } else {
3583            ("properties", "them")
3584        };
3585        let link = security_warning_link(W_SEC_018);
3586        eprintln!(
3587            "[capsule-runtime] warning[{W_SEC_018}]: capabilities.filesystem.read_only is \
3588             declared and the tool '{tool}' declares the {noun} {named} with no murmur \
3589             annotation in effect — calls naming {pronoun} are judged by key name. Annotate a \
3590             destination property with \"format\": \"{destination}\", any object the tool only \
3591             stores with \"format\": \"{opaque}\", and a destination derived from another \
3592             property — or the absence of any — with the schema-root \"{keyword}\" list ({link})",
3593            destination = crate::tool_annotations::FORMAT_DESTINATION,
3594            opaque = crate::tool_annotations::FORMAT_OPAQUE,
3595            keyword = crate::tool_annotations::KEYWORD_DESTINATIONS,
3596        );
3597    }
3598}
3599
3600/// Warns (non-fatal, once per session) when this host's unprivileged user namespaces are
3601/// unrestricted because `kernel.apparmor_restrict_unprivileged_userns` is off, rather than because
3602/// the shipped `mur-sealed` AppArmor profile is confining this binary.
3603///
3604/// The two hosts back `sealed` and the capsule network namespace equally well, and neither is
3605/// refused. They differ in blast radius: the profile grants one binary permission to create a user
3606/// namespace, while the sysctl grants it to everything on the machine, which is the hardening
3607/// Ubuntu 23.10+ ships on precisely because unprivileged user namespaces are a recurring local
3608/// privilege-escalation surface. Both reach the same achieved class, so without this warning a
3609/// `sealed` result on a weakened host reads in the record exactly like one obtained through the
3610/// mechanism murmur ships.
3611///
3612/// Takes the probed grant rather than probing, so `mur run`'s staging path and `mur doctor` state
3613/// one warning in one wording, and so the decision is testable without a host that has AppArmor.
3614/// Every other grant, including [`UsernsGrant::Withheld`], is silent here — `Withheld` is already
3615/// carried by `E-CAP-003`/`E-CAP-005` where it actually blocks something.
3616pub fn warn_on_userns_restriction_disabled_host_wide(grant: Option<UsernsGrant>) {
3617    if grant != Some(UsernsGrant::RestrictionDisabledHostWide) {
3618        return;
3619    }
3620    let link = security_warning_link(W_SEC_013);
3621    eprintln!(
3622        "[capsule-runtime] warning[{W_SEC_013}]: kernel.apparmor_restrict_unprivileged_userns is \
3623         off on this host, so unprivileged user namespaces are unrestricted for every binary on \
3624         the machine, not just for mur — this is what makes sealed containment and the capsule \
3625         network namespace work here, and it is not the configuration murmur ships. To get the \
3626         narrow, mur-only grant instead: restore the knob to 1 (removing any \
3627         /etc/sysctl.d/*-userns.conf drop-in that sets it to 0), then install and load the shipped \
3628         profile with `sudo install -m 644 packaging/apparmor/{profile} {path} && sudo \
3629         apparmor_parser -r {path}`. Nothing is refused because of this ({link})",
3630        profile = crate::sealed::SEALED_APPARMOR_PROFILE_NAME,
3631        path = crate::sealed::SEALED_APPARMOR_PROFILE_PATH,
3632    );
3633}
3634
3635/// Warns (non-fatal, once per session) when the capsule's own top-level `capabilities.state` is
3636/// declared, because that declaration reaches nothing.
3637///
3638/// A durable state store is granted per artifact — it is the tool, driver or hook entry that gets
3639/// the second preopen, and the capsule's own guest is built with no artifact grant at all. So a
3640/// capsule-wide block creates no directory and opens no `state/` path for anybody. Structurally
3641/// valid, hence warned rather than refused, on the same terms as `W-SEC-006` and `W-SEC-008`; but
3642/// stated plainly, because the alternative signal an operator gets is an empty directory that
3643/// never appears.
3644///
3645/// Same seam as [`warn_on_workdir_exec`]: fires at staging, before any session workdir exists, so
3646/// it goes to stderr only and not to `logs/bootstrap.log`.
3647fn warn_on_inert_capsule_wide_state(state_declared: bool) {
3648    if !state_declared {
3649        return;
3650    }
3651    let link = security_warning_link(W_SEC_014);
3652    eprintln!(
3653        "[capsule-runtime] warning[{W_SEC_014}]: capsule-wide capabilities.state is declared, but \
3654         a durable state store is granted per artifact — nothing reads a top-level declaration, \
3655         so no store was created and no 'state' preopen exists. Move the block onto the tool, \
3656         driver or hook entry that needs it ({link})"
3657    );
3658}
3659
3660/// Warns (non-fatal, once per session) when the capsule's own top-level
3661/// `capabilities.conversation` is declared, because that declaration reaches nothing.
3662///
3663/// The grant is per artifact, on the `runtime: hook` entry whose component imports
3664/// `murmur:conversation/read`; the capsule's own guest holds no artifact grant and compiles
3665/// against a world with no such import. Same seam and same terms as
3666/// [`warn_on_inert_capsule_wide_state`].
3667fn warn_on_inert_capsule_wide_conversation(conversation_declared: bool) {
3668    if !conversation_declared {
3669        return;
3670    }
3671    let link = security_warning_link(W_SEC_016);
3672    eprintln!(
3673        "[capsule-runtime] warning[{W_SEC_016}]: capsule-wide capabilities.conversation is \
3674         declared, but the murmur:conversation/read grant is per artifact — nothing reads a \
3675         top-level declaration, so no artifact can read the conversation record. Move the block \
3676         onto the hook entry that needs it ({link})"
3677    );
3678}
3679
3680/// A per-hook `capabilities:` block reuses the whole [`murmur_artifact::Capabilities`]
3681/// vocabulary, but only `network`, `filesystem`, `state` and `task_io` govern a hook — the rest are
3682/// capsule-wide concerns nothing reads per-artifact. Warn rather than reject (the block is
3683/// structurally valid) so an operator who declared, say, `shell.allow` on a hook entry learns
3684/// it is inert instead of assuming it was applied. Infallible and non-fatal, like
3685/// [`warn_if_bash_network_bypass`], and carries the same `W-SEC-*` registry code + doc link
3686/// convention as every other non-fatal capability warning (see `security_warnings.rs`).
3687///
3688/// Not written to `logs/bootstrap.log`, unlike [`warn_if_bash_network_bypass`]: this fires
3689/// during artifact staging in [`stage_session`], before the session workdir
3690/// `warn_if_bash_network_bypass`/`sandbox::warn_for_enforcement_tier` write into even exists.
3691fn warn_on_inert_hook_capabilities(
3692    hook_name: &str,
3693    capabilities: Option<&murmur_artifact::Capabilities>,
3694) {
3695    let inert = inert_capability_sub_blocks(capabilities);
3696    if !inert.is_empty() {
3697        let link = security_warning_link(W_SEC_006);
3698        eprintln!(
3699            "[capsule-runtime] warning[{W_SEC_006}]: hook '{hook_name}' declares capabilities.{} \
3700             which the runtime does not apply per-hook — only capabilities.network, \
3701             capabilities.filesystem, capabilities.state and capabilities.task_io govern a hook \
3702             ({link})",
3703            inert.join(", capabilities.")
3704        );
3705    }
3706}
3707
3708/// The sub-blocks a per-artifact `capabilities:` grant never reads, whichever role declared
3709/// it. Shared by the hook (`W-SEC-006`) and tool/driver (`W-SEC-008`) warnings, which differ
3710/// only in code and wording — the hazard, and the set of inert keys, is identical.
3711fn inert_capability_sub_blocks(
3712    capabilities: Option<&murmur_artifact::Capabilities>,
3713) -> Vec<&'static str> {
3714    let Some(capabilities) = capabilities else {
3715        return Vec::new();
3716    };
3717
3718    [
3719        ("shell", capabilities.shell.is_some()),
3720        ("spawn", capabilities.spawn.is_some()),
3721        ("env", capabilities.env.is_some()),
3722        ("limits", capabilities.limits.is_some()),
3723        // Same hazard as its siblings: host-process bounds are session-scoped (one cgroup scope,
3724        // one workdir guard, one set of rlimits per spawned process), so a per-artifact or
3725        // per-hook `resources:` block is structurally accepted and silently inert.
3726        ("resources", capabilities.resources.is_some()),
3727        // The containment floor is capsule-wide, resolved before staging — a per-artifact
3728        // declaration of it is read by nothing.
3729        ("containment", capabilities.containment.is_some()),
3730    ]
3731    .into_iter()
3732    .filter_map(|(name, present)| present.then_some(name))
3733    .collect()
3734}
3735
3736/// Lower one tool's or driver's per-artifact grant and record it, warning about anything the
3737/// operator declared that narrowing will not honor.
3738///
3739/// Called from the WASM-tool and driver staging arms only. Inserting nothing when the entry
3740/// declares neither `capabilities:` nor `config:` is what makes the absent case a strict no-op:
3741/// dispatch looks the artifact up by name and falls back to the session's own policy on a miss.
3742///
3743/// The two keys are independent, which is why either one alone stages a grant. `config:` on its
3744/// own narrows nothing and widens nothing — the staged grant equals [`ToolCapabilityGrant`]'s
3745/// [`Default`] in every field but `config_json`, so the artifact keeps inheriting the capsule
3746/// ceiling wholesale and simply gains one environment variable.
3747fn stage_artifact_grant(
3748    artifact: &ArtifactRequest,
3749    ceiling_network_allow_rules: &[NetworkAllowRule],
3750    capsule_name: &str,
3751    artifact_grants: &mut HashMap<String, ToolCapabilityGrant>,
3752) -> Result<(), RuntimeError> {
3753    if artifact.capabilities.is_none() && artifact.config.is_none() {
3754        return Ok(());
3755    }
3756    let capabilities = artifact.capabilities.as_ref();
3757
3758    // Derived from `artifact` — the operator's own manifest entry — and never from the
3759    // artifact's bundled `murmur.yaml`, so a tool pulled from a registry cannot scope itself
3760    // up. `capsule_name` is operator-sourced for the same reason: it is what an undeclared
3761    // `capabilities.state.store` defaults to, and a registry-pulled tool must not be able to
3762    // name the store it lands in. Deriving at staging (not at dispatch) means a malformed grant
3763    // fails the run before any guest starts.
3764    let mut grant =
3765        ToolCapabilityGrant::derive(capabilities, ceiling_network_allow_rules, capsule_name)?;
3766    // The one side effect on this path, and it happens only for an entry that declared a store:
3767    // `derive` validated the name and left the directory to be made here, so lowering stays pure
3768    // and a run that never reaches staging creates nothing on disk.
3769    if let Some(store) = grant.state_store.as_deref() {
3770        grant.state_dir = Some(crate::state_store::ensure_state_store(store)?);
3771    }
3772    // Operator-sourced on the same rule as the grant, and filled here rather than in `derive`
3773    // because `config:` sits beside `capabilities:` in the entry, not inside it.
3774    grant.config_json = artifact
3775        .config
3776        .as_ref()
3777        .map(|config| crate::artifact_config::lower_artifact_config(&artifact.name, config))
3778        .transpose()?;
3779    warn_on_out_of_ceiling_network_entries(&artifact.name, &grant.dropped_network_entries);
3780    warn_on_inert_tool_capabilities(&artifact.name, capabilities);
3781    artifact_grants.insert(artifact.name.clone(), grant);
3782    Ok(())
3783}
3784
3785/// A per-artifact `network.allow` entry the capsule-wide ceiling does not itself allow was
3786/// dropped rather than granted — narrowing only ever subtracts. Non-fatal on purpose: the
3787/// resulting posture is strictly *tighter* than the operator asked for, so failing staging
3788/// would punish a safe mistake, but a silent drop would leave them believing a host is
3789/// reachable when it is not.
3790fn warn_on_out_of_ceiling_network_entries(artifact_name: &str, dropped: &[String]) {
3791    if dropped.is_empty() {
3792        return;
3793    }
3794
3795    let link = security_warning_link(W_SEC_007);
3796    eprintln!(
3797        "[capsule-runtime] warning[{W_SEC_007}]: artifact '{artifact_name}' declares \
3798         capabilities.network.allow entries the capsule-wide ceiling does not allow ({}) — \
3799         they are dropped, not granted, because per-artifact capabilities can only narrow \
3800         ({link})",
3801        dropped.join(", ")
3802    );
3803}
3804
3805/// The tool/driver counterpart of [`warn_on_inert_hook_capabilities`]: a per-artifact grant reads
3806/// only `network`, `filesystem` and `state`, so any other sub-block is structurally valid and
3807/// silently inert. Warn rather than reject, matching how every other capability-posture issue
3808/// is reported.
3809fn warn_on_inert_tool_capabilities(
3810    artifact_name: &str,
3811    capabilities: Option<&murmur_artifact::Capabilities>,
3812) {
3813    let inert = inert_capability_sub_blocks(capabilities);
3814    if !inert.is_empty() {
3815        let link = security_warning_link(W_SEC_008);
3816        eprintln!(
3817            "[capsule-runtime] warning[{W_SEC_008}]: artifact '{artifact_name}' declares \
3818             capabilities.{} which per-artifact narrowing does not apply — only \
3819             capabilities.network, capabilities.filesystem and capabilities.state apply to a tool \
3820             or driver ({link})",
3821            inert.join(", capabilities.")
3822        );
3823    }
3824}
3825
3826/// A `runtime: tool` artifact with a native (non-WASM) implementation runs as a host
3827/// subprocess under the capsule-wide shell/sandbox machinery, never through the WASI tool
3828/// path per-artifact grants are applied on. Declaring one is therefore wholly inert, which is
3829/// a sharper hazard than an inert sub-block and gets the same `W-SEC-008` treatment.
3830fn warn_on_unenforceable_native_capabilities(
3831    artifact_name: &str,
3832    capabilities: Option<&murmur_artifact::Capabilities>,
3833) {
3834    if capabilities.is_none() {
3835        return;
3836    }
3837
3838    let link = security_warning_link(W_SEC_008);
3839    eprintln!(
3840        "[capsule-runtime] warning[{W_SEC_008}]: artifact '{artifact_name}' declares \
3841         per-artifact 'capabilities:' but ships a native implementation — narrowing applies \
3842         only to WASM tools and drivers, so this grant is not enforced ({link})"
3843    );
3844}
3845
3846/// A `runtime: tool` artifact with a native (non-WASM) implementation runs as a host subprocess
3847/// under the capsule-wide shell environment, which is not per-artifact — nothing there would
3848/// deliver `MURMUR_ARTIFACT_CONFIG`, and the runtime will not write an operator's config block
3849/// into a capsule-wide environment to fake it. A declared block is therefore wholly inert, and
3850/// gets the `W-SEC-015` treatment its `capabilities:` sibling above gets.
3851fn warn_on_inert_native_config(artifact_name: &str, config: Option<&serde_yaml::Value>) {
3852    if config.is_none() {
3853        return;
3854    }
3855
3856    let link = security_warning_link(W_SEC_015);
3857    eprintln!(
3858        "[capsule-runtime] warning[{W_SEC_015}]: artifact '{artifact_name}' declares 'config:' \
3859         but ships a native implementation — a native tool runs as a host subprocess and reads no \
3860         per-artifact config, so no MURMUR_ARTIFACT_CONFIG is delivered ({link})"
3861    );
3862}
3863
3864/// Builds the single `Engine` a session runs every guest on.
3865///
3866/// `epoch_interruption` compiles a deadline check into wasm loop back-edges and function
3867/// entries. It only arms the mechanism: a deadline fires solely when some store has called
3868/// `set_epoch_deadline` *and* an [`EpochTicker`] is advancing this engine's epoch, both of
3869/// which `stage_session` sets up.
3870pub(crate) fn build_engine() -> Result<Engine, RuntimeError> {
3871    let mut config = Config::new();
3872    config.wasm_component_model(true);
3873    config.epoch_interruption(true);
3874    Engine::new(&config).map_err(|err| RuntimeError::Runtime(err.to_string()))
3875}
3876
3877/// Map a failed capsule-guest invocation onto the most specific `RuntimeError` available,
3878/// so a deadline or resource-limit trap is distinguishable from a guest panic rather than
3879/// collapsing into the generic `CapsuleTrap` bucket.
3880fn capsule_guest_error(error: &wasmtime::Error, limiter: &ExecutionLimiter) -> RuntimeError {
3881    match classify_guest_failure(error, limiter) {
3882        GuestFailure::DeadlineExceeded { seconds } => {
3883            RuntimeError::CapsuleDeadlineExceeded { seconds }
3884        }
3885        GuestFailure::ResourceLimit { message } => RuntimeError::CapsuleResourceLimit { message },
3886        GuestFailure::Other => RuntimeError::CapsuleTrap(error.to_string()),
3887    }
3888}
3889
3890fn map_registry_error(name: &str, version: &str, error: RegistryError) -> RuntimeError {
3891    match error {
3892        RegistryError::NotFound { .. } => RuntimeError::artifact_not_found(name, version),
3893        RegistryError::IntegrityMismatch { .. } => {
3894            RuntimeError::artifact_integrity_failed(name, version)
3895        }
3896        other => RuntimeError::Runtime(other.to_string()),
3897    }
3898}
3899
3900/// Build a guest's WASI context with a scoped environment: no host inheritance, only what
3901/// `policy.env_allow` declares (credential-filtered) plus the runtime's own `extra_env`.
3902///
3903/// `extra_env` is applied last so a manifest cannot shadow a runtime-owned `MURMUR_*` value
3904/// by allowlisting its name.
3905///
3906/// `filesystem_scope` is a per-artifact narrowing of what gets preopened as `"."`: `None`
3907/// preopens `workdir` itself, which is what every caller without a per-artifact grant passes —
3908/// the wide default, and the threat it is and is not chosen against, are recorded on
3909/// [`crate::network_policy::ToolCapabilityGrant`]. `Some(scope)` preopens `workdir/scope`
3910/// instead, created if missing — already validated as relative and non-escaping by
3911/// [`ToolCapabilityGrant::derive`] at staging time.
3912///
3913/// `state_dir` is the artifact's durable state store, already created at `0700` by the staging
3914/// path. `Some(dir)` adds a *second* preopen at the guest path [`STATE_PREOPEN_NAME`], so the
3915/// guest reaches it as `state/<file>`; it is a host path outside every workdir, and the only one
3916/// a guest can name. `None` — every caller without a `capabilities.state` grant — adds nothing,
3917/// leaving the workdir preopen as the guest's only filesystem reach.
3918///
3919/// `config_json` is this artifact's `config:` block, already lowered to compact JSON at staging.
3920/// `Some` sets exactly one variable, [`ARTIFACT_CONFIG_ENV`]; `None` — every caller whose entry
3921/// declared no `config:` — sets none, so the variable is absent from the guest environment rather
3922/// than present and empty. It is injected after the host allowlist and before `extra_env`, which
3923/// is what makes it runtime-owned: `capabilities.env.allow` cannot supply it (the allowlist skips
3924/// the name outright) and cannot shadow it either.
3925fn build_wasi_ctx(
3926    workdir: &Path,
3927    filesystem_scope: Option<&str>,
3928    state_dir: Option<&Path>,
3929    config_json: Option<&str>,
3930    extra_env: &[(String, String)],
3931    policy: &CapabilityPolicy,
3932) -> Result<WasiCtx, RuntimeError> {
3933    let mut builder = WasiCtxBuilder::new();
3934    builder.inherit_stdio();
3935    for (key, value) in build_wasi_env_allowlist(policy) {
3936        builder.env(key, value);
3937    }
3938    if let Some(config_json) = config_json {
3939        builder.env(ARTIFACT_CONFIG_ENV, config_json);
3940    }
3941    for (key, value) in extra_env {
3942        builder.env(key, value);
3943    }
3944
3945    // Hard error rather than a silent fall back to the unscoped workdir (which would widen
3946    // the grant) or to no preopen at all (which would look like a guest bug).
3947    let preopen_root = match filesystem_scope {
3948        None => workdir.to_path_buf(),
3949        Some(scope) => resolve_scoped_dir(workdir, scope)?,
3950    };
3951
3952    builder
3953        .preopened_dir(&preopen_root, ".", DirPerms::all(), FilePerms::all())
3954        .map_err(|err| RuntimeError::wasi(preopen_root, err.to_string()))?;
3955
3956    if let Some(state_dir) = state_dir {
3957        builder
3958            .preopened_dir(
3959                state_dir,
3960                STATE_PREOPEN_NAME,
3961                DirPerms::all(),
3962                FilePerms::all(),
3963            )
3964            .map_err(|err| RuntimeError::wasi(state_dir.to_path_buf(), err.to_string()))?;
3965    }
3966
3967    Ok(builder.build())
3968}
3969
3970/// The `MURMUR_INFERENCE_*` variables every guest of an inference session sees.
3971///
3972/// Never the key: under `transport: http` the endpoint is the gateway's, and the runtime attaches
3973/// the credential itself. `gateway` is `None` for `transport: process`, whose endpoint is empty.
3974fn inference_env_pairs(
3975    inference: &murmur_artifact::InferenceConfig,
3976    gateway: Option<&InferenceGateway>,
3977) -> Vec<(String, String)> {
3978    let mut pairs = vec![
3979        (
3980            "MURMUR_INFERENCE_TRANSPORT".to_string(),
3981            inference.transport.clone(),
3982        ),
3983        (
3984            "MURMUR_INFERENCE_ENDPOINT".to_string(),
3985            gateway
3986                .map(InferenceGateway::driver_endpoint)
3987                .unwrap_or_default(),
3988        ),
3989        (
3990            "MURMUR_INFERENCE_MODEL".to_string(),
3991            inference.model.clone(),
3992        ),
3993        (
3994            "MURMUR_INFERENCE_DRIVER".to_string(),
3995            inference
3996                .driver
3997                .as_ref()
3998                .map(|d| d.artifact.clone())
3999                .unwrap_or_default(),
4000        ),
4001    ];
4002
4003    if let Some(config) = inference.driver.as_ref().and_then(|d| d.config.as_ref()) {
4004        pairs.push(("MURMUR_INFERENCE_DRIVER_CONFIG".to_string(), config.clone()));
4005    }
4006
4007    pairs
4008}
4009
4010/// Suspend an A2A task in the InputRequired state, await external input, and resume.
4011///
4012/// Called from the `murmur:task/task#request-input` host function registered in the tool linker.
4013/// Emits SSE events for the state transitions. Returns `Err` on timeout (traps the WASM guest).
4014pub(crate) async fn request_input_impl(
4015    task_id: String,
4016    prompt: String,
4017    task_registry: Arc<Mutex<TaskRegistry>>,
4018    sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
4019    input_timeout_secs: Option<u64>,
4020) -> wasmtime::Result<String> {
4021    use std::time::Duration;
4022    use tokio::sync::oneshot;
4023
4024    let (tx, rx) = oneshot::channel::<String>();
4025    let cancel = {
4026        let mut reg = task_registry.lock().unwrap();
4027        reg.set_input_required(&task_id, prompt.clone(), tx)
4028            .map_err(|e| wasmtime::Error::msg(format!("request-input: {e}")))?;
4029        reg.cancel_watch(&task_id)
4030    };
4031
4032    // Use high IDs to avoid overlapping with agent-loop SSE event IDs (which start at 0).
4033    let mut sse_event_id: u64 = u64::MAX / 2;
4034    emit_sse(
4035        &sse,
4036        &mut sse_event_id,
4037        "status",
4038        &TaskStatusUpdateEvent {
4039            id: task_id.clone(),
4040            context_id: None,
4041            status: StreamStatus {
4042                state: "input-required".into(),
4043                message: prompt.clone(),
4044                response: None,
4045            },
4046            r#final: false,
4047        },
4048    )
4049    .await;
4050
4051    // `input-required` is a wait like any other, so a cancel ends it rather than leaving the task
4052    // parked until the input timeout. The guest is unwound by the error either way; what differs
4053    // is which terminal state the task is left in, and who writes it.
4054    let waited = async {
4055        match input_timeout_secs {
4056            Some(secs) => tokio::time::timeout(Duration::from_secs(secs), rx)
4057                .await
4058                .map_err(|_| ())
4059                .and_then(|r| r.map_err(|_| ())),
4060            None => rx.await.map_err(|_| ()),
4061        }
4062    };
4063    let result = tokio::select! {
4064        biased;
4065        () = cancel.canceled() => {
4066            cancel.note_phase(crate::cancel::PHASE_INPUT);
4067            Err(InputWaitEnd::Canceled)
4068        }
4069        waited = waited => waited.map_err(|()| InputWaitEnd::TimedOut),
4070    };
4071
4072    match result {
4073        Ok(text) => {
4074            emit_sse(
4075                &sse,
4076                &mut sse_event_id,
4077                "status",
4078                &TaskStatusUpdateEvent {
4079                    id: task_id.clone(),
4080                    context_id: None,
4081                    status: StreamStatus {
4082                        state: "working".into(),
4083                        message: "resumed".into(),
4084                        response: None,
4085                    },
4086                    r#final: false,
4087                },
4088            )
4089            .await;
4090            Ok(text)
4091        }
4092        // The state and the final status event are the agent loop's to write on this path: it
4093        // takes the cancel at its next boundary and ends the attempt there, with the record, the
4094        // trace and the residue that go with it.
4095        Err(InputWaitEnd::Canceled) => Err(wasmtime::Error::msg("task-canceled")),
4096        Err(InputWaitEnd::TimedOut) => {
4097            {
4098                let mut reg = task_registry.lock().unwrap();
4099                reg.finish_task(TaskState::Failed);
4100                reg.advance_resource_generation();
4101            }
4102            emit_sse(
4103                &sse,
4104                &mut sse_event_id,
4105                "status",
4106                &TaskStatusUpdateEvent {
4107                    id: task_id.clone(),
4108                    context_id: None,
4109                    status: StreamStatus {
4110                        state: "failed".into(),
4111                        message: "input-timeout".into(),
4112                        response: None,
4113                    },
4114                    r#final: true,
4115                },
4116            )
4117            .await;
4118            Err(wasmtime::Error::msg("input-timeout"))
4119        }
4120    }
4121}
4122
4123/// Why a `request-input` wait ended without an answer.
4124///
4125/// The two are not interchangeable: a timeout is this task failing, and a cancel is a person
4126/// stopping it — so only one of them writes a terminal state here.
4127enum InputWaitEnd {
4128    Canceled,
4129    TimedOut,
4130}
4131
4132pub(crate) struct NetworkPolicyHooks {
4133    pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
4134    /// Set only on the configured inference driver's own store. A request addressed to the gateway
4135    /// authority is then sent to the provider by the runtime, on the runtime's grant — neither
4136    /// `network_allow_rules` nor the driver's per-artifact narrowing is consulted for it. Every
4137    /// other request from the same store is checked exactly as it is without a gateway.
4138    pub(crate) inference_gateway: Option<Arc<InferenceGateway>>,
4139}
4140
4141impl WasiHttpHooks for NetworkPolicyHooks {
4142    fn send_request(
4143        &mut self,
4144        request: hyper::Request<HyperOutgoingBody>,
4145        config: OutgoingRequestConfig,
4146    ) -> HttpResult<HostFutureIncomingResponse> {
4147        if let Some(gateway) = self.inference_gateway.as_ref() {
4148            if InferenceGateway::is_addressed_to_gateway(request.uri()) {
4149                // Refused before the key is attached unless some admission is open. The check is
4150                // session-wide, not per-request: it holds only because the gateway is attached to
4151                // the agent loop's driver dispatch and hooks' `run-inference`, both of which admit
4152                // first, and never to a driver reached by name through tool dispatch.
4153                if !gateway.spend.has_open_admission() {
4154                    return Err(wasmtime_wasi_http::p2::HttpError::from(
4155                        WasiHttpErrorCode::HttpRequestDenied,
4156                    ));
4157                }
4158                let gateway = Arc::clone(gateway);
4159                return Ok(HostFutureIncomingResponse::pending(
4160                    wasmtime_wasi::runtime::spawn(async move {
4161                        Ok(gateway.send(request, config).await)
4162                    }),
4163                ));
4164            }
4165        }
4166
4167        let target =
4168            RequestTarget::from_request(request.uri(), config.use_tls).ok_or_else(|| {
4169                wasmtime_wasi_http::p2::HttpError::from(WasiHttpErrorCode::HttpRequestDenied)
4170            })?;
4171
4172        if !self
4173            .network_allow_rules
4174            .iter()
4175            .any(|rule| rule.matches(&target))
4176        {
4177            return Err(wasmtime_wasi_http::p2::HttpError::from(
4178                WasiHttpErrorCode::HttpRequestDenied,
4179            ));
4180        }
4181
4182        Ok(wasmtime_wasi_http::p2::default_send_request(
4183            request, config,
4184        ))
4185    }
4186}
4187
4188pub(crate) struct CapsuleStoreState {
4189    /// Resource limiter for this store, registered via `Store::limiter`. Also the record of
4190    /// any growth request it denied, which `classify_guest_failure` reads to tell a
4191    /// limit trap apart from a guest panic.
4192    pub(crate) limits: ExecutionLimiter,
4193    pub(crate) table: ResourceTable,
4194    pub(crate) wasi: WasiCtx,
4195    pub(crate) http: WasiHttpCtx,
4196    pub(crate) http_hooks: NetworkPolicyHooks,
4197    pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
4198    /// `capabilities.peer_fetch.allow`, parsed. Checked **before** `fetch-peer-file` opens any
4199    /// connection, and never merged with `network_allow_rules`.
4200    pub(crate) peer_fetch_rules: Vec<NetworkAllowRule>,
4201    /// The minting side. `None` — no `exports.peer_files` — means no `share-file` tool manifest
4202    /// was written, so this is the belt to that braces: the dispatch branch refuses rather than
4203    /// assuming the tool could not have been called.
4204    pub(crate) peer_plane: Option<Arc<crate::peer_handoff::PeerPlane>>,
4205    /// This capsule's own audience, asserted on every redeem it issues.
4206    pub(crate) peer_own_audience: String,
4207    /// Where the peer-handoff tools write their records. The concurrent `O_APPEND` sink rather
4208    /// than the loop's `TraceWriter`, which is `&mut`-owned by the loop and out of reach here —
4209    /// so a mint and a fetch land at the moment of the event rather than at the next task
4210    /// boundary.
4211    pub(crate) peer_trace: Option<Arc<crate::trace::ResourceTraceAppender>>,
4212    /// This session's authority to delegate. `None` — no `capabilities.spawn.allow`, so no
4213    /// registration and no credential — means no `delegate-task` tool manifest was written. The
4214    /// dispatch branch still refuses on `None` rather than assuming the tool could not have been
4215    /// called.
4216    pub(crate) delegation: Option<Arc<crate::delegation_plane::DelegationPlane>>,
4217    /// Where a submitted plan's per-step lifecycle records go, or `None` for a session that keeps
4218    /// no trace. A second handle to the same `trace.jsonl` the agent loop's writer holds, for the
4219    /// same reason [`Self::peer_trace`] is one: a plan runs on blocking threads that the loop's
4220    /// writer cannot be lent to.
4221    pub(crate) plan_trace: Option<Arc<crate::trace::PlanTraceAppender>>,
4222    /// How many plans this session has submitted. The plan file is named from this and never from
4223    /// anything the model wrote, so a plan `id` reaches no path.
4224    pub(crate) plan_counter: AtomicU64,
4225    pub(crate) inference_env: Vec<(String, String)>,
4226    /// Moved over from [`StagedSession::inference_gateway`]. Handed only to
4227    /// [`Self::dispatch_driver_async`], and attached by [`invoke_tool_component`] only to the
4228    /// configured driver's store.
4229    pub(crate) inference_gateway: Option<Arc<InferenceGateway>>,
4230    /// Moved over from [`StagedSession::spend`]. The agent loop admits every driver turn against
4231    /// it; hooks' `run-inference` and the gateway hold clones of the same account.
4232    pub(crate) spend: Arc<SpendMeter>,
4233    pub(crate) engine: Engine,
4234    pub(crate) workdir: PathBuf,
4235    pub(crate) accessible_workdir: PathBuf,
4236    pub(crate) tool_components: HashMap<String, Component>,
4237    /// Per-artifact narrowing keyed by artifact name, moved over from
4238    /// [`StagedSession::artifact_grants`]. A name absent here dispatches on the full ceiling.
4239    pub(crate) artifact_grants: HashMap<String, ToolCapabilityGrant>,
4240    pub(crate) allowlisted_tools: HashSet<String>,
4241    pub(crate) installed_artifacts: Vec<InstalledArtifactSummary>,
4242    pub(crate) session_id: String,
4243    /// Buffered outgoing A2A send events — drained into trace.jsonl after the capsule run.
4244    /// (peer_url, message_id, task_id, context_id, traceparent, trust)
4245    pub(crate) pending_a2a_events:
4246        Vec<(String, String, String, String, Option<String>, TrustClass)>,
4247    pub(crate) capability_policy: CapabilityPolicy,
4248    /// The lowered `capabilities.filesystem.read_only` surface, built and validated once at
4249    /// staging. Empty for every capsule that declared nothing, and
4250    /// [`Self::has_protected_paths`] is the single boolean that keeps such a capsule from
4251    /// resolving a call, walking a JSON input or resolving a path at all.
4252    pub(crate) protected_paths: ProtectedPaths,
4253    /// What each staged tool's own `input_schema` declared about where its inputs go. Read only
4254    /// by [`Self::check_protected_paths`], and only to decide *where* the analyser looks — never
4255    /// whether it refuses. Empty whenever [`Self::has_protected_paths`] is false.
4256    pub(crate) tool_annotations: ToolAnnotationMap,
4257    /// Host-detected kernel enforcement tier + resolved network allowlist IPs for this
4258    /// session's shell subprocesses. Kept separate from `CapabilityPolicy` (which stays
4259    /// purely manifest-derived) since this is host-probed data, not manifest data.
4260    pub(crate) shell_enforcement: sandbox::ShellEnforcement,
4261    /// W3C traceparent for outgoing murmur:message/send calls — set by the runtime loop
4262    /// after each begin_session so the active session span propagates to peer capsules.
4263    pub(crate) current_traceparent: Option<String>,
4264    /// Why the task now in scope woke this capsule, set by the task loop beside
4265    /// `current_traceparent`. An outgoing peer message stamps this task's trust class, so the
4266    /// receiver inherits it instead of reclassifying the message as fresh. `None` on the
4267    /// script-capsule path, which runs no task loop and so has no task in scope — that stamps
4268    /// `untrusted`, the safe class.
4269    pub(crate) current_task_provenance: Option<TaskProvenance>,
4270    /// The conversation the task now in scope runs under, set beside `current_task_provenance`
4271    /// at every task-activation site. A demoted command's completion is enqueued under this id,
4272    /// so the result joins the conversation the command was started from.
4273    pub(crate) current_context_id: Option<String>,
4274    /// Every delegation this session started and has not yet closed, by `dlg_` id.
4275    ///
4276    /// Filled from the launch notice, read from three places: the completion that arrives as a
4277    /// task closes its row from the child's own `completion.json`, a cancel names whatever is
4278    /// still in flight, and the plane's own failure path releases a launch that will never
4279    /// report. Shared with the A2A door, which snapshots it without taking any other lock.
4280    pub(crate) live_delegations: Arc<crate::cancel::LiveDelegations>,
4281    // ── Detached shell ───────────────────────────────────────────────────────────
4282    /// Where a demoted command registers itself and delivers its completion. `None` is what
4283    /// keeps a call site foreground-only: the script-capsule path and every test construction
4284    /// run no task loop, so a completion would have nowhere to land.
4285    pub(crate) detached: Option<Arc<DetachedRegistry>>,
4286    /// `lifecycle.shell_grace_secs`: how long a shell command runs in the foreground before it
4287    /// is demoted. Read from the resolved lifecycle once per launch.
4288    pub(crate) shell_grace_secs: u64,
4289    // ── A2A request-input support ────────────────────────────────────────────────
4290    /// Shared task registry — Some in A2A mode, None for script capsules.
4291    pub(crate) a2a_task_registry: Option<Arc<Mutex<TaskRegistry>>>,
4292    /// SSE broadcast channel — Some in A2A mode, None for script capsules.
4293    pub(crate) a2a_sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
4294    /// Active A2A task ID — set per-task before run_agent_loop, None outside A2A context.
4295    pub(crate) a2a_task_id: Option<String>,
4296    /// Optional input timeout from lifecycle config.
4297    pub(crate) input_timeout_secs: Option<u64>,
4298    // ── A2A streaming text chunk support ─────────────────────────────────────────
4299    /// Monotonically increasing event ID counter for text chunk SSE events.
4300    /// Starts at u64::MAX/4 to avoid overlap with agent-loop status/artifact IDs (from 0).
4301    pub(crate) a2a_chunk_event_id: Arc<AtomicU64>,
4302    /// Set to true when any emit-chunk call is made during the current driver dispatch.
4303    /// Reset to false before each driver dispatch in run_agent_loop.
4304    pub(crate) a2a_chunks_emitted: Arc<AtomicBool>,
4305    /// Registry used to resolve additional artifacts requested at runtime via `manage.pull()`.
4306    pub(crate) registry: Arc<dyn Registry>,
4307    /// Path to this session's `murmur.lock`, consulted and updated by `manage.pull()`.
4308    pub(crate) lock_path: PathBuf,
4309    // ── Stateful-driver continuation ───────────────────────────────────
4310    /// Continuation id most recently returned by the inference driver via
4311    /// `tool-result.metadata["continuation_id"]`, held for the current session loop.
4312    /// `None` means "no continuation held" — every Turn resends the full `messages`
4313    /// array (the behavior of every driver shipped today, which never sets the key).
4314    pub(crate) driver_continuation_id: Option<String>,
4315    /// The `context_id` under which `driver_continuation_id` was established. Used as a
4316    /// cross-context safety guard: a held continuation is only reused when the current
4317    /// Turn's `context_id` matches this, so a driver-side continuation from one
4318    /// conversation is never carried into an unrelated Task within the same session loop.
4319    pub(crate) driver_continuation_context_id: Option<String>,
4320    /// Number of leading entries of the logical `messages` array the driver has already
4321    /// acknowledged. On an incremental Turn the host transmits only `messages[acked_len..]`.
4322    pub(crate) driver_continuation_acked_len: usize,
4323}
4324
4325impl CapsuleStoreState {
4326    /// This session's cancel flag for the task now in scope, or `None` outside an A2A task.
4327    ///
4328    /// Both halves are needed: the registry mints the signal and the task id names which task's.
4329    /// The script-capsule path has neither and can cancel nothing.
4330    pub(crate) fn task_cancel_signal(&self) -> Option<crate::cancel::CancelSignal> {
4331        let registry = self.a2a_task_registry.as_ref()?;
4332        let task_id = self.a2a_task_id.as_deref()?;
4333        Some(registry.lock().unwrap().cancel_watch(task_id))
4334    }
4335
4336    /// Returns the held continuation `(id, acked_len)` iff a continuation is currently held
4337    /// **and** was established under `context_id`. The context-id guard is required for
4338    /// correctness: without it, an incremental send against a driver-side continuation from
4339    /// an unrelated conversation would silently corrupt the driver's context.
4340    pub(crate) fn active_continuation(&self, context_id: Option<&str>) -> Option<(&str, usize)> {
4341        let id = self.driver_continuation_id.as_deref()?;
4342        if self.driver_continuation_context_id.as_deref() != context_id {
4343            return None;
4344        }
4345        Some((id, self.driver_continuation_acked_len))
4346    }
4347
4348    /// Persist the continuation id a driver returned on this Turn, scoped to `context_id`,
4349    /// recording that the driver now knows the first `acked_len` entries of `messages`.
4350    pub(crate) fn record_continuation(
4351        &mut self,
4352        id: String,
4353        context_id: Option<String>,
4354        acked_len: usize,
4355    ) {
4356        self.driver_continuation_id = Some(id);
4357        self.driver_continuation_context_id = context_id;
4358        self.driver_continuation_acked_len = acked_len;
4359    }
4360
4361    /// Drop any held continuation id and its bookkeeping. Called when a driver stops
4362    /// returning the key ("not continuing") and at every `replace-context` commit.
4363    pub(crate) fn clear_continuation(&mut self) {
4364        self.driver_continuation_id = None;
4365        self.driver_continuation_context_id = None;
4366        self.driver_continuation_acked_len = 0;
4367    }
4368
4369    /// Advance the acked-message count for a currently-held, same-context continuation.
4370    /// Used when a Task's final `end_turn` persists an extra assistant message into the
4371    /// context history that the driver already knows (it generated it), so the next
4372    /// same-context Task sends only its new user message rather than re-including it.
4373    pub(crate) fn advance_continuation_acked_len(
4374        &mut self,
4375        context_id: Option<&str>,
4376        acked_len: usize,
4377    ) {
4378        if self.driver_continuation_id.is_some()
4379            && self.driver_continuation_context_id.as_deref() == context_id
4380        {
4381            self.driver_continuation_acked_len = acked_len;
4382        }
4383    }
4384}
4385
4386impl WasiView for CapsuleStoreState {
4387    fn ctx(&mut self) -> WasiCtxView<'_> {
4388        WasiCtxView {
4389            ctx: &mut self.wasi,
4390            table: &mut self.table,
4391        }
4392    }
4393}
4394
4395impl WasiHttpView for CapsuleStoreState {
4396    fn http(&mut self) -> WasiHttpCtxView<'_> {
4397        WasiHttpCtxView {
4398            ctx: &mut self.http,
4399            table: &mut self.table,
4400            hooks: &mut self.http_hooks,
4401        }
4402    }
4403}
4404
4405impl invoke::Host for CapsuleStoreState {
4406    fn invoke(
4407        &mut self,
4408        name: String,
4409        input: murmur::tool::run::ToolInput,
4410    ) -> Result<murmur::tool::run::ToolResult, String> {
4411        if !self.allowlisted_tools.contains(&name) {
4412            return Err(format!(
4413                "tool '{name}' is not declared in manifest allowlist"
4414            ));
4415        }
4416        tokio::task::block_in_place(|| {
4417            tokio::runtime::Handle::current().block_on(self.dispatch_tool_async(&name, input))
4418        })
4419    }
4420}
4421
4422impl send::Host for CapsuleStoreState {
4423    fn send(
4424        &mut self,
4425        peer_url: String,
4426        message: send::Message,
4427    ) -> Result<send::TaskResult, String> {
4428        check_destination_allowed(
4429            &self.network_allow_rules,
4430            &peer_url,
4431            "capabilities.network.allow",
4432        )?;
4433
4434        let message_id = message.message_id.clone();
4435        let outgoing_msg = outgoing::OutgoingMessage {
4436            message_id: message.message_id,
4437            context_id: message.context_id,
4438            text: message.text,
4439        };
4440
4441        // send_a2a_message is async; use block_in_place so we can call it from this sync
4442        // host function while inside a multi-thread Tokio runtime (script capsule path).
4443        let traceparent = self.current_traceparent.clone();
4444        // The sending runtime's own current task, read off the store state the task loop writes
4445        // it to. `murmur:message/send` is linked only on the script-capsule path, which runs no
4446        // task loop, so this is `None` there and stamps `untrusted`.
4447        let sender_task = self.current_task_provenance;
4448        let task = tokio::task::block_in_place(|| {
4449            tokio::runtime::Handle::current().block_on(outgoing::send_a2a_message(
4450                &peer_url,
4451                outgoing_msg,
4452                traceparent.clone(),
4453                sender_task,
4454            ))
4455        })?;
4456
4457        self.pending_a2a_events.push((
4458            peer_url.clone(),
4459            message_id,
4460            task.id.clone(),
4461            task.context_id.clone(),
4462            traceparent,
4463            stamp_for_peer(sender_task).trust(),
4464        ));
4465        Ok(send::TaskResult {
4466            task_id: task.id,
4467            context_id: task.context_id,
4468            state: match task.status.state {
4469                crate::a2a::TaskState::Submitted => "submitted".to_string(),
4470                crate::a2a::TaskState::Working => "working".to_string(),
4471                crate::a2a::TaskState::InputRequired => "input-required".to_string(),
4472                crate::a2a::TaskState::Completed => "completed".to_string(),
4473                crate::a2a::TaskState::Failed => "failed".to_string(),
4474                crate::a2a::TaskState::Rejected => "rejected".to_string(),
4475                crate::a2a::TaskState::Canceled => "canceled".to_string(),
4476            },
4477        })
4478    }
4479}
4480
4481impl manage::Host for CapsuleStoreState {
4482    fn list(&mut self) -> Vec<manage::ArtifactSummary> {
4483        let mut result: Vec<manage::ArtifactSummary> = self
4484            .installed_artifacts
4485            .iter()
4486            .filter(|a| a.runtime.is_llm_visible())
4487            .map(|artifact| manage::ArtifactSummary {
4488                name: artifact.name.clone(),
4489                version: artifact.version.clone(),
4490                runtime: runtime_type_to_wit(&artifact.runtime, artifact.implementation.as_ref()),
4491            })
4492            .collect();
4493
4494        let existing: std::collections::HashSet<String> =
4495            result.iter().map(|a| a.name.clone()).collect();
4496        for binary in &self.capability_policy.shell_allow {
4497            if !existing.contains(binary) {
4498                result.push(manage::ArtifactSummary {
4499                    name: binary.clone(),
4500                    version: "0.0.0".to_string(),
4501                    runtime: manage::RuntimeType::Native,
4502                });
4503            }
4504        }
4505
4506        result
4507    }
4508
4509    fn describe(&mut self, name: String) -> Result<manage::ArtifactInfo, String> {
4510        // Check installed artifacts first, then fall back to shell tool manifests on disk.
4511        let fallback_summary;
4512        let summary = if let Some(s) = self
4513            .installed_artifacts
4514            .iter()
4515            .find(|artifact| artifact.name == name)
4516        {
4517            s
4518        } else if self
4519            .capability_policy
4520            .shell_allow
4521            .iter()
4522            .any(|b| b == &name)
4523        {
4524            fallback_summary = InstalledArtifactSummary {
4525                name: name.clone(),
4526                version: "0.0.0".to_string(),
4527                runtime: ArtifactRuntime::Tool,
4528                implementation: Some(ArtifactImplementation::Native),
4529            };
4530            &fallback_summary
4531        } else {
4532            return Err(format!("artifact '{name}' is not installed"));
4533        };
4534
4535        let manifest_path = self
4536            .workdir
4537            .join("tools")
4538            .join(&name)
4539            .join(PACKED_MANIFEST_ENTRY);
4540
4541        read_artifact_info_from_manifest(&manifest_path, summary)
4542    }
4543
4544    fn search(&mut self, query: String) -> Result<Vec<manage::ArtifactSummary>, String> {
4545        Err(format!("not implemented (query: {query})"))
4546    }
4547
4548    fn pull(&mut self, name: String, version: String) -> Result<manage::ArtifactSummary, String> {
4549        // 1. Resolve + verify against the registry's own self-reported hash.
4550        let resolved = self
4551            .registry
4552            .resolve_with_platform(&name, &version, Some(current_platform()))
4553            .map_err(|err| format!("failed to resolve {name}@{version}: {err}"))?;
4554
4555        verify_sha256(&name, &version, &resolved.bytes, &resolved.sha256).map_err(|_| {
4556            format!("artifact integrity check failed for {name}@{version}: registry-reported hash does not match downloaded bytes")
4557        })?;
4558
4559        // 2. Cross-check against any existing murmur.lock pin — a runtime pull must never
4560        // silently override what's already pinned for this artifact.
4561        let mut lock = match read_lockfile(&self.lock_path) {
4562            Ok(lock) => lock,
4563            Err(LockfileError::NotFound(_)) => MurmurLock {
4564                lock_version: LOCK_VERSION,
4565                artifacts: Vec::new(),
4566            },
4567            Err(err) => return Err(format!("failed to read murmur.lock: {err}")),
4568        };
4569
4570        // Which shape the pin takes follows the payload: a native binary is pinned under this
4571        // host's platform, everything else under `any`. An entry that has no key for this
4572        // platform yet is not a conflict — it is a platform nobody has installed on before.
4573        let incoming_sha256 = if resolved.meta.runtime == RuntimeType::Native {
4574            LockedSha256::for_one_platform(current_platform(), resolved.sha256.clone())
4575        } else {
4576            LockedSha256::any(resolved.sha256.clone())
4577        };
4578
4579        if let Some(existing) = lock.artifact_for(&name) {
4580            if let Some(conflict) = existing.conflict_with(&version, &incoming_sha256) {
4581                return Err(format!(
4582                    "murmur.lock conflict for '{name}': {conflict} — refusing to override a \
4583                     pinned artifact at runtime"
4584                ));
4585            }
4586        }
4587
4588        // 3. Extract murmur.yaml, dispatch extraction by runtime type, and write files under
4589        // <workdir>/tools/<name>/ — no disk writes happen before steps 1-2 succeed.
4590        let manifest_yaml = extract_manifest_yaml(&name, &version, &resolved.bytes)
4591            .map_err(|err| err.to_string())?;
4592        write_tool_manifest(&self.workdir, &name, &manifest_yaml).map_err(|err| err.to_string())?;
4593
4594        let (artifact_runtime, implementation, wasm_component) = match resolved.meta.runtime {
4595            RuntimeType::Wasm => {
4596                let wasm_bytes = extract_root_wasm(&name, &version, &resolved.bytes)
4597                    .map_err(|err| err.to_string())?;
4598                let component = Component::new(&self.engine, &wasm_bytes)
4599                    .map_err(|err| format!("failed to compile pulled component '{name}': {err}"))?;
4600                (
4601                    ArtifactRuntime::Tool,
4602                    Some(ArtifactImplementation::Wasm),
4603                    Some(component),
4604                )
4605            }
4606            RuntimeType::Native => {
4607                let binary = extract_native_binary(&name, &version, &resolved.bytes)
4608                    .map_err(|err| err.to_string())?;
4609                install_native_binaries(&self.workdir, vec![(name.clone(), binary)])
4610                    .map_err(|err| err.to_string())?;
4611                (
4612                    ArtifactRuntime::Tool,
4613                    Some(ArtifactImplementation::Native),
4614                    None,
4615                )
4616            }
4617            RuntimeType::Static => {
4618                let skill_md = extract_skill_md(&name, &version, &resolved.bytes)
4619                    .map_err(|err| err.to_string())?;
4620                install_skill_files(&self.workdir, vec![(name.clone(), skill_md)])
4621                    .map_err(|err| err.to_string())?;
4622                (ArtifactRuntime::Skill, None, None)
4623            }
4624        };
4625
4626        // 4. Files are on disk — now, and only now, update murmur.lock.
4627        lock.upsert(&name, &version, incoming_sha256);
4628        write_lockfile_atomic(&self.lock_path, &lock)
4629            .map_err(|err| format!("failed to write murmur.lock: {err}"))?;
4630
4631        // 5. Reflect the pulled artifact in in-memory session state so list()/describe() (and,
4632        // for WASM tools, invoke()) see it immediately.
4633        if let Some(component) = wasm_component {
4634            self.tool_components.insert(name.clone(), component);
4635        }
4636
4637        let summary = InstalledArtifactSummary {
4638            name: name.clone(),
4639            version: version.clone(),
4640            runtime: artifact_runtime,
4641            implementation,
4642        };
4643        if let Some(existing) = self
4644            .installed_artifacts
4645            .iter_mut()
4646            .find(|artifact| artifact.name == name)
4647        {
4648            *existing = summary.clone();
4649        } else {
4650            self.installed_artifacts.push(summary.clone());
4651        }
4652
4653        Ok(manage::ArtifactSummary {
4654            name,
4655            version,
4656            runtime: runtime_type_to_wit(&summary.runtime, summary.implementation.as_ref()),
4657        })
4658    }
4659
4660    fn remove(&mut self, name: String) -> Result<bool, String> {
4661        Err(format!("not implemented (name: {name})"))
4662    }
4663
4664    fn diagnostics(&mut self) -> Result<manage::RuntimeState, String> {
4665        Ok(manage::RuntimeState {
4666            capsule_id: self.session_id.clone(),
4667            installed: self.list(),
4668            capabilities: "artifact-manager/search and remove are not implemented".to_string(),
4669        })
4670    }
4671}
4672
4673/// The gateway a store for artifact `name` is built with: the session's, and only when `name` is
4674/// the driver it was built for. Every other tool, hook and capsule store gets none, so a request
4675/// they address to the gateway authority is an ordinary allow-list-checked request with no key.
4676fn gateway_for_store(
4677    gateway: Option<&Arc<InferenceGateway>>,
4678    name: &str,
4679) -> Option<Arc<InferenceGateway>> {
4680    gateway
4681        .filter(|gateway| gateway.driver_name == name)
4682        .cloned()
4683}
4684
4685/// Builds the session's inference gateway from the configured driver's bundled `inference_auth:`
4686/// block, or refuses the launch with [`RuntimeError::DriverDeclaresNoInferenceAuth`].
4687///
4688/// `None` for `transport: process`, for no inference, and for a driver that is not among the staged
4689/// artifacts — dispatch already refuses that one by name. The refusal depends on the declaration
4690/// alone, never on whether `inference.api_key` is set.
4691///
4692/// A `transport: http` `inference.api_key` is resolved first, against `credentials_file` and the
4693/// environment, so a credential found nowhere refuses the launch with
4694/// [`RuntimeError::InferenceCredentialNotFound`] even when no gateway would be built.
4695fn stage_inference_gateway(
4696    inference: Option<&InferenceConfig>,
4697    credentials_file: Option<&Path>,
4698    installed_manifests: &[(String, String)],
4699    installed_artifacts: &[InstalledArtifactSummary],
4700    spend: &Arc<SpendMeter>,
4701) -> Result<Option<Arc<InferenceGateway>>, RuntimeError> {
4702    let Some(inference) = inference.filter(|inference| inference.transport == "http") else {
4703        return Ok(None);
4704    };
4705    let credential = inference
4706        .api_key
4707        .as_ref()
4708        .map(|reference| InferenceCredential::resolve(reference, credentials_file).map(Arc::new))
4709        .transpose()?;
4710    let (Some(driver), Some(endpoint)) = (inference.driver.as_ref(), inference.endpoint.as_deref())
4711    else {
4712        return Ok(None);
4713    };
4714    let Some((_, manifest_yaml)) = installed_manifests
4715        .iter()
4716        .find(|(name, _)| *name == driver.artifact)
4717    else {
4718        return Ok(None);
4719    };
4720    let refuse = |reason: Option<String>| RuntimeError::DriverDeclaresNoInferenceAuth {
4721        name: driver.artifact.clone(),
4722        version: installed_artifacts
4723            .iter()
4724            .find(|artifact| artifact.name == driver.artifact)
4725            .map(|artifact| artifact.version.clone())
4726            .unwrap_or_default(),
4727        reason,
4728    };
4729    let auth = match murmur_artifact::parse_inference_auth(manifest_yaml) {
4730        Ok(Some(auth)) => auth,
4731        Ok(None) => return Err(refuse(None)),
4732        Err(err) => return Err(refuse(Some(err.to_string()))),
4733    };
4734    InferenceGateway::new(
4735        driver.artifact.clone(),
4736        endpoint,
4737        auth,
4738        credential,
4739        Arc::clone(spend),
4740    )
4741    .map(|gateway| Some(Arc::new(gateway)))
4742}
4743
4744/// Builds the session's spend account: `inference.max_session_tokens` as its session ceiling and,
4745/// for a `transport: http` session with a driver, the shared daily ledger when
4746/// `machine_tokens_per_day` is set.
4747///
4748/// Refuses with [`RuntimeError::SpendLedgerUnavailable`] when that ledger cannot be opened. A
4749/// `transport: process` session holds no key and makes no call the runtime can meter, so it keeps
4750/// no ledger whatever the operator set.
4751fn stage_spend_meter(
4752    inference: Option<&InferenceConfig>,
4753    machine_tokens_per_day: Option<u64>,
4754    session_id: &str,
4755) -> Result<Arc<SpendMeter>, RuntimeError> {
4756    let metered = inference.filter(|inference| {
4757        inference.transport == "http"
4758            && inference
4759                .driver
4760                .as_ref()
4761                .is_some_and(|driver| !driver.artifact.is_empty())
4762    });
4763    let machine = match (metered, machine_tokens_per_day) {
4764        (Some(_), Some(ceiling)) => Some(MachineLedger::open(session_id, ceiling)?),
4765        _ => None,
4766    };
4767    Ok(Arc::new(SpendMeter::new(
4768        metered.and_then(|inference| inference.max_session_tokens),
4769        machine,
4770    )))
4771}
4772
4773/// Borrowed half of a WASM tool invocation environment: everything
4774/// [`invoke_tool_component`] needs that is neither the component nor the A2A
4775/// wiring. Grouped into a struct so the hook runtime can assemble one from its
4776/// own owned copies without a >7-argument function.
4777pub(crate) struct ToolInvokeEnv<'a> {
4778    pub(crate) engine: &'a Engine,
4779    pub(crate) accessible_workdir: &'a Path,
4780    pub(crate) inference_env: &'a [(String, String)],
4781    pub(crate) capability_policy: &'a CapabilityPolicy,
4782    /// The capsule-wide ceiling. What actually gets enforced is this, clamped by
4783    /// `artifact_grant` when the operator declared one.
4784    pub(crate) network_allow_rules: &'a [NetworkAllowRule],
4785    /// This artifact's optional narrowing, from the operator's own manifest entry. `None` —
4786    /// no `capabilities:` block on the entry — means the ceiling applies untouched and the
4787    /// whole `accessible_workdir` is preopened, exactly as before narrowing existed.
4788    pub(crate) artifact_grant: Option<&'a ToolCapabilityGrant>,
4789    /// The session's inference gateway, attached to this store only when `name` is the driver it
4790    /// was built for.
4791    pub(crate) inference_gateway: Option<&'a Arc<InferenceGateway>>,
4792}
4793
4794/// Per-session A2A wiring registered on a tool linker.
4795///
4796/// The two host interfaces it backs (`murmur:text/chunks`, `murmur:task/task`)
4797/// are always *defined* — a streaming driver imports them and would fail to
4798/// instantiate otherwise — but each function is a no-op when its channel is
4799/// absent. [`ToolA2aWiring::silent`] is that all-absent form, used for a
4800/// dispatch that is not part of an A2A task turn (a hook's `run-inference`
4801/// call, which must not stream chunks into the user's SSE stream or ask the
4802/// user for input).
4803pub(crate) struct ToolA2aWiring {
4804    sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
4805    task_id: Option<String>,
4806    chunk_event_id: Arc<AtomicU64>,
4807    chunks_emitted: Arc<AtomicBool>,
4808    task_registry: Option<Arc<Mutex<TaskRegistry>>>,
4809    input_timeout_secs: Option<u64>,
4810}
4811
4812impl ToolA2aWiring {
4813    pub(crate) fn silent() -> Self {
4814        Self {
4815            sse: None,
4816            task_id: None,
4817            chunk_event_id: Arc::new(AtomicU64::new(0)),
4818            chunks_emitted: Arc::new(AtomicBool::new(false)),
4819            task_registry: None,
4820            input_timeout_secs: None,
4821        }
4822    }
4823}
4824
4825/// Instantiate `component` in a fresh `Linker`/`Store` and call its
4826/// `murmur:tool/run@0.1.0#run` export.
4827///
4828/// This is the single WASM-tool (and therefore inference-driver) invocation
4829/// body in the runtime. [`CapsuleStoreState::dispatch_tool_async`] and
4830/// [`CapsuleStoreState::dispatch_driver_async`] are thin wrappers that fill `env`/`a2a` from the capsule store; a hook's
4831/// `run-inference` host import fills them from its own owned copies. Neither
4832/// duplicates any part of the instantiate/type-check/call sequence below.
4833pub(crate) async fn invoke_tool_component(
4834    env: ToolInvokeEnv<'_>,
4835    a2a: ToolA2aWiring,
4836    name: &str,
4837    component: &Component,
4838    input: murmur::tool::run::ToolInput,
4839) -> Result<murmur::tool::run::ToolResult, String> {
4840    let ToolInvokeEnv {
4841        engine,
4842        accessible_workdir,
4843        inference_env,
4844        capability_policy,
4845        network_allow_rules,
4846        artifact_grant,
4847        inference_gateway,
4848    } = env;
4849    let ToolA2aWiring {
4850        sse: a2a_sse,
4851        task_id: a2a_task_id,
4852        chunk_event_id: a2a_chunk_event_id,
4853        chunks_emitted: a2a_chunks_emitted,
4854        task_registry: a2a_task_registry,
4855        input_timeout_secs,
4856    } = a2a;
4857
4858    let mut linker = Linker::new(engine);
4859    wasmtime_wasi::p2::add_to_linker_async(&mut linker)
4860        .map_err(|err| format!("failed to add WASI linker for tool '{name}': {err}"))?;
4861    wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
4862        .map_err(|err| format!("failed to add HTTP linker for tool '{name}': {err}"))?;
4863
4864    // Register murmur:text/chunks host functions (synchronous).
4865    // Components that do not import this interface ignore the registrations.
4866    // Both functions must be defined in a single .instance() call — Wasmtime
4867    // rejects a second .instance() for the same interface name. Registered
4868    // under the versioned name only (see WIT_TEXT_CHUNKS_IFACE / wit/VERSIONING.md).
4869    {
4870        let chunks_iface = WIT_TEXT_CHUNKS_IFACE;
4871        let sse_for_chunk = a2a_sse.clone();
4872        let task_id_for_chunk = a2a_task_id.clone();
4873        let chunk_event_id = Arc::clone(&a2a_chunk_event_id);
4874        let chunks_emitted_flag = Arc::clone(&a2a_chunks_emitted);
4875        let sse_for_thinking = a2a_sse.clone();
4876        let task_id_for_thinking = a2a_task_id.clone();
4877        let thinking_event_id = Arc::clone(&a2a_chunk_event_id);
4878
4879        let mut inst = linker.instance(chunks_iface).map_err(|err| {
4880            format!("failed to define {chunks_iface} instance for '{name}': {err}")
4881        })?;
4882
4883        inst.func_wrap(
4884            "emit-chunk",
4885            move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>, (chunk,): (String,)| {
4886                chunks_emitted_flag.store(true, Ordering::Relaxed);
4887                if let (Some((ref tx, ref buf)), Some(ref tid)) =
4888                    (&sse_for_chunk, &task_id_for_chunk)
4889                {
4890                    emit_chunk_sse(tx, buf, &chunk_event_id, tid, &chunk);
4891                }
4892                Ok(())
4893            },
4894        )
4895        .map_err(|err| format!("failed to register emit-chunk for tool '{name}': {err}"))?;
4896
4897        inst.func_wrap(
4898            "emit-thinking-chunk",
4899            move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>, (chunk,): (String,)| {
4900                if let (Some((ref tx, ref buf)), Some(ref tid)) =
4901                    (&sse_for_thinking, &task_id_for_thinking)
4902                {
4903                    emit_thinking_chunk_sse(tx, buf, &thinking_event_id, tid, &chunk);
4904                }
4905                Ok(())
4906            },
4907        )
4908        .map_err(|err| {
4909            format!("failed to register emit-thinking-chunk for tool '{name}': {err}")
4910        })?;
4911    }
4912
4913    // Register the murmur:task/task#request-input host function under the
4914    // versioned name only (see WIT_TASK_IFACE).
4915    // Components that do not import this interface ignore the registration.
4916    {
4917        let task_iface = WIT_TASK_IFACE;
4918        let ri_task_registry = a2a_task_registry.clone();
4919        let ri_sse = a2a_sse.clone();
4920        let ri_task_id = a2a_task_id.clone();
4921        let ri_timeout = input_timeout_secs;
4922        linker
4923            .instance(task_iface)
4924            .map_err(|err| format!("failed to define {task_iface} instance for '{name}': {err}"))?
4925            .func_wrap_async(
4926                "request-input",
4927                move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>,
4928                      (prompt,): (String,)| {
4929                    let reg = ri_task_registry.clone();
4930                    let sse = ri_sse.clone();
4931                    let tid = ri_task_id.clone();
4932                    let fut: std::pin::Pin<
4933                        Box<dyn std::future::Future<Output = wasmtime::Result<(String,)>> + Send>,
4934                    > = Box::pin(async move {
4935                        let result = match (reg, tid) {
4936                            (Some(reg), Some(tid)) => {
4937                                request_input_impl(tid, prompt, reg, sse, ri_timeout).await
4938                            }
4939                            _ => Err(wasmtime::Error::msg(
4940                                "request-input is not available outside an A2A task context",
4941                            )),
4942                        };
4943                        result.map(|s| (s,))
4944                    });
4945                    Box::new(fut)
4946                        as Box<
4947                            dyn std::future::Future<Output = wasmtime::Result<(String,)>>
4948                                + Send
4949                                + '_,
4950                        >
4951                },
4952            )
4953            .map_err(|err| format!("failed to register request-input for tool '{name}': {err}"))?;
4954    }
4955
4956    let tool_limits = capability_policy.limits;
4957    // Both halves of the grant are resolved here, on the one path every WASM tool and the
4958    // inference driver share, so a driver needs no enforcement code of its own.
4959    let effective_network_rules = effective_tool_network_rules(artifact_grant, network_allow_rules);
4960    let filesystem_scope = artifact_grant.and_then(|grant| grant.filesystem_scope.as_deref());
4961    // Absent for every artifact that declared no `capabilities.state`, so an undeclared tool is
4962    // built with the workdir preopen and nothing else.
4963    let state_dir = artifact_grant.and_then(|grant| grant.state_dir.as_deref());
4964    // Absent for every artifact that declared no `config:`, so an unconfigured tool is built with
4965    // no `MURMUR_ARTIFACT_CONFIG` in its environment at all. Read off this artifact's own grant,
4966    // which is what keeps one artifact's config out of every other artifact's guest.
4967    let config_json = artifact_grant.and_then(|grant| grant.config_json.as_deref());
4968    let state = ToolStoreState {
4969        limits: tool_limits.limiter(),
4970        table: ResourceTable::new(),
4971        wasi: build_wasi_ctx(
4972            accessible_workdir,
4973            filesystem_scope,
4974            state_dir,
4975            config_json,
4976            inference_env,
4977            capability_policy,
4978        )
4979        .map_err(|err| format!("failed to build WASI context for tool '{name}': {err}"))?,
4980        http: WasiHttpCtx::new(),
4981        http_hooks: NetworkPolicyHooks {
4982            network_allow_rules: effective_network_rules.to_vec(),
4983            inference_gateway: gateway_for_store(inference_gateway, name),
4984        },
4985    };
4986
4987    let mut store = Store::new(engine, state);
4988    // Registered before instantiation — see the capsule store for why.
4989    store.limiter(|state| &mut state.limits);
4990
4991    store.set_epoch_deadline(tool_limits.deadline_ticks());
4992    let instance = linker
4993        .instantiate_async(&mut store, component)
4994        .await
4995        .map_err(|err| format!("failed to instantiate tool '{name}': {err}"))?;
4996
4997    let tool_iface = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED)
4998        .ok_or_else(|| {
4999            RuntimeError::ToolExportMissing {
5000                name: name.to_string(),
5001            }
5002            .to_string()
5003        })?;
5004    let tool_run = instance
5005        .get_export_index(&mut store, Some(&tool_iface), "run")
5006        .and_then(|idx| instance.get_func(&mut store, idx))
5007        .ok_or_else(|| {
5008            RuntimeError::ToolExportMissing {
5009                name: name.to_string(),
5010            }
5011            .to_string()
5012        })?;
5013
5014    let run = tool_run
5015        .typed::<(murmur::tool::run::ToolInput,), (murmur::tool::run::ToolResult,)>(&store)
5016        .map_err(|err| format!("failed to type-check tool '{name}' run export: {err}"))?;
5017
5018    // Fresh budget for `run` itself, so instantiation cost cannot eat into it. This is
5019    // also the driver path (`agent.rs` dispatches the inference driver through here).
5020    store.set_epoch_deadline(tool_limits.deadline_ticks());
5021    let called = run.call_async(&mut store, (input,)).await;
5022    let (result,) = match called {
5023        Ok(result) => result,
5024        Err(err) => {
5025            // Classified rather than folded into the generic "trapped" string, so a
5026            // deadline or limit trap is distinguishable on a path that reports failures
5027            // as plain text. `Other` reproduces the pre-slice wording verbatim.
5028            let failure = classify_guest_failure(&err, &store.data().limits);
5029            return Err(failure.message(&format!("tool '{name}'"), &err));
5030        }
5031    };
5032    Ok(result)
5033}
5034
5035/// Reduce a dispatched tool result to the one string the model will read, and fence it.
5036///
5037/// The `data` / `summary` reduction happens here, once, rather than at each model-facing caller,
5038/// and both fields are consumed: `data` carries the fenced text afterwards and `summary` is left
5039/// `None`, so the `data.or(summary)` both callers write has nothing unfenced to fall back to.
5040///
5041/// `status`, `data_path`, `truncated` and `metadata` are untouched: they are the tool's own
5042/// declarations about the call, not content shown to the model.
5043fn fence_tool_result(name: &str, result: &mut murmur::tool::run::ToolResult) {
5044    let data = result.data.take();
5045    let summary = result.summary.take();
5046    let text = data
5047        .or(summary)
5048        .unwrap_or_else(|| "tool returned no data".to_string());
5049    result.data = Some(crate::fence::wrap_untrusted(
5050        &crate::fence::tool_source(name),
5051        &text,
5052    ));
5053}
5054
5055impl CapsuleStoreState {
5056    /// Async WASM tool dispatch for a guest's `invoke` and the model's tool calls. Never carries the
5057    /// inference gateway: a driver reached by name here gets no keyed route to its provider, and
5058    /// so cannot spend outside an admission.
5059    pub(crate) async fn dispatch_tool_async(
5060        &self,
5061        name: &str,
5062        input: murmur::tool::run::ToolInput,
5063    ) -> Result<murmur::tool::run::ToolResult, String> {
5064        self.dispatch_component_async(name, input, None).await
5065    }
5066
5067    /// The agent loop's driver turn: the one dispatch the inference gateway is attached to, and
5068    /// only under the spend admission the loop opened for it.
5069    pub(crate) async fn dispatch_driver_async(
5070        &self,
5071        name: &str,
5072        input: murmur::tool::run::ToolInput,
5073    ) -> Result<murmur::tool::run::ToolResult, String> {
5074        self.dispatch_component_async(name, input, self.inference_gateway.as_ref())
5075            .await
5076    }
5077
5078    async fn dispatch_component_async(
5079        &self,
5080        name: &str,
5081        input: murmur::tool::run::ToolInput,
5082        inference_gateway: Option<&Arc<InferenceGateway>>,
5083    ) -> Result<murmur::tool::run::ToolResult, String> {
5084        let Some(component) = self.tool_components.get(name) else {
5085            return Err(format!("tool '{name}' is not available in this session"));
5086        };
5087        invoke_tool_component(
5088            ToolInvokeEnv {
5089                engine: &self.engine,
5090                accessible_workdir: &self.accessible_workdir,
5091                inference_env: &self.inference_env,
5092                capability_policy: &self.capability_policy,
5093                network_allow_rules: &self.network_allow_rules,
5094                // Absent for every artifact that declared no `capabilities:` block, and for
5095                // anything pulled in at runtime via `manage.pull()` (which has no operator
5096                // manifest entry to narrow from) — both keep the full ceiling.
5097                artifact_grant: self.artifact_grants.get(name),
5098                inference_gateway,
5099            },
5100            ToolA2aWiring {
5101                sse: self.a2a_sse.clone(),
5102                task_id: self.a2a_task_id.clone(),
5103                chunk_event_id: Arc::clone(&self.a2a_chunk_event_id),
5104                chunks_emitted: Arc::clone(&self.a2a_chunks_emitted),
5105                task_registry: self.a2a_task_registry.clone(),
5106                input_timeout_secs: self.input_timeout_secs,
5107            },
5108            name,
5109            component,
5110            input,
5111        )
5112        .await
5113    }
5114
5115    /// Resolve what a tool call is about to run, for the policy decision point.
5116    ///
5117    /// Routes exactly as [`Self::dispatch_agent_tool_unfenced`] does, in the same order, so the
5118    /// hook is shown the branch that will actually be taken. Everything that is not a shell
5119    /// call — a runtime peer-handoff tool, a native artifact binary, a skill, a WASM tool —
5120    /// resolves to [`ResolvedCall::Tool`] carrying the exact input JSON.
5121    ///
5122    /// Read-only and side-effect free: it decides nothing and grants nothing, and the capability
5123    /// checks it routes past are still performed by the dispatch path itself.
5124    pub(crate) fn resolve_call(
5125        &self,
5126        name: &str,
5127        input: &murmur::tool::run::ToolInput,
5128    ) -> ResolvedCall {
5129        let as_tool = || {
5130            let json = input.data.clone().unwrap_or_default();
5131            ResolvedCall::Tool {
5132                tool_name: name.to_string(),
5133                input_bytes: json.len() as u64,
5134                input: json,
5135            }
5136        };
5137        if name == SHARE_FILE_TOOL
5138            || name == FETCH_PEER_FILE_TOOL
5139            || name == DELEGATE_TASK_TOOL
5140            || name == SUBMIT_PLAN_TOOL
5141        {
5142            return as_tool();
5143        }
5144        let native_bin = self.workdir.join("tools").join(name).join(name);
5145        if native_bin.exists() && !self.tool_components.contains_key(name) {
5146            return as_tool();
5147        }
5148        match resolve_shell_call(
5149            name,
5150            input,
5151            &self.accessible_workdir,
5152            &self.capability_policy,
5153        ) {
5154            Some(ResolvedShellCall {
5155                binary,
5156                command,
5157                argv,
5158                script,
5159                recipe,
5160            }) => ResolvedCall::Shell {
5161                binary,
5162                command,
5163                argv,
5164                script,
5165                recipe,
5166            },
5167            None => as_tool(),
5168        }
5169    }
5170
5171    /// Whether this capsule declared any `capabilities.filesystem.read_only` entry.
5172    ///
5173    /// The one branch the dispatch path takes on: `false` means no call is resolved, no path is
5174    /// resolved, no JSON input is walked and no analyser pass runs, so a capsule that declared
5175    /// nothing pays nothing.
5176    pub(crate) fn has_protected_paths(&self) -> bool {
5177        !self.protected_paths.is_empty()
5178    }
5179
5180    /// The manifest's own answer to "does this call write a declared read-only path?".
5181    ///
5182    /// Grants nothing and widens nothing: the only outcome it can produce is that a call does not
5183    /// happen. Runs on this session's own workdir, which is the root every declared entry is
5184    /// relative to.
5185    pub(crate) fn check_protected_paths(
5186        &self,
5187        call: &ResolvedCall,
5188    ) -> Option<ProtectedPathRefusal> {
5189        self.protected_paths
5190            .check_call(&self.accessible_workdir, call, &self.tool_annotations)
5191    }
5192
5193    /// Dispatch a tool call from the agent loop: native binary, shell, or WASM, and fence
5194    /// whatever comes back.
5195    ///
5196    /// The single convergence point of every agent-facing tool call, on both invocation paths
5197    /// (WASM component and native subprocess), which is why the fence is applied here rather
5198    /// than at either of the two callers that turn a [`DispatchOutcome`] into model-facing text.
5199    /// The reduction from `data`/`summary` to one string happens here too, so no unfenced tool
5200    /// text is left on the outcome for a caller to reach for.
5201    pub(crate) async fn dispatch_agent_tool_async(
5202        &self,
5203        name: &str,
5204        input: murmur::tool::run::ToolInput,
5205        gate: Option<&mut crate::agent::CallGate<'_>>,
5206    ) -> Result<DispatchOutcome, String> {
5207        let mut outcome = self.dispatch_agent_tool_unfenced(name, input, gate).await?;
5208        // Every branch is fenced except the skill branch. A skill result is `skill.md`, read off
5209        // disk from inside the capsule, staged at install and fixed for the whole run: it is the
5210        // capsule author's own guidance and its entire purpose is to be followed as instruction,
5211        // so fencing it as data would make a declared skill inert. Every other branch returns
5212        // bytes produced at call time by something outside the capsule, and the runtime has no
5213        // notion of a trusted tool, so the rule for them needs no judgement: all of them are
5214        // fenced, unconditionally.
5215        if !outcome.is_skill {
5216            fence_tool_result(name, &mut outcome.result);
5217        }
5218        Ok(outcome)
5219    }
5220
5221    /// [`Self::dispatch_agent_tool_async`]'s branches, before the fence. Private: whatever this
5222    /// returns reaches the model unfenced unless the caller fences it.
5223    ///
5224    /// Two callers. [`Self::dispatch_agent_tool_async`] fences the result and is the agent loop's
5225    /// route. [`Self::dispatch_submit_plan`] deliberately does not, because a plan step's output
5226    /// becomes one field of a report that is fenced as a whole.
5227    ///
5228    /// `gate` is carried through rather than consulted here: this is the branch table, and the
5229    /// decision point is applied by whoever is about to dispatch a call — the agent loop for its
5230    /// own turn, and [`crate::plan`] for each step of a submitted plan.
5231    async fn dispatch_agent_tool_unfenced(
5232        &self,
5233        name: &str,
5234        input: murmur::tool::run::ToolInput,
5235        gate: Option<&mut crate::agent::CallGate<'_>>,
5236    ) -> Result<DispatchOutcome, String> {
5237        // The two runtime-provided peer-handoff tools, intercepted ahead of every other path.
5238        // They have no artifact, no binary and no component: the manifests under
5239        // `workdir/tools/` exist so `build_tool_inventory` shows them to the model, and this is
5240        // where the call actually lands.
5241        if name == SHARE_FILE_TOOL {
5242            return self
5243                .dispatch_share_file(input)
5244                .await
5245                .map(DispatchOutcome::tool);
5246        }
5247        if name == FETCH_PEER_FILE_TOOL {
5248            return self
5249                .dispatch_fetch_peer_file(input)
5250                .await
5251                .map(DispatchOutcome::tool);
5252        }
5253        if name == DELEGATE_TASK_TOOL {
5254            return self
5255                .dispatch_delegate_task(input)
5256                .await
5257                .map(DispatchOutcome::tool);
5258        }
5259        if name == SUBMIT_PLAN_TOOL {
5260            return self
5261                .dispatch_submit_plan(input, gate)
5262                .await
5263                .map(DispatchOutcome::tool);
5264        }
5265
5266        // Native artifact: packaged binary in workdir/tools/<name>/<name>
5267        let native_bin = self.workdir.join("tools").join(name).join(name);
5268        if native_bin.exists() && !self.tool_components.contains_key(name) {
5269            return enforce_allowlist(&self.allowlisted_tools, name, || {
5270                dispatch_native_tool(
5271                    name,
5272                    input,
5273                    &native_bin,
5274                    &self.accessible_workdir,
5275                    &self.workdir,
5276                    &self.capability_policy,
5277                    &self.shell_enforcement,
5278                )
5279            })
5280            .map(DispatchOutcome::tool);
5281        }
5282
5283        // Shell tool — run on a blocking thread so the LocalSet stays free to handle
5284        // incoming HTTP requests (e.g. curl POSTing back to the same capsule's server).
5285        if self
5286            .capability_policy
5287            .shell_allow
5288            .iter()
5289            .any(|allowed| allowed == name)
5290        {
5291            let name = name.to_string();
5292            let accessible_workdir = self.accessible_workdir.clone();
5293            let session_workdir = self.workdir.clone();
5294            let env_overrides = self.inference_env.clone();
5295            let policy = self.capability_policy.clone();
5296            let enforcement = self.shell_enforcement.clone();
5297            // Both halves are required. Without a registry there is no task loop to deliver a
5298            // completion to; without a context id there is no conversation for the completion to
5299            // join, and a completion that opened its own would be a result nobody asked for.
5300            // `command` is filled in by `dispatch_shell_tool`, which is where it is parsed.
5301            let detach = match (&self.detached, &self.current_context_id) {
5302                (Some(registry), Some(context_id)) => Some(DetachPolicy {
5303                    grace: std::time::Duration::from_secs(self.shell_grace_secs),
5304                    registry: Arc::clone(registry),
5305                    command: String::new(),
5306                    context_id: context_id.clone(),
5307                    provenance: self.current_task_provenance,
5308                }),
5309                _ => None,
5310            };
5311            return tokio::task::spawn_blocking(move || {
5312                dispatch_shell_tool(
5313                    &name,
5314                    input,
5315                    &accessible_workdir,
5316                    &session_workdir,
5317                    &env_overrides,
5318                    &policy,
5319                    &enforcement,
5320                    detach,
5321                )
5322            })
5323            .await
5324            .map_err(|e| format!("shell tool panicked: {e}"));
5325        }
5326
5327        // Skill artifact: return skill.md content as the tool result (no WASM dispatch).
5328        // Works in capsules with no shell/file capabilities — the runtime reads the file.
5329        let skill_md_path = self.workdir.join("tools").join(name).join("skill.md");
5330        if skill_md_path.exists() {
5331            let content = fs::read_to_string(&skill_md_path)
5332                .map_err(|e| format!("failed to read skill '{name}': {e}"))?;
5333            return Ok(DispatchOutcome::skill(murmur::tool::run::ToolResult {
5334                status: murmur::tool::run::Status::Passed,
5335                summary: Some(format!("Skill {name} guidance")),
5336                data: Some(content),
5337                data_path: None,
5338                truncated: false,
5339                metadata: Vec::new(),
5340            }));
5341        }
5342
5343        // WASM tool
5344        if !self.allowlisted_tools.contains(name) {
5345            return Err(format!(
5346                "tool '{name}' is not declared in manifest allowlist"
5347            ));
5348        }
5349        self.dispatch_tool_async(name, input)
5350            .await
5351            .map(DispatchOutcome::tool)
5352    }
5353
5354    /// `share-file`: mint one handle for one file, for one named peer.
5355    ///
5356    /// The audience is derived from the peer's own agent card, fetched here. That fetch is an
5357    /// ordinary outbound request and is enforced against `capabilities.network.allow` — the same
5358    /// rule `send::Host::send` applies — so **minting grants no new outbound authority**.
5359    ///
5360    /// Returns no filesystem path in any field. The agent asked for the path; echoing it back as
5361    /// a fact of the handle would make the handle look like an address, which is the one thing it
5362    /// is not.
5363    async fn dispatch_share_file(
5364        &self,
5365        input: murmur::tool::run::ToolInput,
5366    ) -> Result<murmur::tool::run::ToolResult, String> {
5367        let args = parse_tool_json_input(SHARE_FILE_TOOL, &input)?;
5368        let path = required_string_arg(SHARE_FILE_TOOL, &args, "path")?;
5369        let peer = required_string_arg(SHARE_FILE_TOOL, &args, "peer")?;
5370        let ttl = args.get("ttl").and_then(serde_json::Value::as_str);
5371
5372        let Some(plane) = self.peer_plane.as_ref().filter(|plane| plane.is_declared()) else {
5373            return Err(format!(
5374                "'{SHARE_FILE_TOOL}' needs an exports.peer_files block in murmur.yaml; \
5375                 this capsule declares none"
5376            ));
5377        };
5378
5379        let ttl_secs =
5380            match ttl {
5381                None => None,
5382                Some(text) => Some(murmur_artifact::parse_duration_secs(text).map_err(
5383                    |message| format!("'{SHARE_FILE_TOOL}' was given an unusable ttl: {message}"),
5384                )?),
5385            };
5386
5387        let audience = match self.peer_audience_for(&peer).await {
5388            Ok(audience) => audience,
5389            Err(reason) => {
5390                self.trace_mint(
5391                    None,
5392                    &path,
5393                    "",
5394                    None,
5395                    "peer_unreachable",
5396                    Some(reason.clone()),
5397                )
5398                .await;
5399                return Err(reason);
5400            }
5401        };
5402
5403        match plane.mint_handle(&path, &audience, ttl_secs) {
5404            Ok(minted) => {
5405                self.trace_mint(
5406                    Some(minted.handle_id.clone()),
5407                    &minted.path,
5408                    &minted.audience,
5409                    Some(minted.expires_at_ms),
5410                    "ok",
5411                    None,
5412                )
5413                .await;
5414                Ok(json_tool_result(
5415                    format!("Minted a handle for '{}' addressed to {}", path, audience),
5416                    serde_json::json!({
5417                        "handle": minted.handle,
5418                        "handle_id": minted.handle_id,
5419                        "expires_at_ms": minted.expires_at_ms,
5420                        "audience": minted.audience,
5421                    }),
5422                ))
5423            }
5424            Err(error) => {
5425                let reason = error.message();
5426                self.trace_mint(
5427                    None,
5428                    &path,
5429                    &audience,
5430                    None,
5431                    error.code(),
5432                    Some(reason.clone()),
5433                )
5434                .await;
5435                // Names the authoriser and nothing else: not the host path it resolved to, not
5436                // what it found there. A refused mint must not become a probe.
5437                Err(format!(
5438                    "'{SHARE_FILE_TOOL}' refused '{path}': {reason}. Only files under the \
5439                     declared exports.peer_files.root may be shared."
5440                ))
5441            }
5442        }
5443    }
5444
5445    /// Records one launched child in this session's trace, before its delegation has ended.
5446    ///
5447    /// The registration into [`Self::live_delegations`] happens on the launching thread, in the
5448    /// plane's own launch callback, rather than here: this write is `async` and its wait can be
5449    /// cancelled, and a delegation that is up must be nameable whether or not the call that
5450    /// started it ever returned.
5451    async fn write_delegation_start(&self, notice: &crate::delegation_plane::DelegationLaunch) {
5452        if let Some(trace) = &self.peer_trace {
5453            trace
5454                .write_delegation_start(
5455                    &notice.delegation_id,
5456                    &notice.capsule,
5457                    &notice.version,
5458                    &notice.child_session_id,
5459                    &notice.child_workdir,
5460                )
5461                .await;
5462        }
5463    }
5464
5465    /// Close the `delegation_start` this completion belongs to, as the task carrying it begins.
5466    ///
5467    /// A delegation that returns on start has not ended when its tool call returns, so this is
5468    /// where its terminal `delegation` line is written — one line per delegation still, joined to
5469    /// the start by the `dlg_` id both carry, and to the completion task by the same id on its
5470    /// `task_start`.
5471    ///
5472    /// `outcome` and `reason` come out of the child's own `completion.json`, never out of the
5473    /// completion's message text: both reporters write that file before they post, so the
5474    /// structured record is on disk by the time this task exists. A file that cannot be read still
5475    /// closes the row — a delegation left permanently in flight in the trace is a worse record
5476    /// than one whose outcome is stated as unknown.
5477    async fn close_started_delegation(&self, delegation_id: &str) {
5478        let Some(started) = self.live_delegations.finish(delegation_id) else {
5479            // A completion for a delegation this session did not start: another session's, or one
5480            // whose start was never recorded. `mur trace show` renders the terminal line on its
5481            // own row, which is the honest reading of it.
5482            return;
5483        };
5484        let Some(trace) = &self.peer_trace else {
5485            return;
5486        };
5487        let recorded = crate::delegation::read_completion(&started.workdir);
5488        let (outcome, reason, child_session_id, duration_ms) = match recorded {
5489            Some(outcome) => (
5490                outcome.status.as_str().to_string(),
5491                outcome.detail.clone(),
5492                Some(outcome.session_id.clone()).filter(|id| !id.is_empty()),
5493                outcome.duration_ms,
5494            ),
5495            None => (
5496                "unknown".to_string(),
5497                Some(format!(
5498                    "a completion arrived for this delegation, but no readable {} was left in {}",
5499                    crate::delegation::COMPLETION_FILE,
5500                    started.workdir.display()
5501                )),
5502                None,
5503                started
5504                    .started
5505                    .elapsed()
5506                    .as_millis()
5507                    .try_into()
5508                    .unwrap_or(u64::MAX),
5509            ),
5510        };
5511        trace
5512            .write_delegation(
5513                &started.capsule,
5514                &started.version,
5515                Some(delegation_id.to_string()),
5516                child_session_id,
5517                duration_ms,
5518                &outcome,
5519                reason,
5520            )
5521            .await;
5522    }
5523
5524    /// `delegate-task`: hand one task to one sub-capsule and return as soon as it holds it.
5525    ///
5526    /// The agent names a capsule, a version and a task. Everything else — the daemon's address,
5527    /// this session's credential, the approval, the child's directory, the child's process and the
5528    /// A2A conversation with it — is composed by [`crate::delegation_plane::DelegationPlane`] and
5529    /// never enters the model's context. That is why a delegating capsule needs no
5530    /// `capabilities.network.allow` entry for the daemon: it never addresses it.
5531    ///
5532    /// **The call returns when the child starts, not when it finishes.** What the child did
5533    /// arrives afterwards as a `completion`-origin task in the background lane, carrying this
5534    /// delegation's `dlg_` id — so one turn can issue several delegations and carry on while all
5535    /// of them run. The `data` this returns therefore names a delegation in flight and carries no
5536    /// answer: `output` and `result_path` are absent, and `child_workdir` is where the child's
5537    /// own trace and, later, its result will be.
5538    ///
5539    /// Still run on a blocking thread, exactly as the shell branch is, because the plane's three
5540    /// steps — the daemon, the launch and the delivery — are blocking calls; it is now the length
5541    /// of a launch rather than the length of a child's run.
5542    async fn dispatch_delegate_task(
5543        &self,
5544        input: murmur::tool::run::ToolInput,
5545    ) -> Result<murmur::tool::run::ToolResult, String> {
5546        let args = parse_tool_json_input(DELEGATE_TASK_TOOL, &input)?;
5547        let request = crate::delegation_plane::DelegationRequest {
5548            capsule: required_string_arg(DELEGATE_TASK_TOOL, &args, "capsule")?,
5549            version: required_string_arg(DELEGATE_TASK_TOOL, &args, "version")?,
5550            task: required_string_arg(DELEGATE_TASK_TOOL, &args, "task")?,
5551        };
5552
5553        let Some(plane) = self.delegation.as_ref() else {
5554            return Err(format!(
5555                "'{DELEGATE_TASK_TOOL}' needs a capabilities.spawn.allow list in murmur.yaml; \
5556                 this capsule declares none"
5557            ));
5558        };
5559
5560        // Which capsules may be delegated to is the daemon's question, not this runtime's: the
5561        // referee holds the parent's envelope and answers with a sentence naming the manifest key
5562        // and the entry that failed. Pre-empting it here would replace that sentence with a
5563        // weaker one and let a capsule self-authorise its own spawn rights.
5564        let plane = Arc::clone(plane);
5565        let started = std::time::Instant::now();
5566        // The launch notice leaves the blocking call on a channel whose `send` is synchronous, so
5567        // the parent's `delegation_start` reaches disk while the launch is still finishing rather
5568        // than after it returns — which is the whole point of the record, for a child that is
5569        // about to be handed a task and may then hang or crash. This side of the plane's callback
5570        // is a channel because the write is `async`: the notice has to cross back to the task
5571        // loop below to be written at all.
5572        let (launch_tx, mut launch_rx) = tokio::sync::mpsc::unbounded_channel();
5573        let live = Arc::clone(&self.live_delegations);
5574        let child_root = self.accessible_workdir.clone();
5575        let origin = crate::delegation_plane::DelegationOrigin {
5576            context_id: self.current_context_id.clone().unwrap_or_default(),
5577            launched: Some(Arc::new(
5578                move |notice: crate::delegation_plane::DelegationLaunch| {
5579                    // Registered here, synchronously on the launching thread, so a child that is up is
5580                    // in the live set before anything can wait on it — including when the wait below
5581                    // is cancelled and the trace write never happens.
5582                    live.register(
5583                        notice.delegation_id.clone(),
5584                        crate::cancel::LiveDelegation {
5585                            workdir: child_root.join(&notice.child_workdir),
5586                            capsule: notice.capsule.clone(),
5587                            version: notice.version.clone(),
5588                            child_session_id: notice.child_session_id.clone(),
5589                            child_workdir: notice.child_workdir.clone(),
5590                            started: std::time::Instant::now(),
5591                        },
5592                    );
5593                    let _ = launch_tx.send(notice);
5594                },
5595            )),
5596            // The completion this delegation posts inherits the delegating task's class, the same
5597            // derivation a demoted shell command's completion uses. `None` — the script-capsule
5598            // path — is read as untrusted by the plane.
5599            trust: self.current_task_provenance.map(|task| task.trust()),
5600        };
5601        // A cancel that lands mid-launch must not queue behind it: the child is already the live
5602        // set's problem, and the person who stopped the task is waiting on an answer.
5603        let cancel = self.task_cancel_signal();
5604        let mut starting = tokio::task::spawn_blocking(move || plane.start(&request, &origin));
5605        let mut notices_open = true;
5606        let joined = loop {
5607            tokio::select! {
5608                biased;
5609                () = async {
5610                    match &cancel {
5611                        Some(signal) => signal.canceled().await,
5612                        None => std::future::pending().await,
5613                    }
5614                } => {
5615                    if let Some(signal) = &cancel {
5616                        signal.note_phase(crate::cancel::PHASE_DELEGATION);
5617                    }
5618                    // Whatever the launch is doing continues on its own thread; nothing here kills
5619                    // it. A child that came up is in the live set and is named as residue.
5620                    while let Ok(notice) = launch_rx.try_recv() {
5621                        self.write_delegation_start(&notice).await;
5622                    }
5623                    return Err(format!(
5624                        "'{DELEGATE_TASK_TOOL}' was canceled while the sub-capsule was starting; \
5625                         any child that came up is still running and is named in the cancel's \
5626                         residue"
5627                    ));
5628                }
5629                notice = launch_rx.recv(), if notices_open => match notice {
5630                    Some(notice) => self.write_delegation_start(&notice).await,
5631                    // The sender lives in the blocking closure, so this is that closure ending.
5632                    None => notices_open = false,
5633                },
5634                joined = &mut starting => break joined,
5635            }
5636        };
5637        // A launch that finished between the arms being polled leaves its notice behind, and it
5638        // still has to be recorded.
5639        while let Ok(notice) = launch_rx.try_recv() {
5640            self.write_delegation_start(&notice).await;
5641        }
5642        let result = joined.map_err(|error| format!("'{DELEGATE_TASK_TOOL}' panicked: {error}"))?;
5643
5644        let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
5645        let status = result.status;
5646        // A delegation that started has not ended, so its terminal `delegation` line is not
5647        // written here: it is written when the completion arrives, by
5648        // [`Self::close_started_delegation`]. Only a delegation that will never produce a
5649        // completion — the daemon refused it, or the child never took its task — is closed now.
5650        if status != crate::delegation_plane::DelegationStatus::Started {
5651            // A launch that got far enough to be announced but not far enough to be started is
5652            // closed here, so the row it opened is not also waiting for a completion that will
5653            // never come.
5654            self.live_delegations.release(&result.delegation_id);
5655            if let Some(trace) = &self.peer_trace {
5656                trace
5657                    .write_delegation(
5658                        &result.capsule,
5659                        &result.version,
5660                        Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
5661                        Some(result.session_id.clone()).filter(|id| !id.is_empty()),
5662                        duration_ms,
5663                        status.as_str(),
5664                        Some(result.output.clone()),
5665                    )
5666                    .await;
5667            }
5668        }
5669
5670        // A refusal is the referee's own sentence and nothing else. The operator reading it has to
5671        // see which manifest key and which entry to edit, not the HTTP transcript that carried it,
5672        // and not this runtime's opinion wrapped around it.
5673        if status == crate::delegation_plane::DelegationStatus::Refused {
5674            return Err(result.output);
5675        }
5676
5677        let summary = format!(
5678            "Delegated to {}@{}: {}",
5679            result.capsule,
5680            result.version,
5681            status.as_str()
5682        );
5683        let mut data = serde_json::json!({
5684            "delegation_id": result.delegation_id,
5685            "session_id": result.session_id,
5686            "capsule": result.capsule,
5687            "version": result.version,
5688            "status": status.as_str(),
5689        });
5690        match &result.child_workdir {
5691            // Present only on a started delegation, and the only path there is to the child's own
5692            // trace: joined to this capsule's workdir, it is a directory the parent's ordinary
5693            // file tools can already address.
5694            Some(workdir) => {
5695                data["child_workdir"] = serde_json::Value::String(workdir.clone());
5696            }
5697            // A failed start produced no child worth naming, so what the agent gets is why.
5698            None => {
5699                data["output"] = serde_json::Value::String(result.output.clone());
5700            }
5701        }
5702        Ok(murmur::tool::run::ToolResult {
5703            status: if status == crate::delegation_plane::DelegationStatus::Started {
5704                murmur::tool::run::Status::Passed
5705            } else {
5706                murmur::tool::run::Status::Failed
5707            },
5708            summary: Some(summary),
5709            data: Some(data.to_string()),
5710            data_path: None,
5711            truncated: result.truncated,
5712            metadata: Vec::new(),
5713        })
5714    }
5715
5716    /// `submit-plan`: run one plan of steps to completion and return every step's result.
5717    ///
5718    /// The scheduler is `crate::plan`, and every `tool` step it runs comes back through
5719    /// [`Self::dispatch_agent_tool_unfenced`] — this session's own dispatch, with this session's
5720    /// own allowlist and shell grant. Every `tool` and `shell` step is put to `gate` first, which
5721    /// is the same decision point the agent loop applies to its own calls, so a plan step is
5722    /// subject to `capabilities.filesystem.read_only` and to a `commit_policy: deny` policy hook
5723    /// exactly as the equivalent direct call is. A plan therefore reaches exactly what the model
5724    /// could already call one turn at a time, and nothing else. `submit-plan` itself is refused
5725    /// as a step by `plan::validate_plan`, so a plan cannot submit a plan.
5726    ///
5727    /// The scheduler is blocking and thread-scoped, so it runs on `spawn_blocking` while this
5728    /// side services its tool calls over a channel — the shape [`Self::dispatch_delegate_task`]
5729    /// uses for its launch notices, for the same reason: the blocking side has no runtime handle
5730    /// and the `!Send` WASI state it needs lives on this `LocalSet`.
5731    async fn dispatch_submit_plan(
5732        &self,
5733        input: murmur::tool::run::ToolInput,
5734        mut gate: Option<&mut crate::agent::CallGate<'_>>,
5735    ) -> Result<murmur::tool::run::ToolResult, String> {
5736        if !self.capability_policy.plan_submit {
5737            return Err(format!(
5738                "'{SUBMIT_PLAN_TOOL}' needs a capabilities.plan.submit: true declaration in \
5739                 murmur.yaml; this capsule declares none"
5740            ));
5741        }
5742
5743        let args = parse_tool_json_input(SUBMIT_PLAN_TOOL, &input)?;
5744        let Some(serde_json::Value::Object(plan)) = args.get("plan") else {
5745            return Err(format!(
5746                "'{SUBMIT_PLAN_TOOL}' requires a 'plan' argument holding a JSON object with an \
5747                 'id' and a 'steps' array"
5748            ));
5749        };
5750        let plan_id = plan
5751            .get("id")
5752            .and_then(serde_json::Value::as_str)
5753            .unwrap_or_default()
5754            .to_string();
5755        let plan_path = self.write_plan_file(plan)?;
5756
5757        // Every directory under `tools/` is a tool this session can dispatch, which is what
5758        // `validate_plan` checks a `tool` step against. `submit-plan` is left out so a plan
5759        // naming it is refused for what it is rather than for the tool being absent.
5760        let installed_tools: HashSet<String> = fs::read_dir(self.workdir.join("tools"))
5761            .into_iter()
5762            .flatten()
5763            .flatten()
5764            .filter(|entry| entry.path().is_dir())
5765            .filter_map(|entry| entry.file_name().into_string().ok())
5766            .filter(|name| name != SUBMIT_PLAN_TOOL)
5767            .collect();
5768
5769        // The blocking side asks for a decision or a tool call and waits; this side answers it.
5770        // Unbounded because a plan's fan-out is its own bound: every request in flight is one
5771        // scheduler thread already parked on its reply.
5772        let (request_tx, mut request_rx) = tokio::sync::mpsc::unbounded_channel::<PlanRequest>();
5773
5774        // The same short-circuit the agent loop applies to its own calls: with neither a policy
5775        // hook nor a `read_only` declaration there is nothing that can refuse a step, so no step
5776        // resolves a call and no step crosses the channel to ask.
5777        let gates = gate.as_ref().is_some_and(|gate| gate.gates(self));
5778
5779        let capability_policy = self.capability_policy.clone();
5780        let scheduler_workdir = self.accessible_workdir.clone();
5781        let scheduler_session_workdir = self.workdir.clone();
5782        let session_id = self.session_id.clone();
5783        // The conversation this plan was submitted from, named on the `MURMUR_SPAWNER` handle
5784        // every `capsule` step injects into its child — the same value `delegate-task` names.
5785        let current_context_id = self.current_context_id.clone();
5786        // Cloned off the plane rather than held a second time on this state: one registration's
5787        // authority stays in one place.
5788        let spawn_credential = self.delegation.as_ref().map(|plane| plane.credential());
5789        let plan_trace = self.plan_trace.clone();
5790        let registry = Arc::clone(&self.registry);
5791        let gate_tx = request_tx.clone();
5792        let mut scheduling = tokio::task::spawn_blocking(move || {
5793            let gate_step = move |call: &crate::plan::PlannedCall<'_>| -> Option<String> {
5794                if !gates {
5795                    return None;
5796                }
5797                let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
5798                // Fail closed on both arms: a decision point that cannot be reached refuses, so
5799                // a session shutting down mid-plan cannot let a step through ungated.
5800                if gate_tx
5801                    .send(PlanRequest::Gate(
5802                        GatedStepCall::from_planned(call),
5803                        reply_tx,
5804                    ))
5805                    .is_err()
5806                {
5807                    return Some(PLAN_GATE_UNREACHABLE.to_string());
5808                }
5809                reply_rx
5810                    .blocking_recv()
5811                    .unwrap_or_else(|_| Some(PLAN_GATE_UNREACHABLE.to_string()))
5812            };
5813            let invoke_tool = move |name: &str, input: murmur::tool::run::ToolInput| {
5814                let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
5815                request_tx
5816                    .send(PlanRequest::Invoke(name.to_string(), input, reply_tx))
5817                    .map_err(|_| "the session stopped answering plan tool calls".to_string())?;
5818                reply_rx
5819                    .blocking_recv()
5820                    .map_err(|_| "the session stopped answering plan tool calls".to_string())?
5821            };
5822            let ctx = crate::plan::SchedulerContext {
5823                accessible_workdir: scheduler_workdir,
5824                session_workdir: scheduler_session_workdir,
5825                capability_policy,
5826                installed_tools,
5827                // A `capsule` step's version is the daemon's question, not this session's: the
5828                // scheduler falls back to `0.1.0` for a name it holds no version for, and a
5829                // session holds none for any of them.
5830                capsule_versions: HashMap::new(),
5831                current_session_id: Some(session_id),
5832                current_context_id,
5833                registry,
5834                spawn_credential,
5835                trace: plan_trace.as_deref(),
5836                gate_step: &gate_step,
5837                invoke_tool: &invoke_tool,
5838            };
5839            crate::plan::execute(&plan_path, &ctx)
5840        });
5841
5842        let mut requests_open = true;
5843        let joined = loop {
5844            tokio::select! {
5845                request = request_rx.recv(), if requests_open => match request {
5846                    Some(request) => self.answer_plan_request(request, gate.as_deref_mut()).await,
5847                    // The senders live in the blocking closure, so this is that closure ending.
5848                    None => requests_open = false,
5849                },
5850                joined = &mut scheduling => break joined,
5851            }
5852        };
5853        // A request that arrived between the two arms being polled still has a scheduler thread
5854        // parked on its reply, and the join above only means the outer closure returned.
5855        while let Ok(request) = request_rx.try_recv() {
5856            self.answer_plan_request(request, gate.as_deref_mut()).await;
5857        }
5858        let report = joined.map_err(|error| format!("'{SUBMIT_PLAN_TOOL}' panicked: {error}"))?;
5859
5860        let mut succeeded = 0usize;
5861        let mut failed = 0usize;
5862        let mut skipped = 0usize;
5863        let steps: Vec<serde_json::Value> = report
5864            .results
5865            .iter()
5866            .map(|result| {
5867                match result.status {
5868                    crate::plan::StepStatus::Success => succeeded += 1,
5869                    crate::plan::StepStatus::Failed => failed += 1,
5870                    crate::plan::StepStatus::Skipped => skipped += 1,
5871                }
5872                serde_json::json!({
5873                    "step_id": result.step_id,
5874                    "status": result.status.as_str(),
5875                    "output": result.output,
5876                    "error": result.error,
5877                })
5878            })
5879            .collect();
5880        let data = serde_json::json!({
5881            "plan_id": plan_id,
5882            "completed": report.completed,
5883            "failed_step": report.failed_step,
5884            "steps": steps,
5885        });
5886        let summary = format!(
5887            "Plan '{plan_id}': {} steps, {succeeded} succeeded, {failed} failed, {skipped} skipped",
5888            report.results.len()
5889        );
5890
5891        Ok(murmur::tool::run::ToolResult {
5892            // The status carries the plan's outcome so the model is told the plan did not
5893            // complete, rather than having to read the report to notice.
5894            status: if report.completed {
5895                murmur::tool::run::Status::Passed
5896            } else {
5897                murmur::tool::run::Status::Failed
5898            },
5899            summary: Some(summary),
5900            data: Some(data.to_string()),
5901            data_path: None,
5902            truncated: false,
5903            metadata: Vec::new(),
5904        })
5905    }
5906
5907    /// Answer one request from a running plan: a decision point, or a `tool` step's call.
5908    ///
5909    /// Split out of the servicing loop because the loop asks twice — once while the scheduler is
5910    /// still running and once to drain what arrived as it joined — and a request answered on one
5911    /// path but not the other would leave a scheduler thread parked forever.
5912    async fn answer_plan_request(
5913        &self,
5914        request: PlanRequest,
5915        gate: Option<&mut crate::agent::CallGate<'_>>,
5916    ) {
5917        match request {
5918            PlanRequest::Gate(call, reply) => {
5919                let verdict = match gate {
5920                    Some(gate) => {
5921                        let resolved = call.resolve(&self.accessible_workdir);
5922                        // A refusal that could not be recorded refuses anyway: an unaudited call
5923                        // is the thing the decision point exists to prevent.
5924                        match gate.check(self, &resolved).await {
5925                            Ok(verdict) => verdict,
5926                            Err(error) => Some(error.to_string()),
5927                        }
5928                    }
5929                    None => None,
5930                };
5931                let _ = reply.send(verdict);
5932            }
5933            PlanRequest::Invoke(name, input, reply) => {
5934                // Unfenced deliberately: this result becomes one field of the plan report, and
5935                // `dispatch_agent_tool_async` fences that report once on its way to the model.
5936                // Fencing here would wrap every step's output a second time.
5937                //
5938                // Boxed because this reaches back into the dispatcher that called it: the two
5939                // futures are mutually recursive and one of them has to be behind a pointer for
5940                // either to have a size. `validate_plan` refuses a `submit-plan` step, so the
5941                // recursion is one level deep in practice.
5942                let outcome = Box::pin(self.dispatch_agent_tool_unfenced(&name, input, gate))
5943                    .await
5944                    .map(|outcome| outcome.result);
5945                let _ = reply.send(outcome);
5946            }
5947        }
5948    }
5949
5950    /// Write one submitted plan into `<workdir>/plans/plan-<n>.json` and return its path.
5951    ///
5952    /// `n` is this session's own counter and the plan's `id` reaches no part of the name: the id
5953    /// is model-supplied, and a file named from it could collide or traverse.
5954    fn write_plan_file(
5955        &self,
5956        plan: &serde_json::Map<String, serde_json::Value>,
5957    ) -> Result<PathBuf, String> {
5958        let plans_dir = self.workdir.join("plans");
5959        fs::create_dir_all(&plans_dir)
5960            .map_err(|error| format!("'{SUBMIT_PLAN_TOOL}' could not create plans/: {error}"))?;
5961        let ordinal = self.plan_counter.fetch_add(1, Ordering::Relaxed) + 1;
5962        let plan_path = plans_dir.join(format!("plan-{ordinal}.json"));
5963        let body = serde_json::Value::Object(plan.clone()).to_string();
5964        fs::write(&plan_path, body).map_err(|error| {
5965            format!(
5966                "'{SUBMIT_PLAN_TOOL}' could not write {}: {error}",
5967                plan_path.display()
5968            )
5969        })?;
5970        Ok(plan_path)
5971    }
5972
5973    /// The audience a handle for `peer` must be minted for, read off that peer's own agent card.
5974    async fn peer_audience_for(&self, peer: &str) -> Result<String, String> {
5975        check_destination_allowed(
5976            &self.network_allow_rules,
5977            peer,
5978            "capabilities.network.allow",
5979        )?;
5980        let card = crate::outgoing::fetch_agent_card(peer)
5981            .await
5982            .map_err(|error| format!("peer_unreachable: {error}"))?;
5983        crate::peer_handoff::audience_from_card(&card)
5984            .map_err(|error| format!("peer_unreachable: {error}"))
5985    }
5986
5987    #[allow(clippy::too_many_arguments)]
5988    async fn trace_mint(
5989        &self,
5990        handle_id: Option<String>,
5991        path: &str,
5992        audience: &str,
5993        expires_at_ms: Option<u64>,
5994        outcome: &str,
5995        reason: Option<String>,
5996    ) {
5997        if let Some(trace) = &self.peer_trace {
5998            trace
5999                .write_peer_handle_mint(handle_id, path, audience, expires_at_ms, outcome, reason)
6000                .await;
6001        }
6002    }
6003
6004    /// `fetch-peer-file`: redeem a handle a peer sent and land the bytes as a file.
6005    ///
6006    /// The bytes are never placed in the result and never returned as text. Ingestion is a file
6007    /// the agent must decide to read, not context it is handed — a peer that can put arbitrary
6008    /// content in front of this model would otherwise have a prompt-injection channel that costs
6009    /// it nothing.
6010    async fn dispatch_fetch_peer_file(
6011        &self,
6012        input: murmur::tool::run::ToolInput,
6013    ) -> Result<murmur::tool::run::ToolResult, String> {
6014        let args = parse_tool_json_input(FETCH_PEER_FILE_TOOL, &input)?;
6015        let peer = required_string_arg(FETCH_PEER_FILE_TOOL, &args, "peer")?;
6016        let handle = required_string_arg(FETCH_PEER_FILE_TOOL, &args, "handle")?;
6017        let handle_id = crate::peer_handoff::handle_id(&handle);
6018
6019        // Before any connection is opened, so a refused destination is never contacted at all.
6020        if let Err(reason) = check_destination_allowed(
6021            &self.peer_fetch_rules,
6022            &peer,
6023            "capabilities.peer_fetch.allow",
6024        ) {
6025            self.trace_fetch(
6026                &peer,
6027                &handle_id,
6028                None,
6029                "peer_not_allowed",
6030                Some(reason.clone()),
6031            )
6032            .await;
6033            return Err(reason);
6034        }
6035
6036        let response = match crate::outgoing::redeem_peer_handle(
6037            &peer,
6038            &handle,
6039            &self.peer_own_audience,
6040        )
6041        .await
6042        {
6043            Ok(response) => response,
6044            Err(error) => {
6045                self.trace_fetch(
6046                    &peer,
6047                    &handle_id,
6048                    None,
6049                    "peer_unreachable",
6050                    Some(error.clone()),
6051                )
6052                .await;
6053                return Err(format!(
6054                    "'{FETCH_PEER_FILE_TOOL}' could not reach {peer}: {error}"
6055                ));
6056            }
6057        };
6058
6059        if response.status != 200 {
6060            // The peer's own refusal code, restated rather than flattened: `handle_expired` and
6061            // `handle_not_valid` mean different things to whoever reads the trace.
6062            let code = serde_json::from_slice::<serde_json::Value>(&response.body)
6063                .ok()
6064                .and_then(|body| {
6065                    body.get("error")
6066                        .and_then(serde_json::Value::as_str)
6067                        .map(str::to_string)
6068                })
6069                .unwrap_or_else(|| format!("http_{}", response.status));
6070            let reason = format!("{peer} refused the handle: {code}");
6071            self.trace_fetch(&peer, &handle_id, None, &code, Some(reason.clone()))
6072                .await;
6073            return Err(format!("'{FETCH_PEER_FILE_TOOL}' failed: {reason}"));
6074        }
6075
6076        let sha256 = murmur_artifact::sha256_hex(&response.body);
6077        // The validator the peer served describes the body it accompanies, so disagreeing with it
6078        // means the bytes were not the ones that were hashed. Refuse rather than store.
6079        if let Some(etag) = response.header("etag") {
6080            let expected = format!("\"sha256:{sha256}\"");
6081            if etag != expected {
6082                let reason = format!("{peer} served an etag that does not describe its own body");
6083                self.trace_fetch(
6084                    &peer,
6085                    &handle_id,
6086                    None,
6087                    "etag_mismatch",
6088                    Some(reason.clone()),
6089                )
6090                .await;
6091                return Err(format!("'{FETCH_PEER_FILE_TOOL}' failed: {reason}"));
6092            }
6093        }
6094        let generation = response
6095            .header("x-murmur-generation")
6096            .and_then(|value| value.parse::<u64>().ok());
6097
6098        // Runtime-chosen, never peer-chosen: the peer discloses no path, and the basename is only
6099        // a hint read out of the token's own unverified payload.
6100        let basename = crate::peer_handoff::decode_payload_unverified(&handle).map(|p| p.p);
6101        let stored_path = crate::peer_handoff::stored_path_for(&handle_id, basename.as_deref());
6102        let absolute = self.accessible_workdir.join(&stored_path);
6103        if let Some(parent) = absolute.parent() {
6104            if let Err(error) = fs::create_dir_all(parent) {
6105                let reason = format!("failed to create {}: {error}", parent.display());
6106                self.trace_fetch(&peer, &handle_id, None, "io_error", Some(reason.clone()))
6107                    .await;
6108                return Err(format!("'{FETCH_PEER_FILE_TOOL}' failed: {reason}"));
6109            }
6110        }
6111        if let Err(error) = fs::write(&absolute, &response.body) {
6112            let reason = format!("failed to write {stored_path}: {error}");
6113            self.trace_fetch(&peer, &handle_id, None, "io_error", Some(reason.clone()))
6114                .await;
6115            return Err(format!("'{FETCH_PEER_FILE_TOOL}' failed: {reason}"));
6116        }
6117
6118        if let Some(trace) = &self.peer_trace {
6119            trace
6120                .write_peer_file_fetch(
6121                    &peer,
6122                    &handle_id,
6123                    Some(stored_path.clone()),
6124                    Some(response.body.len() as u64),
6125                    Some(sha256.clone()),
6126                    "ok",
6127                    None,
6128                )
6129                .await;
6130        }
6131
6132        let mut data = serde_json::json!({
6133            "path": stored_path,
6134            "bytes": response.body.len(),
6135            "sha256": sha256,
6136            "peer": peer,
6137        });
6138        if let Some(generation) = generation {
6139            data["generation"] = serde_json::json!(generation);
6140        }
6141        Ok(json_tool_result(
6142            format!(
6143                "Stored {} bytes from {peer} at {stored_path}",
6144                response.body.len()
6145            ),
6146            data,
6147        ))
6148    }
6149
6150    async fn trace_fetch(
6151        &self,
6152        peer: &str,
6153        handle_id: &str,
6154        stored_path: Option<String>,
6155        outcome: &str,
6156        reason: Option<String>,
6157    ) {
6158        if let Some(trace) = &self.peer_trace {
6159            trace
6160                .write_peer_file_fetch(peer, handle_id, stored_path, None, None, outcome, reason)
6161                .await;
6162        }
6163    }
6164}
6165
6166/// Refuses a destination that no rule in `rules` covers, naming the manifest key that would have
6167/// to allow it.
6168///
6169/// The same `RequestTarget`/`NetworkAllowRule` pair `send::Host::send` uses, applied to a second,
6170/// separate list — so `capabilities.peer_fetch.allow` and `capabilities.network.allow` are
6171/// enforced by one matcher and can never drift into two dialects.
6172fn check_destination_allowed(
6173    rules: &[NetworkAllowRule],
6174    peer_url: &str,
6175    field: &str,
6176) -> Result<(), String> {
6177    let for_parse = if peer_url.contains("://") {
6178        peer_url.to_string()
6179    } else {
6180        format!("http://{peer_url}")
6181    };
6182    let uri: http::Uri = for_parse
6183        .parse()
6184        .map_err(|e| format!("invalid peer URL '{peer_url}': {e}"))?;
6185    let target = RequestTarget::from_request(&uri, false)
6186        .ok_or_else(|| format!("invalid peer URL '{peer_url}'"))?;
6187    if rules.iter().any(|rule| rule.matches(&target)) {
6188        return Ok(());
6189    }
6190    Err(format!("network policy: '{peer_url}' not in {field}"))
6191}
6192
6193/// One tool call's `data` field, parsed as a JSON object.
6194fn parse_tool_json_input(
6195    tool: &str,
6196    input: &murmur::tool::run::ToolInput,
6197) -> Result<serde_json::Map<String, serde_json::Value>, String> {
6198    let raw = input.data.as_deref().unwrap_or("{}");
6199    let raw = if raw.trim().is_empty() { "{}" } else { raw };
6200    match serde_json::from_str::<serde_json::Value>(raw) {
6201        Ok(serde_json::Value::Object(map)) => Ok(map),
6202        _ => Err(format!("'{tool}' expects a JSON object as its input")),
6203    }
6204}
6205
6206fn required_string_arg(
6207    tool: &str,
6208    args: &serde_json::Map<String, serde_json::Value>,
6209    key: &str,
6210) -> Result<String, String> {
6211    args.get(key)
6212        .and_then(serde_json::Value::as_str)
6213        .map(str::trim)
6214        .filter(|value| !value.is_empty())
6215        .map(str::to_string)
6216        .ok_or_else(|| format!("'{tool}' requires a non-empty '{key}'"))
6217}
6218
6219/// A passing tool result whose `data` is a JSON object. The agent loop sends `data || summary` to
6220/// the model, so the object is what the model reads back.
6221fn json_tool_result(summary: String, data: serde_json::Value) -> murmur::tool::run::ToolResult {
6222    murmur::tool::run::ToolResult {
6223        status: murmur::tool::run::Status::Passed,
6224        summary: Some(summary),
6225        data: Some(data.to_string()),
6226        data_path: None,
6227        truncated: false,
6228        metadata: Vec::new(),
6229    }
6230}
6231
6232fn read_artifact_info_from_manifest(
6233    manifest_path: &Path,
6234    fallback: &InstalledArtifactSummary,
6235) -> Result<manage::ArtifactInfo, String> {
6236    let manifest_content = fs::read_to_string(manifest_path)
6237        .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?;
6238
6239    let value: Value = serde_yaml::from_str(&manifest_content)
6240        .map_err(|err| format!("failed to parse {}: {err}", manifest_path.display()))?;
6241
6242    let root = value.as_mapping();
6243
6244    let description = root
6245        .and_then(|mapping| mapping.get(Value::String("description".to_string())))
6246        .and_then(Value::as_str)
6247        .unwrap_or_default()
6248        .to_string();
6249
6250    let tags = root
6251        .and_then(|mapping| mapping.get(Value::String("tags".to_string())))
6252        .and_then(Value::as_sequence)
6253        .map(|items| {
6254            items
6255                .iter()
6256                .filter_map(Value::as_str)
6257                .map(ToString::to_string)
6258                .collect::<Vec<_>>()
6259        })
6260        .unwrap_or_default();
6261
6262    let input_schema = root
6263        .and_then(|mapping| mapping.get(Value::String("input_schema".to_string())))
6264        .map(yaml_to_json_string)
6265        .transpose()?;
6266    let output_schema = root
6267        .and_then(|mapping| mapping.get(Value::String("output_schema".to_string())))
6268        .map(yaml_to_json_string)
6269        .transpose()?;
6270
6271    Ok(manage::ArtifactInfo {
6272        name: fallback.name.clone(),
6273        version: fallback.version.clone(),
6274        description,
6275        tags,
6276        runtime: runtime_type_to_wit(&fallback.runtime, fallback.implementation.as_ref()),
6277        input_schema,
6278        output_schema,
6279    })
6280}
6281
6282fn yaml_to_json_string(value: &Value) -> Result<String, String> {
6283    if let Value::String(s) = value {
6284        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
6285            return serde_json::to_string(&parsed)
6286                .map_err(|err| format!("failed to convert schema to JSON: {err}"));
6287        }
6288    }
6289    serde_json::to_string(value).map_err(|err| format!("failed to convert schema to JSON: {err}"))
6290}
6291
6292fn runtime_type_to_wit(
6293    runtime: &ArtifactRuntime,
6294    implementation: Option<&ArtifactImplementation>,
6295) -> manage::RuntimeType {
6296    match (runtime, implementation) {
6297        (ArtifactRuntime::Tool, Some(ArtifactImplementation::Native)) => {
6298            manage::RuntimeType::Native
6299        }
6300        _ => manage::RuntimeType::Wasm,
6301    }
6302}
6303
6304struct ToolStoreState {
6305    /// Resource limiter for this store — see [`CapsuleStoreState::limits`].
6306    limits: ExecutionLimiter,
6307    table: ResourceTable,
6308    wasi: WasiCtx,
6309    http: WasiHttpCtx,
6310    http_hooks: NetworkPolicyHooks,
6311}
6312
6313impl WasiView for ToolStoreState {
6314    fn ctx(&mut self) -> WasiCtxView<'_> {
6315        WasiCtxView {
6316            ctx: &mut self.wasi,
6317            table: &mut self.table,
6318        }
6319    }
6320}
6321
6322impl WasiHttpView for ToolStoreState {
6323    fn http(&mut self) -> WasiHttpCtxView<'_> {
6324        WasiHttpCtxView {
6325            ctx: &mut self.http,
6326            table: &mut self.table,
6327            hooks: &mut self.http_hooks,
6328        }
6329    }
6330}
6331
6332/// The tool names the runtime answers itself, and which therefore no artifact may claim.
6333///
6334/// This is the single definition of the set. Each member is answered inside
6335/// `dispatch_agent_tool_unfenced` before any allowlist check is reached, so an artifact installed
6336/// under one of these names would be shadowed at dispatch no matter what the allowlist said — and
6337/// its `tools/<name>/murmur.yaml` would in any case be overwritten by the synthetic write that
6338/// follows the staging loop.
6339///
6340/// Shell binary names are deliberately absent: they are operator-chosen through
6341/// `capabilities.shell.allow`, so there is no fixed set to reserve, and
6342/// `write_shell_tool_manifests` already yields to an artifact manifest that is already on disk.
6343pub(crate) const RESERVED_TOOL_NAMES: [&str; 4] = [
6344    SHARE_FILE_TOOL,
6345    FETCH_PEER_FILE_TOOL,
6346    DELEGATE_TASK_TOOL,
6347    SUBMIT_PLAN_TOOL,
6348];
6349
6350/// Whether `name` is answered by the runtime itself rather than by an artifact.
6351///
6352/// Matched exactly rather than case-insensitively: artifact names are case-sensitive everywhere
6353/// else in this codebase, so a name differing only in case is a different artifact.
6354#[must_use]
6355pub(crate) fn is_reserved_tool_name(name: &str) -> bool {
6356    RESERVED_TOOL_NAMES.contains(&name)
6357}
6358
6359/// Refuses the first declared artifact name that collides with a runtime-provided tool.
6360///
6361/// Called from `stage_session` ahead of the artifact loop, and from `mur run` ahead of its
6362/// installed-artifact pre-flight, so the operator is told about the collision rather than about a
6363/// missing artifact they were never going to be allowed to install under that name.
6364pub fn check_no_reserved_tool_names<'a, I>(names: I) -> Result<(), RuntimeError>
6365where
6366    I: IntoIterator<Item = &'a str>,
6367{
6368    for name in names {
6369        if is_reserved_tool_name(name) {
6370            return Err(RuntimeError::ReservedToolName {
6371                name: name.to_string(),
6372            });
6373        }
6374    }
6375    Ok(())
6376}
6377
6378/// A `capabilities.env.allow` entry the credential backstop removes from every guest environment,
6379/// with the pattern that removes it.
6380#[derive(Debug, Clone, PartialEq, Eq)]
6381pub struct StrippedEnvAllowEntry {
6382    /// The declared variable name, verbatim. Never its value.
6383    pub name: String,
6384    /// The matching backstop pattern, verbatim.
6385    pub pattern: String,
6386    /// The list the pattern came from.
6387    pub source: crate::shell::BackstopPatternSource,
6388}
6389
6390/// Every distinct `policy.env_allow` name the credential backstop drops, in first-declaration
6391/// order, each attributed through [`crate::credential_backstop_match`] against
6392/// `policy.shell_strip_env`. Empty when every declared name reaches the guest, including when
6393/// `env_allow` is empty. Judged from names alone.
6394pub fn stripped_env_allow_entries(policy: &CapabilityPolicy) -> Vec<StrippedEnvAllowEntry> {
6395    let mut entries: Vec<StrippedEnvAllowEntry> = Vec::new();
6396    for name in &policy.env_allow {
6397        if entries.iter().any(|entry| &entry.name == name) {
6398            continue;
6399        }
6400        if let Some(matched) =
6401            crate::shell::credential_backstop_match(name, &policy.shell_strip_env)
6402        {
6403            entries.push(StrippedEnvAllowEntry {
6404                name: name.clone(),
6405                pattern: matched.pattern,
6406                source: matched.source,
6407            });
6408        }
6409    }
6410    entries
6411}
6412
6413/// Refuses a capsule whose `capabilities.env.allow` names a variable the credential backstop
6414/// strips, naming every such entry at once.
6415///
6416/// [`crate::shell::build_wasi_env_allowlist`] drops those names whatever the manifest says, so the
6417/// grant would deliver nothing. Called from `stage_session` ahead of every registry resolve, from
6418/// `mur run` ahead of `--explain-scope`, and from `mur doctor` as a warning, so all three judge
6419/// one set of manifests.
6420pub fn check_env_allow_reaches_guests(policy: &CapabilityPolicy) -> Result<(), RuntimeError> {
6421    let entries = stripped_env_allow_entries(policy);
6422    if entries.is_empty() {
6423        return Ok(());
6424    }
6425    Err(RuntimeError::EnvAllowStrippedByBackstop { entries })
6426}
6427
6428/// The `E-CAP-016` entry list: `'NAME' (<source> pattern 'PATTERN')`, comma-joined.
6429pub(crate) fn describe_stripped_env_allow_entries(entries: &[StrippedEnvAllowEntry]) -> String {
6430    entries
6431        .iter()
6432        .map(|entry| {
6433            let source = match entry.source {
6434                crate::shell::BackstopPatternSource::Builtin => "credential backstop",
6435                crate::shell::BackstopPatternSource::StripEnv => "capabilities.shell.strip_env",
6436            };
6437            format!("'{}' ({source} pattern '{}')", entry.name, entry.pattern)
6438        })
6439        .collect::<Vec<_>>()
6440        .join(", ")
6441}
6442
6443/// Write a single artifact's `murmur.yaml` under `<workdir>/tools/<name>/`.
6444///
6445/// Used both by `stage_session` (for every artifact declared in the manifest) and by
6446/// `manage.pull()` (for the single artifact it just fetched at runtime). It is the only function
6447/// that writes an *artifact's* tool manifest, which is why the reserved-name guard sits here: the
6448/// pull path has no staging check ahead of it, and a capsule that pulled `delegate-task` at
6449/// runtime would otherwise overwrite the synthetic manifest and lose every call to the
6450/// interception in `dispatch_agent_tool_unfenced`.
6451fn write_tool_manifest(
6452    workdir: &Path,
6453    name: &str,
6454    manifest_yaml: &str,
6455) -> Result<(), RuntimeError> {
6456    if is_reserved_tool_name(name) {
6457        return Err(RuntimeError::ReservedToolName {
6458            name: name.to_string(),
6459        });
6460    }
6461    write_tool_manifest_unchecked(workdir, name, manifest_yaml)
6462}
6463
6464/// Write one runtime-provided tool's synthetic `murmur.yaml`, carrying the inverse guard.
6465///
6466/// A name absent from [`RESERVED_TOOL_NAMES`] is refused, so a further runtime-provided tool
6467/// routed through here without being added to the list fails loudly instead of shipping shadowable
6468/// by an artifact of the same name.
6469fn write_runtime_provided_tool_manifest(
6470    workdir: &Path,
6471    name: &str,
6472    manifest_yaml: &str,
6473) -> Result<(), RuntimeError> {
6474    if !is_reserved_tool_name(name) {
6475        return Err(RuntimeError::RuntimeProvidedToolNotReserved {
6476            name: name.to_string(),
6477        });
6478    }
6479    write_tool_manifest_unchecked(workdir, name, manifest_yaml)
6480}
6481
6482/// The write both guarded entry points share, once their opposite name checks have passed.
6483fn write_tool_manifest_unchecked(
6484    workdir: &Path,
6485    name: &str,
6486    manifest_yaml: &str,
6487) -> Result<(), RuntimeError> {
6488    let manifest_path = workdir.join("tools").join(name).join(PACKED_MANIFEST_ENTRY);
6489    let Some(parent) = manifest_path.parent() else {
6490        return Err(RuntimeError::Runtime(format!(
6491            "failed to derive parent for tool manifest path {}",
6492            manifest_path.display()
6493        )));
6494    };
6495
6496    fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
6497        path: manifest_path.display().to_string(),
6498        source,
6499    })?;
6500    fs::write(&manifest_path, manifest_yaml).map_err(|source| RuntimeError::WriteToolManifest {
6501        path: manifest_path.display().to_string(),
6502        source,
6503    })
6504}
6505
6506/// Write each native tool's binary to `<workdir>/tools/<name>/<name>`, executable.
6507///
6508/// Every image is classified against this host before the first one is written. A batch holding
6509/// one unrunnable binary installs none of them: a refusal that had already written two of three
6510/// tools would leave a workdir that is neither the state before staging nor the state after it.
6511fn install_native_binaries(
6512    workdir: &Path,
6513    native_binaries: Vec<(String, Vec<u8>)>,
6514) -> Result<(), RuntimeError> {
6515    let host_platform = current_platform();
6516    for (name, bytes) in &native_binaries {
6517        if let NativeBinaryVerdict::Mismatch { binary_platform } =
6518            native_binary_verdict(bytes, host_platform)
6519        {
6520            return Err(RuntimeError::NativeBinaryPlatformMismatch {
6521                name: name.clone(),
6522                binary_platform: binary_platform.to_string(),
6523                host_platform: host_platform.to_string(),
6524            });
6525        }
6526    }
6527
6528    for (name, bytes) in native_binaries {
6529        let binary_path = workdir.join("tools").join(&name).join(&name);
6530        let Some(parent) = binary_path.parent() else {
6531            return Err(RuntimeError::Runtime(format!(
6532                "failed to derive parent for native binary path {}",
6533                binary_path.display()
6534            )));
6535        };
6536        fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
6537            path: binary_path.display().to_string(),
6538            source,
6539        })?;
6540        fs::write(&binary_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
6541            path: binary_path.display().to_string(),
6542            source,
6543        })?;
6544        #[cfg(unix)]
6545        {
6546            use std::os::unix::fs::PermissionsExt;
6547            let mut perms = fs::metadata(&binary_path)
6548                .map_err(|source| RuntimeError::WriteToolManifest {
6549                    path: binary_path.display().to_string(),
6550                    source,
6551                })?
6552                .permissions();
6553            perms.set_mode(0o755);
6554            fs::set_permissions(&binary_path, perms).map_err(|source| {
6555                RuntimeError::WriteToolManifest {
6556                    path: binary_path.display().to_string(),
6557                    source,
6558                }
6559            })?;
6560        }
6561    }
6562    Ok(())
6563}
6564
6565/// Resolve and read a local-source skill's `skill.md` bytes.
6566///
6567/// `source` may be a relative or absolute path. Relative paths resolve against `manifest_dir`
6568/// (the directory containing `murmur.yaml`). The resolved path may be:
6569///   - a file (assumed to be `skill.md` itself), read directly, or
6570///   - a directory, in which case `skill.md` is located case-insensitively within it.
6571///
6572/// Errors before the workdir is created so that failures exit non-zero with no side effects.
6573fn load_local_skill_md(manifest_dir: &Path, source: &str) -> Result<Vec<u8>, RuntimeError> {
6574    let raw = Path::new(source);
6575    let path = if raw.is_absolute() {
6576        raw.to_path_buf()
6577    } else {
6578        manifest_dir.join(raw)
6579    };
6580
6581    if !path.exists() {
6582        return Err(RuntimeError::SkillSourceNotFound {
6583            path: path.display().to_string(),
6584        });
6585    }
6586
6587    let skill_md_path = if path.is_dir() {
6588        find_skill_md_in_dir(&path)?.ok_or_else(|| RuntimeError::SkillSourceMissingSkillMd {
6589            path: path.display().to_string(),
6590        })?
6591    } else {
6592        path.clone()
6593    };
6594
6595    fs::read(&skill_md_path).map_err(|source| RuntimeError::SkillSourceRead {
6596        path: skill_md_path.display().to_string(),
6597        source,
6598    })
6599}
6600
6601/// Locate `skill.md` in a directory, matching the filename case-insensitively. First match wins.
6602fn find_skill_md_in_dir(dir: &Path) -> Result<Option<std::path::PathBuf>, RuntimeError> {
6603    let entries = fs::read_dir(dir).map_err(|source| RuntimeError::SkillSourceRead {
6604        path: dir.display().to_string(),
6605        source,
6606    })?;
6607    for entry in entries {
6608        let entry = entry.map_err(|source| RuntimeError::SkillSourceRead {
6609            path: dir.display().to_string(),
6610            source,
6611        })?;
6612        if entry.file_name().to_string_lossy().to_lowercase() == "skill.md" {
6613            return Ok(Some(entry.path()));
6614        }
6615    }
6616    Ok(None)
6617}
6618
6619fn install_skill_files(
6620    workdir: &Path,
6621    skill_files: Vec<(String, Vec<u8>)>,
6622) -> Result<(), RuntimeError> {
6623    for (name, bytes) in skill_files {
6624        let skill_path = workdir.join("tools").join(&name).join("skill.md");
6625        let Some(parent) = skill_path.parent() else {
6626            return Err(RuntimeError::Runtime(format!(
6627                "failed to derive parent for skill.md path {}",
6628                skill_path.display()
6629            )));
6630        };
6631        fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
6632            path: skill_path.display().to_string(),
6633            source,
6634        })?;
6635        fs::write(&skill_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
6636            path: skill_path.display().to_string(),
6637            source,
6638        })?;
6639    }
6640    Ok(())
6641}
6642
6643/// Refuses a persistent capsule that declares `exports.peer_files` without a short enough
6644/// `max_ttl`.
6645///
6646/// The rule keys on `lifecycle.after_task`, which is the manifest's ephemerality axis. `exit` —
6647/// the default — is ephemeral: the capsule dies with the task and teardown destroys the minting
6648/// key, so every outstanding handle stops verifying at once and the declared lifetime can never
6649/// be the real bound. `sleep` is the operator's opt-out: the capsule stays alive, its instance
6650/// key stays alive, and a handle sitting in persisted A2A message history stays redeemable — so
6651/// there the declared lifetime *is* the bound, and it must be declared and capped.
6652fn check_persistent_handle_ttl(
6653    peer_files: Option<&murmur_artifact::PeerFilesExport>,
6654    lifecycle: &LifecycleConfig,
6655) -> Result<(), RuntimeError> {
6656    let Some(export) = peer_files else {
6657        return Ok(());
6658    };
6659    if lifecycle.after_task != murmur_artifact::AfterTask::Sleep {
6660        return Ok(());
6661    }
6662    let ceiling = murmur_artifact::PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS;
6663    match export.max_ttl_secs {
6664        Some(declared) if declared <= ceiling => Ok(()),
6665        declared => Err(RuntimeError::PersistentCapsuleNeedsHandleTtl {
6666            declared_secs: declared,
6667            ceiling_secs: ceiling,
6668        }),
6669    }
6670}
6671
6672/// Writes the synthetic tool manifests for the two peer-handoff tools, each only when its own
6673/// grant is declared.
6674fn write_peer_handoff_tool_manifests(
6675    workdir: &Path,
6676    can_mint: bool,
6677    can_fetch: bool,
6678) -> Result<(), RuntimeError> {
6679    if can_mint {
6680        write_runtime_provided_tool_manifest(workdir, SHARE_FILE_TOOL, SHARE_FILE_TOOL_MANIFEST)?;
6681    }
6682    if can_fetch {
6683        write_runtime_provided_tool_manifest(
6684            workdir,
6685            FETCH_PEER_FILE_TOOL,
6686            FETCH_PEER_FILE_TOOL_MANIFEST,
6687        )?;
6688    }
6689    Ok(())
6690}
6691
6692/// Tool the minting side gains from `exports.peer_files`.
6693pub(crate) const SHARE_FILE_TOOL: &str = "share-file";
6694
6695/// Tool the ingesting side gains from `capabilities.peer_fetch`.
6696pub(crate) const FETCH_PEER_FILE_TOOL: &str = "fetch-peer-file";
6697
6698/// `share-file`'s manifest. The description tells the model the two things it cannot work out
6699/// from the schema: that the returned handle is what goes into the message, and that the path is
6700/// relative to the declared export root rather than to the workdir.
6701const SHARE_FILE_TOOL_MANIFEST: &str = concat!(
6702    "name: share-file\n",
6703    "version: 0.0.0\n",
6704    "runtime: tool\n",
6705    "implementation: native\n",
6706    "description: \"Mint an opaque handle a named peer can use to fetch one file from this ",
6707    "capsule's declared peer export. `path` is relative to exports.peer_files.root; `peer` is ",
6708    "the peer's address. Returns a handle to put in a message to that peer — it names no ",
6709    "filesystem path and is redeemable only by that peer.\"\n",
6710    "input_schema: '",
6711    r#"{"type":"object","properties":{"path":{"type":"string"},"peer":{"type":"string"},"ttl":{"type":"string"}},"required":["path","peer"]}"#,
6712    "'\n",
6713);
6714
6715/// `fetch-peer-file`'s manifest. It states plainly that the bytes arrive as a file, because a
6716/// model that expects them inline will otherwise ask for them again.
6717const FETCH_PEER_FILE_TOOL_MANIFEST: &str = concat!(
6718    "name: fetch-peer-file\n",
6719    "version: 0.0.0\n",
6720    "runtime: tool\n",
6721    "implementation: native\n",
6722    "description: \"Redeem a handle a peer sent and store the file it names in this capsule's ",
6723    "workdir. Returns the stored path, size and SHA-256 — never the file's contents. Read the ",
6724    "stored path if you need what is in it.\"\n",
6725    "input_schema: '",
6726    r#"{"type":"object","properties":{"peer":{"type":"string"},"handle":{"type":"string"}},"required":["peer","handle"]}"#,
6727    "'\n",
6728);
6729
6730/// Writes the delegation tool's synthetic manifest, only for a capsule that declares at least one
6731/// name in `capabilities.spawn.allow`.
6732///
6733/// A capsule that declares none gets no file, so `delegate-task` is absent from its tool inventory
6734/// and from `session_start`'s `tools_declared` — the grant governs the tool's existence rather
6735/// than its success.
6736fn write_delegate_task_tool_manifest(
6737    workdir: &Path,
6738    spawn_allow: &[String],
6739) -> Result<(), RuntimeError> {
6740    if spawn_allow.is_empty() {
6741        return Ok(());
6742    }
6743    write_runtime_provided_tool_manifest(
6744        workdir,
6745        DELEGATE_TASK_TOOL,
6746        &delegate_task_tool_manifest(spawn_allow),
6747    )
6748}
6749
6750/// Tool a capsule gains from `capabilities.spawn.allow`.
6751pub(crate) const DELEGATE_TASK_TOOL: &str = "delegate-task";
6752
6753/// `delegate-task`'s manifest, with the granted capsule names built into its schema.
6754///
6755/// The description tells the model the three things the schema cannot: that the call does not
6756/// return until the sub-capsule has finished, that `version` is exact because there is no `latest`
6757/// anywhere in this system, and that the task text is the whole of what the sub-capsule is told.
6758fn delegate_task_tool_manifest(spawn_allow: &[String]) -> String {
6759    let allowed = serde_json::to_string(spawn_allow).unwrap_or_else(|_| "[]".to_string());
6760    let schema = format!(
6761        r#"{{"type":"object","properties":{{"capsule":{{"type":"string","enum":{allowed}}},"version":{{"type":"string"}},"task":{{"type":"string"}}}},"required":["capsule","version","task"]}}"#
6762    );
6763    format!(
6764        "name: {DELEGATE_TASK_TOOL}\n\
6765         version: 0.0.0\n\
6766         runtime: tool\n\
6767         implementation: native\n\
6768         description: \"Hand one task to one sub-capsule and wait for its answer. `capsule` must \
6769         be one of the names this capsule is allowed to delegate to; `version` is that capsule's \
6770         exact version, because there is no latest or stable alias; `task` is the whole of what \
6771         the sub-capsule is told, so state the objective in full. The sub-capsule runs as its own \
6772         process with its own workdir and this call does not return until it has finished. \
6773         Returns its answer text.\"\n\
6774         input_schema: '{schema}'\n",
6775    )
6776}
6777
6778/// Writes the plan tool's synthetic manifest, only for a capsule that declares
6779/// `capabilities.plan.submit: true`.
6780///
6781/// A capsule that declares nothing gets no file, so `submit-plan` is absent from its tool
6782/// inventory and from `session_start`'s `tools_declared` — the grant governs the tool's existence
6783/// rather than its success, exactly as [`write_delegate_task_tool_manifest`] does.
6784fn write_submit_plan_tool_manifest(workdir: &Path, plan_submit: bool) -> Result<(), RuntimeError> {
6785    if !plan_submit {
6786        return Ok(());
6787    }
6788    write_runtime_provided_tool_manifest(workdir, SUBMIT_PLAN_TOOL, SUBMIT_PLAN_TOOL_MANIFEST)
6789}
6790
6791/// What a running plan asks of the session that was handed it. The scheduler thread that sent
6792/// one is parked on its `oneshot` until this session replies.
6793enum PlanRequest {
6794    /// A step about to be dispatched, reaching the session's decision point first. The answer is
6795    /// the refusal text, or `None` to let the step run.
6796    Gate(GatedStepCall, tokio::sync::oneshot::Sender<Option<String>>),
6797    /// A `tool` step's call: the tool's name, the step's input, and where the answer goes.
6798    Invoke(
6799        String,
6800        murmur::tool::run::ToolInput,
6801        tokio::sync::oneshot::Sender<Result<murmur::tool::run::ToolResult, String>>,
6802    ),
6803}
6804
6805/// An owned [`crate::plan::PlannedCall`], because the gate's answer comes from another thread.
6806enum GatedStepCall {
6807    Tool {
6808        name: String,
6809        input_json: String,
6810    },
6811    Shell {
6812        binary: String,
6813        command: String,
6814        argv: Vec<String>,
6815    },
6816}
6817
6818impl GatedStepCall {
6819    fn from_planned(call: &crate::plan::PlannedCall<'_>) -> Self {
6820        match call {
6821            crate::plan::PlannedCall::Tool { name, input_json } => Self::Tool {
6822                name: (*name).to_string(),
6823                input_json: (*input_json).to_string(),
6824            },
6825            crate::plan::PlannedCall::Shell {
6826                binary,
6827                command,
6828                argv,
6829            } => Self::Shell {
6830                binary: (*binary).to_string(),
6831                command: (*command).to_string(),
6832                argv: argv.to_vec(),
6833            },
6834        }
6835    }
6836
6837    /// The same [`ResolvedCall`] the decision point is shown for the equivalent call made
6838    /// directly, so a plan step and an agent turn are judged on the same values.
6839    ///
6840    /// The shell arm is resolved here rather than through [`resolve_shell_call`] because the two
6841    /// forms differ: a shell *tool* call carries a `command` string the runtime always hands to
6842    /// the interpreter as `-c`, while a plan step carries a whole argv the scheduler execs
6843    /// directly. `script` is therefore set only for the `-c` form that actually reaches a shell —
6844    /// which is exactly the distinction `ProtectedPaths::check_shell` draws between a body whose
6845    /// redirections are live and an argv where `>` is a literal argument.
6846    fn resolve(&self, workdir: &Path) -> ResolvedCall {
6847        match self {
6848            Self::Tool { name, input_json } => ResolvedCall::Tool {
6849                tool_name: name.clone(),
6850                input_bytes: input_json.len() as u64,
6851                input: input_json.clone(),
6852            },
6853            Self::Shell {
6854                binary,
6855                command,
6856                argv,
6857            } => {
6858                let script = (is_shell_interpreter(binary)
6859                    && argv.first().is_some_and(|first| first == "-c"))
6860                .then(|| argv.get(1).cloned().unwrap_or_default());
6861                let recipe = match script {
6862                    Some(_) => None,
6863                    None => crate::recipes::resolve_recipe(workdir, binary, argv),
6864                };
6865                ResolvedCall::Shell {
6866                    binary: crate::sandbox::resolve_invoked_binary_path(binary),
6867                    command: command.clone(),
6868                    argv: argv.clone(),
6869                    script,
6870                    recipe,
6871                }
6872            }
6873        }
6874    }
6875}
6876
6877/// Tool a capsule gains from `capabilities.plan.submit`.
6878pub(crate) const SUBMIT_PLAN_TOOL: &str = "submit-plan";
6879
6880/// What a plan step is refused with when the session's decision point cannot be reached at all.
6881///
6882/// Fail closed: the alternative is a step running because the thing that would have refused it
6883/// went away.
6884const PLAN_GATE_UNREACHABLE: &str =
6885    "Refused: the session stopped answering policy checks, so this step was not dispatched.";
6886
6887/// `submit-plan`'s manifest. Its schema names one argument and says nothing about a step, because
6888/// the plan format is documented rather than schema-checked here; the description carries the
6889/// three things the schema cannot — that the call blocks until the whole plan has settled, which
6890/// independent steps overlap and which do not, and how a later step reads an earlier one's
6891/// output.
6892const SUBMIT_PLAN_TOOL_MANIFEST: &str = concat!(
6893    "name: submit-plan\n",
6894    "version: 0.0.0\n",
6895    "runtime: tool\n",
6896    "implementation: native\n",
6897    "description: \"Hand one plan to the runtime and get back every step's result. `plan` is an ",
6898    "object with an `id` and a `steps` array; each step has an `id` and exactly one of `tool`, ",
6899    "`shell` or `capsule`, plus optional `input`, `depends_on`, `if`, `on_error` and `retries`. ",
6900    "The call does not return until every step has finished. Independent `shell` and `capsule` ",
6901    "steps run at the same time; `tool` steps are dispatched one at a time even when nothing ",
6902    "orders them. A step's output is reachable from a later step as ",
6903    "$<step id>.output and its status as $<step id>.status, anywhere in that step's input.\"\n",
6904    "input_schema: '",
6905    r#"{"type":"object","properties":{"plan":{"type":"object"}},"required":["plan"]}"#,
6906    "'\n",
6907);
6908
6909fn write_shell_tool_manifests(workdir: &Path, shell_allow: &[String]) -> Result<(), RuntimeError> {
6910    for binary in shell_allow {
6911        let manifest_path = workdir
6912            .join("tools")
6913            .join(binary)
6914            .join(PACKED_MANIFEST_ENTRY);
6915
6916        if manifest_path.exists() {
6917            continue;
6918        }
6919
6920        let Some(parent) = manifest_path.parent() else {
6921            return Err(RuntimeError::Runtime(format!(
6922                "failed to derive parent for shell manifest path {}",
6923                manifest_path.display()
6924            )));
6925        };
6926
6927        fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
6928            path: manifest_path.display().to_string(),
6929            source,
6930        })?;
6931
6932        fs::write(&manifest_path, shell_tool_manifest_yaml(binary)).map_err(|source| {
6933            RuntimeError::WriteToolManifest {
6934                path: manifest_path.display().to_string(),
6935                source,
6936            }
6937        })?;
6938    }
6939
6940    Ok(())
6941}
6942
6943/// Execute a native artifact binary.
6944///
6945/// The binary receives the serialized ToolInput JSON on stdin and must write a valid
6946/// ToolResult JSON object to stdout. The binary's working directory is the capsule workdir.
6947fn dispatch_native_tool(
6948    name: &str,
6949    input: murmur::tool::run::ToolInput,
6950    binary_path: &Path,
6951    accessible_workdir: &Path,
6952    session_workdir: &Path,
6953    policy: &CapabilityPolicy,
6954    enforcement: &sandbox::ShellEnforcement,
6955) -> Result<murmur::tool::run::ToolResult, String> {
6956    use std::{
6957        io::Write,
6958        process::{Command, Stdio},
6959    };
6960
6961    let input_json = serde_json::to_string(&serde_json::json!({
6962        "data": input.data,
6963        "log_path": input.log_path,
6964    }))
6965    .map_err(|e| format!("failed to serialize input for native tool '{name}': {e}"))?;
6966
6967    enforcement.check_workdir_budget()?;
6968
6969    let env = build_shell_env(policy, &[], session_workdir)?;
6970
6971    // Bound to a local before spawning (rather than chained straight into `.spawn()`) so a
6972    // `pre_exec` step can be attached to it, mirroring `execute_shell`'s shape.
6973    let mut command = Command::new(binary_path);
6974    command
6975        .current_dir(accessible_workdir)
6976        .env_clear()
6977        .envs(env)
6978        .stdin(Stdio::piped())
6979        .stdout(Stdio::piped())
6980        .stderr(Stdio::piped());
6981
6982    // This path carries the same hard rlimits and the same cgroup membership as `execute_shell`,
6983    // but installs no seccomp filter and no Landlock scope. That asymmetry is deliberate and
6984    // open: a native-implementation artifact is bounded but not confined.
6985    sandbox::attach_process_limits(&mut command, enforcement);
6986    // Mark every fd >= 3 close-on-exec in the forked child, so this subprocess inherits only the
6987    // stdio pipes configured just above and nothing that merely happened to be open in the
6988    // runtime process. Fds 0/1/2 are deliberately excluded: this function writes the tool's
6989    // `ToolInput` JSON to the child's stdin and parses its `ToolResult` JSON back off stdout, so
6990    // stdio inheritance across `execve` is the point, not a leak — see
6991    // `sandbox::FD_HYGIENE_FIRST_FD` for the full recorded decision.
6992    //
6993    // This is the shared helper `execute_shell`'s path also runs (as the first step of
6994    // `sandbox::linux_enforce::child_install_enforcement`), so the two spawn paths cannot drift
6995    // apart on this dimension. It takes no policy input — fd hygiene is unconditional — and it
6996    // deliberately does not bring seccomp/Landlock enforcement with it; kernel sandboxing of
6997    // native tool subprocesses remains the documented gap it was.
6998    crate::sandbox::apply_fd_hygiene(&mut command);
6999
7000    let mut child = command
7001        .spawn()
7002        .map_err(|e| format!("failed to spawn native tool '{name}': {e}"))?;
7003
7004    if let Some(mut stdin) = child.stdin.take() {
7005        let _ = stdin.write_all(input_json.as_bytes());
7006        // stdin closes when dropped, signalling EOF to the child
7007    }
7008
7009    let output = child
7010        .wait_with_output()
7011        .map_err(|e| format!("native tool '{name}' failed to complete: {e}"))?;
7012
7013    let stdout = String::from_utf8_lossy(&output.stdout);
7014    let stderr = String::from_utf8_lossy(&output.stderr);
7015
7016    if stdout.trim().is_empty() {
7017        return Ok(murmur::tool::run::ToolResult {
7018            status: if output.status.success() {
7019                murmur::tool::run::Status::Passed
7020            } else {
7021                murmur::tool::run::Status::Error
7022            },
7023            summary: Some(format!(
7024                "native tool '{}' exited with {}",
7025                name, output.status
7026            )),
7027            data: if stderr.is_empty() {
7028                None
7029            } else {
7030                Some(stderr.to_string())
7031            },
7032            data_path: None,
7033            truncated: false,
7034            metadata: Vec::new(),
7035        });
7036    }
7037
7038    match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
7039        Ok(json) => {
7040            let status = match json.get("status").and_then(|s| s.as_str()) {
7041                Some("passed") => murmur::tool::run::Status::Passed,
7042                Some("failed") => murmur::tool::run::Status::Failed,
7043                _ => murmur::tool::run::Status::Error,
7044            };
7045            let summary = json
7046                .get("summary")
7047                .and_then(|s| s.as_str())
7048                .map(|s| s.to_string());
7049            let data = json
7050                .get("data")
7051                .and_then(|d| d.as_str())
7052                .map(|s| s.to_string())
7053                .or_else(|| {
7054                    json.get("data")
7055                        .filter(|d| !d.is_null())
7056                        .map(|d| d.to_string())
7057                });
7058            Ok(murmur::tool::run::ToolResult {
7059                status,
7060                summary,
7061                data,
7062                data_path: None,
7063                truncated: false,
7064                metadata: Vec::new(),
7065            })
7066        }
7067        Err(_) => Ok(murmur::tool::run::ToolResult {
7068            status: if output.status.success() {
7069                murmur::tool::run::Status::Passed
7070            } else {
7071                murmur::tool::run::Status::Error
7072            },
7073            summary: Some(format!("native tool '{}' completed", name)),
7074            data: Some(stdout.to_string()),
7075            data_path: None,
7076            truncated: false,
7077            metadata: Vec::new(),
7078        }),
7079    }
7080}
7081
7082/// What a shell tool call will actually run, resolved from the tool name and its input.
7083///
7084/// One value produced by one function, handed to both the policy decision point and the spawn,
7085/// so what a hook is asked to approve and what `execute_shell` is given cannot drift apart.
7086pub(crate) struct ResolvedShellCall {
7087    /// The program that will be invoked, canonicalized against the host `PATH` where that
7088    /// resolves and the bare name otherwise — the same value the post-call `shell-event`
7089    /// carries.
7090    pub binary: String,
7091    /// The `command` string as the tool received it, untruncated. A display value; `argv` and
7092    /// `script` are what identify the call.
7093    pub command: String,
7094    /// The exact argument list handed to the executable.
7095    pub argv: Vec<String>,
7096    /// The `-c` body for the interpreter form, `None` for every other form.
7097    pub script: Option<String>,
7098    /// The body of the recipe this call names, read out of `workdir` by
7099    /// [`crate::recipes::resolve_recipe`], and `None` for every call that names none the
7100    /// runtime can resolve.
7101    pub recipe: Option<String>,
7102}
7103
7104/// Resolve what a shell tool call will run, or `None` when `name` is not a declared shell
7105/// binary or its input carries no usable `command`.
7106///
7107/// The decision point's view of a call. It performs no capability decision of its own: the
7108/// `shell_allow` test here is the routing question "is this name a shell tool at all"; the
7109/// enforcing check is at the top of `execute_shell`.
7110///
7111/// `workdir` is the directory the subprocess will run in, and is where a build-tool recipe is
7112/// read from and confined to.
7113pub(crate) fn resolve_shell_call(
7114    name: &str,
7115    input: &murmur::tool::run::ToolInput,
7116    workdir: &Path,
7117    policy: &CapabilityPolicy,
7118) -> Option<ResolvedShellCall> {
7119    resolve_shell_call_inner(name, input, workdir, policy).ok()
7120}
7121
7122/// [`resolve_shell_call`] keeping the diagnostic, for the dispatch path that reports it to the
7123/// model. The two must stay one function: an argv computed twice is an argv that can be
7124/// approved in one form and executed in another.
7125fn resolve_shell_call_inner(
7126    name: &str,
7127    input: &murmur::tool::run::ToolInput,
7128    workdir: &Path,
7129    policy: &CapabilityPolicy,
7130) -> Result<ResolvedShellCall, String> {
7131    if !policy.shell_allow.iter().any(|allowed| allowed == name) {
7132        return Err(format!(
7133            "binary '{name}' is not in capabilities.shell.allow"
7134        ));
7135    }
7136    let command = extract_shell_command(input)?;
7137    let (argv, script) = if is_shell_interpreter(name) {
7138        (
7139            vec!["-c".to_string(), command.clone()],
7140            Some(command.clone()),
7141        )
7142    } else {
7143        (split_shell_words(&command), None)
7144    };
7145    // An interpreter form is never a recipe invocation: what it names is its own `-c` body, and
7146    // reading a build tool out of that body would be guessing.
7147    let recipe = match script {
7148        Some(_) => None,
7149        None => crate::recipes::resolve_recipe(workdir, name, &argv),
7150    };
7151    Ok(ResolvedShellCall {
7152        binary: crate::sandbox::resolve_invoked_binary_path(name),
7153        command,
7154        argv,
7155        script,
7156        recipe,
7157    })
7158}
7159
7160/// `detach` is what decides whether a slow command can be demoted. `None` runs it to completion
7161/// in the foreground — the only shape available to a caller with no task loop to deliver a
7162/// completion to.
7163#[allow(clippy::too_many_arguments)]
7164fn dispatch_shell_tool(
7165    name: &str,
7166    input: murmur::tool::run::ToolInput,
7167    accessible_workdir: &Path,
7168    session_workdir: &Path,
7169    env_overrides: &[(String, String)],
7170    policy: &CapabilityPolicy,
7171    enforcement: &sandbox::ShellEnforcement,
7172    detach: Option<DetachPolicy>,
7173) -> DispatchOutcome {
7174    let resolved = match resolve_shell_call_inner(name, &input, accessible_workdir, policy) {
7175        Ok(resolved) => resolved,
7176        Err(error) => {
7177            return DispatchOutcome::tool(murmur::tool::run::ToolResult {
7178                status: murmur::tool::run::Status::Error,
7179                summary: Some("shell command parsing failed".to_string()),
7180                data: Some(error),
7181                data_path: None,
7182                truncated: false,
7183                metadata: Vec::new(),
7184            });
7185        }
7186    };
7187    let ResolvedShellCall {
7188        command,
7189        argv,
7190        script,
7191        recipe,
7192        ..
7193    } = resolved;
7194    let args: Vec<&str> = argv.iter().map(String::as_str).collect();
7195
7196    let detach = detach.map(|detach| DetachPolicy {
7197        command: command.clone(),
7198        ..detach
7199    });
7200
7201    match run_shell(
7202        name,
7203        &args,
7204        env_overrides,
7205        accessible_workdir,
7206        session_workdir,
7207        policy,
7208        enforcement,
7209        detach,
7210    ) {
7211        Ok(ShellOutcome::Finished(result)) => {
7212            let shell = ShellDispatchInfo {
7213                // `name` is the invoked binary (each `capabilities.shell.allow` entry is
7214                // exposed as its own tool), resolved to a path by `execute_shell`.
7215                binary: result.binary.clone(),
7216                command: command.clone(),
7217                argv: argv.clone(),
7218                script: script.clone(),
7219                recipe: recipe.clone(),
7220                exit_code: result.exit_code,
7221                stdout: result.stdout.clone(),
7222                stderr: result.stderr.clone(),
7223                stdout_bytes: result.stdout.len() as u64,
7224                stderr_bytes: result.stderr.len() as u64,
7225                duration_ms: result.duration_ms,
7226                resource_limit: result.resource_limit_hit.clone(),
7227            };
7228            DispatchOutcome {
7229                result: shell_result_to_tool_result(name, &command, result),
7230                shell: Some(shell),
7231                detached: None,
7232                is_skill: false,
7233                fatal: None,
7234            }
7235        }
7236        // A demoted command has no exit code, no output and no duration yet, so it fills none of
7237        // the fields `ShellDispatchInfo` exists to carry — hence `shell: None`, and hence no
7238        // `HookEvent::Shell`. What it did produce is the handle.
7239        Ok(ShellOutcome::Detached(info)) => DispatchOutcome {
7240            result: demotion_tool_result(&info.work_id),
7241            shell: None,
7242            detached: Some(info),
7243            is_skill: false,
7244            fatal: None,
7245        },
7246        Err(error) => DispatchOutcome {
7247            // The tool result is filled in either way, so the trace records the call that was
7248            // attempted; `fatal` is what tells the agent turn loop that this particular failure
7249            // is not one the capsule gets another turn to react to.
7250            result: murmur::tool::run::ToolResult {
7251                status: murmur::tool::run::Status::Error,
7252                summary: Some("shell execution failed".to_string()),
7253                data: Some(error.to_string()),
7254                data_path: None,
7255                truncated: false,
7256                metadata: Vec::new(),
7257            },
7258            shell: None,
7259            detached: None,
7260            is_skill: false,
7261            fatal: error.session_fatal(),
7262        },
7263    }
7264}
7265
7266fn extract_shell_command(input: &murmur::tool::run::ToolInput) -> Result<String, String> {
7267    let data = input
7268        .data
7269        .as_deref()
7270        .ok_or_else(|| "shell tool input.data is required".to_string())?;
7271
7272    let json: serde_json::Value = serde_json::from_str(data)
7273        .map_err(|error| format!("shell tool input must be valid JSON: {error}"))?;
7274
7275    let command = json
7276        .get("command")
7277        .and_then(serde_json::Value::as_str)
7278        .map(str::trim)
7279        .filter(|value| !value.is_empty())
7280        .ok_or_else(|| "shell tool input must include a non-empty 'command' string".to_string())?;
7281
7282    Ok(command.to_string())
7283}
7284
7285/// The command line a person would type at a shell prompt to run this call.
7286///
7287/// An interpreter's `command` is already a whole shell line — it becomes the `-c` body — so it
7288/// stands alone. Every other allowlisted binary receives the argument list alone, because
7289/// `shell_tool_manifest_yaml` tells the model to omit the binary name, so the name goes back in
7290/// front of it here.
7291///
7292/// `name` is the declared short name, the `capabilities.shell.allow` entry, not
7293/// `ShellResult::binary`: that field is the resolved absolute path and would render
7294/// `$ /usr/bin/ls .`.
7295fn shell_command_line(name: &str, command: &str) -> String {
7296    if is_shell_interpreter(name) {
7297        format!("$ {command}")
7298    } else {
7299        format!("$ {name} {command}")
7300    }
7301}
7302
7303fn shell_result_to_tool_result(
7304    name: &str,
7305    command: &str,
7306    result: ShellResult,
7307) -> murmur::tool::run::ToolResult {
7308    let mut data = format!(
7309        "{}\nExit code: {}\nStdout:\n{}\nStderr:\n{}",
7310        shell_command_line(name, command),
7311        result.exit_code,
7312        result.stdout,
7313        result.stderr
7314    );
7315
7316    let mut metadata = Vec::new();
7317    if result.truncated {
7318        if let Some(path) = result.full_output_path.as_ref() {
7319            data.push_str(&format!(
7320                "\n\nOutput truncated. Full output written to {path}"
7321            ));
7322            metadata.push(("full_output_path".to_string(), path.clone()));
7323        } else {
7324            data.push_str("\n\nOutput truncated.");
7325        }
7326    }
7327
7328    murmur::tool::run::ToolResult {
7329        status: murmur::tool::run::Status::Passed,
7330        summary: Some(format!(
7331            "Shell command exited with code {}",
7332            result.exit_code
7333        )),
7334        data: Some(data),
7335        data_path: None,
7336        truncated: result.truncated,
7337        metadata,
7338    }
7339}
7340
7341/// The context id one `task.md` task runs under: the operator's `--context` value when they gave
7342/// one, or a fresh id.
7343///
7344/// A supplied id is what makes a record reachable outside A2A: it is the same id the A2A path
7345/// gets from its client, so the two produce the same record path for the same conversation.
7346fn task_context_id(supplied: Option<&str>) -> String {
7347    supplied
7348        .map(str::to_string)
7349        .unwrap_or_else(|| format!("ctx_{}", uuid::Uuid::now_v7().simple()))
7350}
7351
7352/// `~/.murmur/conversations/<record>` for this launch, or `None` when it keeps no record.
7353///
7354/// Three ways to have none, and all three are ordinary: `context.record: off`, a
7355/// `process`-transport capsule (whose CLI owns its own conversation and whose loop never builds a
7356/// message list), and a host whose home directory cannot be resolved. The last is reported and
7357/// survived rather than refused — the record is on by default for every `http` capsule, so a host
7358/// with no usable `HOME` must not become one where nothing launches.
7359fn resolve_conversation_root(
7360    context: Option<&ContextConfig>,
7361    capsule_name: &str,
7362    inference: &murmur_artifact::InferenceConfig,
7363    workdir: &Path,
7364) -> Option<PathBuf> {
7365    if inference.transport == "process" {
7366        return None;
7367    }
7368    let record = crate::conversation::resolve_record_name(context, capsule_name)?;
7369    match crate::conversation::record_root(&record) {
7370        Ok(root) => Some(root),
7371        Err(reason) => {
7372            let message = format!(
7373                "[conversation] the record for '{record}' could not be located ({reason}); \
7374                 this session runs unrecorded"
7375            );
7376            eprintln!("[capsule-runtime] {message}");
7377            agent::append_bootstrap_log(workdir, &message);
7378            None
7379        }
7380    }
7381}
7382
7383/// Enforce both `retain:` blocks and record every deletion in this session's own trace.
7384///
7385/// Called once per launch, immediately after `session_start`. Nothing here can fail a launch: a
7386/// store that cannot be read, a directory that cannot be removed and a trace line that cannot be
7387/// written are each survived, on the same terms as an unresolvable `HOME` in
7388/// [`resolve_conversation_root`] — a capsule whose retention cannot run must still do its work.
7389///
7390/// Session pruning is computed from `workdir.parent()`, the directory holding every sibling
7391/// session, and never considers an id at or after this session's own. Record pruning needs the
7392/// resolved conversation root, the capsule name — records this capsule does not own are never
7393/// touched — and the launch's context id, which is never removed and is the one record
7394/// `max_messages` truncates.
7395#[allow(clippy::too_many_arguments)]
7396async fn apply_retention(
7397    trace: &mut TraceWriter,
7398    workdir: &Path,
7399    session_id: &str,
7400    trace_retain: Option<&murmur_artifact::TraceRetainConfig>,
7401    context_retain: Option<&murmur_artifact::ContextRetainConfig>,
7402    conversation_root: Option<&Path>,
7403    capsule_name: &str,
7404    context_id: Option<&str>,
7405) {
7406    use crate::trace::{
7407        RETENTION_REASON_MAX_MESSAGES, RETENTION_STORE_RECORDS, RETENTION_STORE_SESSIONS,
7408    };
7409
7410    let now_ms = crate::retention::now_ms();
7411
7412    if let (Some(policy), Some(sessions_root)) = (trace_retain, workdir.parent()) {
7413        let pruned = crate::retention::prune_sessions(sessions_root, session_id, policy, now_ms);
7414        for reason in [
7415            crate::trace::RETENTION_REASON_MAX_SESSIONS,
7416            crate::trace::RETENTION_REASON_MAX_AGE,
7417        ] {
7418            let targets: Vec<String> = pruned
7419                .iter()
7420                .filter(|session| session.reason == reason)
7421                .map(|session| session.name.clone())
7422                .collect();
7423            if targets.is_empty() {
7424                continue;
7425            }
7426            let _ = trace
7427                .write_retention(RETENTION_STORE_SESSIONS, reason, targets, None)
7428                .await;
7429        }
7430    }
7431
7432    let (Some(policy), Some(root)) = (context_retain, conversation_root) else {
7433        return;
7434    };
7435
7436    let pruned = crate::retention::prune_records(root, capsule_name, context_id, policy, now_ms);
7437    if !pruned.is_empty() {
7438        let targets: Vec<String> = pruned
7439            .iter()
7440            .map(|record| record.context_id.clone())
7441            .collect();
7442        let _ = trace
7443            .write_retention(
7444                RETENTION_STORE_RECORDS,
7445                crate::trace::RETENTION_REASON_MAX_AGE,
7446                targets,
7447                None,
7448            )
7449            .await;
7450    }
7451
7452    // `max_messages` truncates the one record this launch opens, at the point it is opened: that
7453    // is where the growth is, it is O(one record) rather than O(every conversation), and it never
7454    // rewrites a conversation this capsule is not touching. A launch that mints a context per
7455    // task opens no record here, so it has nothing to truncate.
7456    let (Some(keep), Some(context_id)) = (policy.max_messages, context_id) else {
7457        return;
7458    };
7459    let path = crate::conversation::record_file(root, context_id);
7460    match crate::conversation::read_header(&path) {
7461        // A header naming another capsule is that capsule's record, whatever context id this
7462        // launch was handed. An unowned record on this launch's own context is one this launch
7463        // is about to append to and adopt, so the truncation adopts it in the same rewrite.
7464        Some(header) if header.capsule != capsule_name => return,
7465        _ => {}
7466    }
7467    match crate::retention::truncate_record(&path, keep, capsule_name) {
7468        Ok(outcome) if outcome.dropped > 0 => {
7469            let _ = trace
7470                .write_retention(
7471                    RETENTION_STORE_RECORDS,
7472                    RETENTION_REASON_MAX_MESSAGES,
7473                    vec![context_id.to_string()],
7474                    Some(outcome.dropped),
7475                )
7476                .await;
7477        }
7478        Ok(_) => {}
7479        Err(reason) => {
7480            let message = format!(
7481                "[retention] {} could not be truncated ({reason}); this record keeps growing",
7482                path.display()
7483            );
7484            eprintln!("[capsule-runtime] {message}");
7485            agent::append_bootstrap_log(workdir, &message);
7486        }
7487    }
7488}
7489
7490fn resolve_context_window(context: Option<&ContextConfig>) -> u32 {
7491    context.and_then(|c| c.max_tokens).unwrap_or(0)
7492}
7493
7494fn validate_capability_policy(policy: &CapabilityPolicy) -> Result<(), RuntimeError> {
7495    parse_network_allow_rules(&policy.network_allow)?;
7496    if let Some(scope) = policy.filesystem_scope.as_deref() {
7497        validate_filesystem_scope(scope)?;
7498    }
7499
7500    Ok(())
7501}
7502
7503fn enforce_allowlist<T, F>(
7504    allowlisted_tools: &HashSet<String>,
7505    name: &str,
7506    dispatch: F,
7507) -> Result<T, String>
7508where
7509    F: FnOnce() -> Result<T, String>,
7510{
7511    if !allowlisted_tools.contains(name) {
7512        return Err(format!(
7513            "tool '{name}' is not declared in manifest allowlist"
7514        ));
7515    }
7516
7517    dispatch()
7518}
7519
7520/// What ended the task loop's bounded wait: a task arriving (or its channel closing) or a report
7521/// on work this runtime started.
7522enum Woke {
7523    Task(Option<IncomingTask>),
7524    Report(DetachedReport),
7525    /// The session received `SIGTERM`.
7526    Terminating,
7527}
7528
7529/// Say that a task was cancelled while it was still `submitted`, which is all such a task gets: no
7530/// `task_start`, no `on-task-start`, no request to the provider, and no `task_end`, because it
7531/// never ran. The `PHASE_QUEUED` record is its ending.
7532///
7533/// The final status event is the only thing that closes a `message/stream` connection on this
7534/// task: it never reaches an agent loop, so nothing else would.
7535async fn record_canceled_before_start(
7536    task: &IncomingTask,
7537    trace: &mut TraceWriter,
7538    detached: &Arc<DetachedRegistry>,
7539    live_delegations: &crate::cancel::LiveDelegations,
7540    sse: &Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
7541    event_id: &mut u64,
7542) {
7543    let residue = crate::cancel::Residue::snapshot(Some(detached), live_delegations);
7544    let _ = trace
7545        .write_task_canceled(
7546            &task.task_id,
7547            None,
7548            crate::cancel::PHASE_QUEUED,
7549            residue.detached_work_ids(),
7550            residue.delegation_ids(),
7551        )
7552        .await;
7553    let _ = trace.flush().await;
7554    emit_sse(
7555        sse,
7556        event_id,
7557        "status",
7558        &crate::streaming::TaskStatusUpdateEvent {
7559            id: task.task_id.clone(),
7560            context_id: Some(task.context_id.clone()),
7561            status: crate::streaming::StreamStatus {
7562                state: "canceled".into(),
7563                message: "task canceled before it started".into(),
7564                response: None,
7565            },
7566            r#final: true,
7567        },
7568    )
7569    .await;
7570}
7571
7572/// Empty every lane on the way out of a terminating session, starting nothing.
7573///
7574/// A task the `SIGTERM` handler cancelled is recorded as cancelled before it started, exactly as
7575/// the activation step records one. A task the door accepted after the handler ran is not
7576/// `Canceled` and is dropped without a record.
7577async fn close_lanes_on_termination(
7578    lanes: &mut LaneQueue,
7579    task_registry: &Arc<Mutex<TaskRegistry>>,
7580    trace: &mut TraceWriter,
7581    detached: &Arc<DetachedRegistry>,
7582    live_delegations: &crate::cancel::LiveDelegations,
7583    sse: &Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
7584    event_id: &mut u64,
7585) {
7586    // `None` for the active lane is safe only because nothing taken here is started.
7587    while let Some((_, task)) = lanes.next(None) {
7588        if task_registry.lock().unwrap().is_canceled(&task.task_id) {
7589            record_canceled_before_start(&task, trace, detached, live_delegations, sse, event_id)
7590                .await;
7591        }
7592    }
7593}
7594
7595/// Turn a report about work this runtime started into a queued `completion`-origin task, and
7596/// record whatever join the report needs in the trace.
7597///
7598/// Two kinds of report, one path: a demoted shell command finishing, and a prior session's
7599/// demoted commands going unaccounted. Both are [`TaskOrigin::Completion`] and land in the `bg`
7600/// lane by [`TaskLane::for_origin`]'s rule; neither declares a lane. A delegation's outcome is
7601/// not one of these — it arrives at the door as an ordinary inbound completion, posted by the
7602/// child or by the watcher behind it.
7603///
7604/// `can_accept` is not consulted, on either arm. A completion is work the capsule already
7605/// admitted when it admitted the task that started the command, and a loss report is the only
7606/// account anything will ever give of that work; refusing either would drop the result this path
7607/// exists to deliver. The `enqueue` is not optional either: `start_task` asserts a positive
7608/// pending count, so a task pushed onto the queue without one would trip that assertion. An
7609/// outstanding report therefore counts against `queue_depth`, and a capsule with detached work in
7610/// flight has less room for new inbound requests. A loss report costs exactly one pending item
7611/// however many work ids it names, and the drain loop clears it on its first pass.
7612async fn enqueue_detached_report(
7613    report: DetachedReport,
7614    task_registry: &Arc<Mutex<TaskRegistry>>,
7615    lanes: &mut LaneQueue,
7616    trace: &mut TraceWriter,
7617) {
7618    let task = match report {
7619        DetachedReport::Completed(completion) => {
7620            let task = IncomingTask {
7621                task_id: format!("tsk_{}", uuid::Uuid::now_v7().simple()),
7622                context_id: completion.context_id.clone(),
7623                message_id: format!("msg_{}", uuid::Uuid::now_v7().simple()),
7624                message_text: completion.message_text(),
7625                provenance: completion.provenance,
7626                // Nothing propagated a trace context to a background command: the turn that
7627                // started it is over, and inventing a parent span would attribute the completion
7628                // to it.
7629                traceparent: None,
7630                source: crate::a2a::SOURCE_DETACHED_SHELL,
7631                // A demoted shell command reports on a work id, not on a delegation: no
7632                // sub-capsule was launched, so there is no delegation for the trace to join this
7633                // task to.
7634                delegation_id: None,
7635            };
7636            let _ = trace
7637                .write_shell_completed(
7638                    &completion.work_id,
7639                    &completion.binary,
7640                    &completion.command,
7641                    completion.exit_code,
7642                    completion.duration_ms,
7643                    &completion.output_path,
7644                    completion.output_bytes,
7645                    completion.resource_limit.clone(),
7646                    completion.status(),
7647                    &task.task_id,
7648                )
7649                .await;
7650            task
7651        }
7652        // Nothing is written to this session's trace beyond the `task_start` the loop writes when
7653        // it starts the task: the `shell_lost` markers are already in the trace of the session
7654        // that started the work, which is the file that has to hold them for the marker to clear.
7655        DetachedReport::Lost(report) => IncomingTask {
7656            task_id: report.task_id.clone(),
7657            context_id: report.context_id.clone(),
7658            message_id: format!("msg_{}", uuid::Uuid::now_v7().simple()),
7659            message_text: report.message_text(),
7660            provenance: report.provenance,
7661            traceparent: None,
7662            source: crate::a2a::SOURCE_DETACHED_LOST,
7663            delegation_id: None,
7664        },
7665    };
7666
7667    task_registry
7668        .lock()
7669        .unwrap()
7670        .enqueue(&task.task_id, &task.context_id);
7671    lanes.push(task);
7672}
7673
7674fn generate_session_id() -> String {
7675    format!("ses_{}", uuid::Uuid::now_v7().simple())
7676}
7677
7678fn resolve_lifecycle(
7679    base: Option<LifecycleConfig>,
7680    override_: Option<&murmur_artifact::LifecycleOverride>,
7681) -> LifecycleConfig {
7682    let mut config = base.unwrap_or_default();
7683    if let Some(ov) = override_ {
7684        if let Some(ta) = &ov.task_acceptance {
7685            config.task_acceptance = ta.clone();
7686        }
7687        if let Some(at) = &ov.after_task {
7688            config.after_task = at.clone();
7689        }
7690    }
7691    config
7692}
7693
7694#[cfg(test)]
7695mod tests {
7696    use murmur_artifact::LockedArtifact;
7697
7698    // ── delegate-task's synthetic manifest ───────────────────────────────────
7699
7700    /// The tool's contract, pinned: the model reads this manifest, and the `enum` is the whole of
7701    /// what stops it naming a capsule the operator never granted.
7702    #[test]
7703    fn the_delegation_manifest_carries_the_granted_names_and_nothing_else() {
7704        let manifest =
7705            super::delegate_task_tool_manifest(&["worker".to_string(), "reviewer".to_string()]);
7706        let parsed: serde_yaml::Value =
7707            serde_yaml::from_str(&manifest).expect("the generated manifest is YAML");
7708        assert_eq!(parsed["name"].as_str(), Some("delegate-task"));
7709        assert_eq!(parsed["runtime"].as_str(), Some("tool"));
7710        assert_eq!(parsed["implementation"].as_str(), Some("native"));
7711
7712        let schema: serde_json::Value =
7713            serde_json::from_str(parsed["input_schema"].as_str().expect("a schema string"))
7714                .expect("the schema is JSON");
7715        assert_eq!(
7716            schema["required"],
7717            serde_json::json!(["capsule", "version", "task"])
7718        );
7719        assert_eq!(
7720            schema["properties"]["capsule"]["enum"],
7721            serde_json::json!(["worker", "reviewer"]),
7722            "the enum is the capsule's own spawn.allow, in declaration order"
7723        );
7724        // No URL, no credential, no workdir, no capability: the runtime composes all four.
7725        for absent in ["roost", "url", "credential", "approval", "workdir"] {
7726            assert!(
7727                !schema["properties"]
7728                    .as_object()
7729                    .expect("an object")
7730                    .contains_key(absent),
7731                "'{absent}' must not be an argument the agent supplies"
7732            );
7733        }
7734    }
7735
7736    /// A capsule that declares no `capabilities.spawn.allow` gets no file, so the tool is absent
7737    /// from its inventory rather than present and failing.
7738    #[test]
7739    fn an_ungranted_capsule_is_written_no_delegation_tool() {
7740        let dir = tempfile::tempdir().unwrap();
7741        super::write_delegate_task_tool_manifest(dir.path(), &[]).unwrap();
7742        assert!(!dir.path().join("tools").join("delegate-task").exists());
7743
7744        super::write_delegate_task_tool_manifest(dir.path(), &["worker".to_string()]).unwrap();
7745        assert!(dir
7746            .path()
7747            .join("tools")
7748            .join("delegate-task")
7749            .join(PACKED_MANIFEST_ENTRY)
7750            .exists());
7751    }
7752
7753    // ── submit-plan's synthetic manifest ─────────────────────────────────────
7754
7755    /// The plan tool's contract, pinned: one object argument, and a description carrying the
7756    /// three things the schema cannot say.
7757    #[test]
7758    fn the_plan_manifest_declares_one_object_argument() {
7759        let parsed: serde_yaml::Value =
7760            serde_yaml::from_str(super::SUBMIT_PLAN_TOOL_MANIFEST).expect("the manifest is YAML");
7761        assert_eq!(parsed["name"].as_str(), Some("submit-plan"));
7762        assert_eq!(parsed["version"].as_str(), Some("0.0.0"));
7763        assert_eq!(parsed["runtime"].as_str(), Some("tool"));
7764        assert_eq!(parsed["implementation"].as_str(), Some("native"));
7765
7766        let schema: serde_json::Value =
7767            serde_json::from_str(parsed["input_schema"].as_str().expect("a schema string"))
7768                .expect("the schema is JSON");
7769        assert_eq!(schema["required"], serde_json::json!(["plan"]));
7770        assert_eq!(schema["properties"]["plan"]["type"], "object");
7771
7772        let description = parsed["description"].as_str().expect("a description");
7773        assert!(description.contains("does not return until every step has finished"));
7774        assert!(description.contains("at the same time"));
7775        assert!(description.contains("$<step id>.output"));
7776    }
7777
7778    /// A capsule that declares no `capabilities.plan.submit` gets no file, so the tool is absent
7779    /// from its inventory rather than present and failing.
7780    #[test]
7781    fn an_ungranted_capsule_is_written_no_plan_tool() {
7782        let dir = tempfile::tempdir().unwrap();
7783        super::write_submit_plan_tool_manifest(dir.path(), false).unwrap();
7784        assert!(!dir.path().join("tools").join("submit-plan").exists());
7785
7786        super::write_submit_plan_tool_manifest(dir.path(), true).unwrap();
7787        assert!(dir
7788            .path()
7789            .join("tools")
7790            .join("submit-plan")
7791            .join(PACKED_MANIFEST_ENTRY)
7792            .exists());
7793    }
7794
7795    // ── reserved tool names ──────────────────────────────────────────────────
7796
7797    /// The list is what every runtime-provided writer is routed through, so a further synthetic
7798    /// tool added without extending it has to fail here rather than ship shadowed by an artifact
7799    /// of the same name. The arity assertion is the part that catches that.
7800    #[test]
7801    fn the_reserved_set_covers_every_runtime_provided_tool() {
7802        assert_eq!(
7803            super::RESERVED_TOOL_NAMES.len(),
7804            4,
7805            "a new runtime-provided tool must be added to RESERVED_TOOL_NAMES, and this arity \
7806             raised, before its writer can succeed"
7807        );
7808        for name in [
7809            super::SHARE_FILE_TOOL,
7810            super::FETCH_PEER_FILE_TOOL,
7811            super::DELEGATE_TASK_TOOL,
7812            super::SUBMIT_PLAN_TOOL,
7813        ] {
7814            assert!(
7815                super::is_reserved_tool_name(name),
7816                "'{name}' is answered by the runtime and must be reserved"
7817            );
7818        }
7819        // Operator-chosen shell binary names are deliberately not members.
7820        assert!(!super::is_reserved_tool_name("bash"));
7821        // Case-sensitive, because artifact names are.
7822        assert!(!super::is_reserved_tool_name("Delegate-Task"));
7823    }
7824
7825    /// The funnel guard on the artifact side. `manage.pull()` writes through this function with no
7826    /// staging check ahead of it, so a capsule that pulls `delegate-task` mid-session must be
7827    /// refused here — and must leave the synthetic manifest untouched.
7828    #[test]
7829    fn an_artifact_manifest_write_refuses_a_reserved_name() {
7830        let dir = tempfile::tempdir().unwrap();
7831        super::write_delegate_task_tool_manifest(dir.path(), &["worker".to_string()]).unwrap();
7832        let synthetic = dir
7833            .path()
7834            .join("tools")
7835            .join(super::DELEGATE_TASK_TOOL)
7836            .join(PACKED_MANIFEST_ENTRY);
7837        let before = std::fs::read_to_string(&synthetic).unwrap();
7838
7839        for name in super::RESERVED_TOOL_NAMES {
7840            let error = super::write_tool_manifest(dir.path(), name, "name: impostor\n")
7841                .expect_err("a reserved name must not be written as an artifact manifest");
7842            assert!(
7843                matches!(error, RuntimeError::ReservedToolName { .. }),
7844                "got {error:?}"
7845            );
7846            assert!(
7847                error.to_string().contains(name),
7848                "the refusal names the collision: {error}"
7849            );
7850        }
7851
7852        assert_eq!(
7853            std::fs::read_to_string(&synthetic).unwrap(),
7854            before,
7855            "the synthetic manifest survives the refused write byte for byte"
7856        );
7857        assert!(
7858            !dir.path()
7859                .join("tools")
7860                .join(super::SHARE_FILE_TOOL)
7861                .exists(),
7862            "a refused write creates no directory"
7863        );
7864    }
7865
7866    /// The inverse guard on the synthetic side.
7867    #[test]
7868    fn a_runtime_provided_write_refuses_an_unreserved_name() {
7869        let dir = tempfile::tempdir().unwrap();
7870        let error =
7871            super::write_runtime_provided_tool_manifest(dir.path(), "summon-task", "name: x\n")
7872                .expect_err("an unreserved name must not be written as a runtime-provided tool");
7873        assert!(
7874            matches!(error, RuntimeError::RuntimeProvidedToolNotReserved { .. }),
7875            "got {error:?}"
7876        );
7877        assert!(
7878            error.to_string().contains("RESERVED_TOOL_NAMES"),
7879            "the refusal says where to add the name: {error}"
7880        );
7881        assert!(!dir.path().join("tools").exists(), "and writes no file");
7882    }
7883
7884    /// The operator-facing half: every reserved name is refused as an artifact, and the message
7885    /// carries the whole set so the operator sees what else is off-limits.
7886    #[test]
7887    fn a_declared_artifact_under_a_reserved_name_is_refused() {
7888        for name in super::RESERVED_TOOL_NAMES {
7889            let error = super::check_no_reserved_tool_names([name, "corpus"])
7890                .expect_err("a reserved name must be refused as an artifact name");
7891            let rendered = error.to_string();
7892            assert!(rendered.contains(name), "{rendered}");
7893            for reserved in super::RESERVED_TOOL_NAMES {
7894                assert!(
7895                    rendered.contains(reserved),
7896                    "the refusal lists the whole reserved set: {rendered}"
7897                );
7898            }
7899        }
7900        super::check_no_reserved_tool_names(["corpus", "bash", "share_file"])
7901            .expect("a capsule with no collision is untouched");
7902    }
7903
7904    // ── task-start-event.context-window ──────────────────────────────────────
7905
7906    /// The manifest body every `context:` case below shares, minus the `context:` block.
7907    const CONTEXT_WINDOW_MANIFEST: &str = r#"name: windowed
7908version: 0.1.0
7909runtime: capsule
7910artifacts: []
7911inference:
7912  transport: http
7913  endpoint: https://api.anthropic.com
7914  model: claude-opus-4-5
7915  max_tokens: 4096
7916  driver:
7917    artifact: murmur-driver-anthropic
7918"#;
7919
7920    /// The `context-window` the three `HookEvent::TaskStart` sites in `launch_session`
7921    /// send, computed the way they compute it: from the parsed manifest's `context:`
7922    /// block, through [`resolve_context_window`], widened to the WIT `u64`.
7923    fn dispatched_context_window(manifest_yaml: &str) -> u64 {
7924        let manifest = murmur_artifact::RuntimeManifest::from_yaml_str(manifest_yaml)
7925            .expect("the manifest under test parses");
7926        u64::from(resolve_context_window(manifest.context.as_ref()))
7927    }
7928
7929    /// A capsule declaring `context.max_tokens` puts that number on every
7930    /// `on-task-start`, so a seeding hook never has to know the model or its window.
7931    #[test]
7932    fn a_declared_context_window_reaches_on_task_start() {
7933        let yaml = format!("{CONTEXT_WINDOW_MANIFEST}context:\n  max_tokens: 200000\n");
7934        assert_eq!(dispatched_context_window(&yaml), 200_000);
7935    }
7936
7937    /// A capsule with no `context:` block sends `0` — the WIT contract's "the host has
7938    /// not computed this", never a guessed default window.
7939    #[test]
7940    fn no_context_block_sends_a_zero_context_window() {
7941        assert_eq!(dispatched_context_window(CONTEXT_WINDOW_MANIFEST), 0);
7942    }
7943
7944    // ── The persistent-capsule handle-TTL rule ───────────────────────────────
7945
7946    fn peer_export(max_ttl_secs: Option<u64>) -> murmur_artifact::PeerFilesExport {
7947        murmur_artifact::PeerFilesExport {
7948            root: "out/".to_string(),
7949            max_ttl_secs,
7950            max_bytes: 10 * 1024 * 1024,
7951        }
7952    }
7953
7954    fn lifecycle_with(after_task: murmur_artifact::AfterTask) -> LifecycleConfig {
7955        LifecycleConfig {
7956            after_task,
7957            ..Default::default()
7958        }
7959    }
7960
7961    /// An ephemeral capsule needs no ceiling at all: teardown destroys the key, so the declared
7962    /// lifetime can never be the real bound however long it is.
7963    #[test]
7964    fn an_ephemeral_capsule_may_declare_any_handle_ttl_or_none() {
7965        for declared in [None, Some(1), Some(900), Some(86_400), Some(u64::MAX)] {
7966            assert!(
7967                check_persistent_handle_ttl(
7968                    Some(&peer_export(declared)),
7969                    &lifecycle_with(murmur_artifact::AfterTask::Exit),
7970                )
7971                .is_ok(),
7972                "exit + max_ttl {declared:?} must launch"
7973            );
7974        }
7975    }
7976
7977    #[test]
7978    fn a_persistent_capsule_must_declare_a_handle_ttl_at_or_under_the_ceiling() {
7979        for declared in [Some(1), Some(600), Some(900)] {
7980            assert!(
7981                check_persistent_handle_ttl(
7982                    Some(&peer_export(declared)),
7983                    &lifecycle_with(murmur_artifact::AfterTask::Sleep),
7984                )
7985                .is_ok(),
7986                "sleep + max_ttl {declared:?} must launch"
7987            );
7988        }
7989        for declared in [None, Some(901), Some(1800), Some(3600)] {
7990            let error = check_persistent_handle_ttl(
7991                Some(&peer_export(declared)),
7992                &lifecycle_with(murmur_artifact::AfterTask::Sleep),
7993            )
7994            .expect_err("sleep + max_ttl {declared:?} must refuse");
7995            assert!(matches!(
7996                error,
7997                RuntimeError::PersistentCapsuleNeedsHandleTtl { .. }
7998            ));
7999            let rendered = error.to_string();
8000            assert!(
8001                rendered.contains("exports.peer_files.max_ttl"),
8002                "{rendered}"
8003            );
8004            assert!(
8005                rendered.contains("lifecycle.after_task: sleep"),
8006                "{rendered}"
8007            );
8008            assert!(rendered.contains("900s"), "{rendered}");
8009            assert!(rendered.contains("durability"), "{rendered}");
8010        }
8011    }
8012
8013    /// The rule is about handles, so a capsule that declares no peer export is never asked.
8014    #[test]
8015    fn a_capsule_without_peer_files_is_unaffected_by_the_ttl_rule() {
8016        for after_task in [
8017            murmur_artifact::AfterTask::Exit,
8018            murmur_artifact::AfterTask::Sleep,
8019        ] {
8020            assert!(check_persistent_handle_ttl(None, &lifecycle_with(after_task)).is_ok());
8021        }
8022    }
8023    use murmur_artifact::{ArtifactMeta, ArtifactRuntime, Registry, ResolvedArtifact, RuntimeType};
8024    use tempfile::TempDir;
8025
8026    use super::*;
8027
8028    fn bootstrap_log_contents(workdir: &Path) -> String {
8029        fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
8030    }
8031
8032    fn lifecycle(task_acceptance: TaskAcceptance, after_task: AfterTask) -> LifecycleConfig {
8033        LifecycleConfig {
8034            task_acceptance,
8035            after_task,
8036            ..LifecycleConfig::default()
8037        }
8038    }
8039
8040    /// A capsule that can delegate and leaves `lifecycle` at its defaults is warned, because
8041    /// `after_task: exit` ends the session before any outcome can arrive.
8042    #[test]
8043    fn warn_for_unreachable_delegation_outcomes_writes_the_code_and_link_to_bootstrap_log() {
8044        let temp = TempDir::new().unwrap();
8045        warn_for_unreachable_delegation_outcomes(temp.path(), true, &LifecycleConfig::default());
8046        let log = bootstrap_log_contents(temp.path());
8047
8048        assert!(log.contains(W_SEC_020), "log was: {log}");
8049        assert!(
8050            log.contains(&security_warning_link(W_SEC_020)),
8051            "log was: {log}"
8052        );
8053        assert!(log.contains("lifecycle.task_acceptance"), "log was: {log}");
8054        assert!(log.contains("lifecycle.after_task"), "log was: {log}");
8055    }
8056
8057    /// The only lifecycle a completion can reach is `queue` + `sleep`; a capsule that declares no
8058    /// `capabilities.spawn.allow` is never warned whatever it declares.
8059    #[test]
8060    fn only_a_delegating_capsule_that_cannot_be_told_is_warned() {
8061        assert!(unreachable_delegation_outcomes_warning(
8062            true,
8063            &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep)
8064        )
8065        .is_none());
8066        for lifecycle in [
8067            lifecycle(TaskAcceptance::Queue, AfterTask::Exit),
8068            lifecycle(TaskAcceptance::Single, AfterTask::Sleep),
8069            lifecycle(TaskAcceptance::None, AfterTask::Sleep),
8070        ] {
8071            assert!(
8072                unreachable_delegation_outcomes_warning(true, &lifecycle).is_some(),
8073                "{lifecycle:?} cannot receive a completion"
8074            );
8075            assert!(
8076                unreachable_delegation_outcomes_warning(false, &lifecycle).is_none(),
8077                "a capsule that cannot delegate is not warned about delegating"
8078            );
8079        }
8080    }
8081
8082    /// A capsule that can run shell commands and leaves `lifecycle` at its defaults is warned,
8083    /// because `after_task: exit` ends the session before any demoted command's completion can
8084    /// arrive.
8085    #[test]
8086    fn warn_for_unreachable_shell_completions_writes_the_code_and_link_to_bootstrap_log() {
8087        let temp = TempDir::new().unwrap();
8088        warn_for_unreachable_shell_completions(temp.path(), true, &LifecycleConfig::default());
8089        let log = bootstrap_log_contents(temp.path());
8090
8091        assert!(log.contains(W_SEC_022), "log was: {log}");
8092        assert!(
8093            log.contains(&security_warning_link(W_SEC_022)),
8094            "log was: {log}"
8095        );
8096        assert!(log.contains("lifecycle.shell_grace_secs"), "log was: {log}");
8097        assert!(log.contains("lifecycle.task_acceptance"), "log was: {log}");
8098        assert!(log.contains("lifecycle.after_task"), "log was: {log}");
8099    }
8100
8101    /// The only lifecycle a completion can reach is `queue` + `sleep`; a capsule that declares no
8102    /// `capabilities.shell.allow` is never warned whatever it declares.
8103    #[test]
8104    fn only_a_shell_running_capsule_that_cannot_be_told_is_warned() {
8105        assert!(unreachable_shell_completions_warning(
8106            true,
8107            &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep)
8108        )
8109        .is_none());
8110        for lifecycle in [
8111            lifecycle(TaskAcceptance::Queue, AfterTask::Exit),
8112            lifecycle(TaskAcceptance::Single, AfterTask::Sleep),
8113            lifecycle(TaskAcceptance::None, AfterTask::Sleep),
8114        ] {
8115            assert_eq!(
8116                unreachable_shell_completions_warning(true, &lifecycle).map(|(code, _)| code),
8117                Some(W_SEC_022),
8118                "{lifecycle:?} cannot receive a completion"
8119            );
8120            assert!(
8121                unreachable_shell_completions_warning(false, &lifecycle).is_none(),
8122                "a capsule that cannot run shell commands is not warned about them"
8123            );
8124        }
8125    }
8126
8127    /// `lifecycle.shell_grace_secs` is not part of the decision. `0` demotes on the first check
8128    /// after the spawn, so a low grace makes the discard more likely rather than less, and no
8129    /// value of it turns demotion off.
8130    #[test]
8131    fn the_shell_completion_warning_ignores_the_grace_period() {
8132        for shell_grace_secs in [0, 1, 10, 3_600] {
8133            let receiving = LifecycleConfig {
8134                task_acceptance: TaskAcceptance::Queue,
8135                after_task: AfterTask::Sleep,
8136                shell_grace_secs,
8137                ..LifecycleConfig::default()
8138            };
8139            assert!(
8140                unreachable_shell_completions_warning(true, &receiving).is_none(),
8141                "queue+sleep is silent at every grace period, including {shell_grace_secs}"
8142            );
8143            let discarding = LifecycleConfig {
8144                after_task: AfterTask::Exit,
8145                ..receiving
8146            };
8147            assert!(
8148                unreachable_shell_completions_warning(true, &discarding).is_some(),
8149                "exit warns at every grace period, including {shell_grace_secs}"
8150            );
8151        }
8152    }
8153
8154    // ── capabilities.env.allow secret grants ─────────────────────────────────
8155
8156    fn env_allow_policy(names: &[&str], strip: &[&str]) -> CapabilityPolicy {
8157        CapabilityPolicy {
8158            env_allow: names.iter().map(|name| (*name).to_string()).collect(),
8159            shell_strip_env: strip.iter().map(|name| (*name).to_string()).collect(),
8160            ..CapabilityPolicy::default()
8161        }
8162    }
8163
8164    /// The held arm: a credential-shaped name the backstop's fixed list does not cover reaches
8165    /// every guest, and the default lifecycle does not keep the capsule past its task.
8166    #[test]
8167    fn a_credential_shaped_name_the_backstop_keeps_is_reported_as_reaching_the_guest() {
8168        let grants = secret_shaped_env_grants(
8169            &env_allow_policy(&["DATABASE_PASSWORD"], &[]),
8170            &LifecycleConfig::default(),
8171        );
8172
8173        assert_eq!(
8174            grants,
8175            vec![SecretShapedEnvGrant {
8176                name: "DATABASE_PASSWORD".to_string(),
8177                outlives_launcher: false,
8178            }]
8179        );
8180        let message = secret_shaped_env_grant_message(&grants[0]);
8181        assert!(message.contains("the capsule holds it"), "{message}");
8182        assert!(!message.contains("lifecycle.after_task"), "{message}");
8183    }
8184
8185    /// The escalated arm: `after_task: sleep` alone, without `task_acceptance: queue`, still keeps
8186    /// the capsule alive past the task that launched it, so the sentence is added. Read directly
8187    /// off `after_task` rather than through `can_receive_background_tasks`, which would stay
8188    /// silent here.
8189    #[test]
8190    fn sleep_escalates_a_held_grant_even_without_a_queue() {
8191        for acceptance in [
8192            TaskAcceptance::Single,
8193            TaskAcceptance::Queue,
8194            TaskAcceptance::None,
8195        ] {
8196            let grants = secret_shaped_env_grants(
8197                &env_allow_policy(&["DATABASE_PASSWORD"], &[]),
8198                &lifecycle(acceptance.clone(), AfterTask::Sleep),
8199            );
8200            assert!(
8201                grants[0].outlives_launcher,
8202                "{acceptance:?} + sleep outlives the launching task"
8203            );
8204            let message = secret_shaped_env_grant_message(&grants[0]);
8205            assert!(message.contains("lifecycle.after_task: sleep"), "{message}");
8206            assert!(
8207                message.contains("with nothing left waiting on it"),
8208                "{message}"
8209            );
8210        }
8211    }
8212
8213    /// `GITHUB_TOKEN` is credential-shaped *and* on the backstop's list, so no guest holds it and
8214    /// there is no grant to report; `E-CAP-016` refuses the declaration instead.
8215    #[test]
8216    fn a_name_the_backstop_drops_produces_no_grant() {
8217        assert!(secret_shaped_env_grants(
8218            &env_allow_policy(&["GITHUB_TOKEN"], &[]),
8219            &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep),
8220        )
8221        .is_empty());
8222    }
8223
8224    /// A manifest's own `capabilities.shell.strip_env` pattern takes a credential-shaped name out
8225    /// of the warning, because the backstop consults that list too.
8226    #[test]
8227    fn a_strip_matched_credential_shaped_name_produces_no_grant() {
8228        assert!(secret_shaped_env_grants(
8229            &env_allow_policy(&["MY_SERVICE_SECRET"], &["*_SERVICE_SECRET"]),
8230            &LifecycleConfig::default(),
8231        )
8232        .is_empty());
8233    }
8234
8235    /// Names only the segment rule of `is_credential_shaped_env_name` catches still reach every
8236    /// guest, so each is reported.
8237    #[test]
8238    fn segment_matched_credential_names_each_produce_a_grant() {
8239        let grants = secret_shaped_env_grants(
8240            &env_allow_policy(
8241                &["PRIVATE_KEY", "SSH_KEY", "CREDENTIALS", "SENTRY_DSN"],
8242                &[],
8243            ),
8244            &LifecycleConfig::default(),
8245        );
8246
8247        assert_eq!(
8248            grants
8249                .iter()
8250                .map(|grant| grant.name.as_str())
8251                .collect::<Vec<_>>(),
8252            vec!["PRIVATE_KEY", "SSH_KEY", "CREDENTIALS", "SENTRY_DSN"]
8253        );
8254    }
8255
8256    /// `after_task: sleep` is not itself a trigger: with no credential-shaped name declared there
8257    /// is nothing to hold, and a name that is not credential-shaped is not judged.
8258    #[test]
8259    fn sleep_without_a_credential_shaped_name_decides_nothing() {
8260        assert!(secret_shaped_env_grants(
8261            &env_allow_policy(&["HOME", "TZ", "LANG", "BUILD_NUMBER"], &[]),
8262            &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep),
8263        )
8264        .is_empty());
8265        assert!(secret_shaped_env_grants(
8266            &CapabilityPolicy::default(),
8267            &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep),
8268        )
8269        .is_empty());
8270    }
8271
8272    /// One judgment per distinct name, in declaration order, with non-credential names and names
8273    /// the backstop drops passed over rather than counted.
8274    #[test]
8275    fn every_distinct_credential_shaped_name_is_judged_once_in_declaration_order() {
8276        let grants = secret_shaped_env_grants(
8277            &env_allow_policy(
8278                &[
8279                    "DATABASE_PASSWORD",
8280                    "HOME",
8281                    "GITHUB_TOKEN",
8282                    "SIGNING_KEY",
8283                    "DATABASE_PASSWORD",
8284                ],
8285                &[],
8286            ),
8287            &LifecycleConfig::default(),
8288        );
8289
8290        assert_eq!(
8291            grants
8292                .iter()
8293                .map(|grant| grant.name.as_str())
8294                .collect::<Vec<_>>(),
8295            vec!["DATABASE_PASSWORD", "SIGNING_KEY"]
8296        );
8297    }
8298
8299    // ── E-CAP-016: env.allow entries the credential backstop strips ─────────
8300
8301    #[test]
8302    fn stripped_env_allow_entries_are_distinct_ordered_and_attributed() {
8303        let entries = super::stripped_env_allow_entries(&env_allow_policy(
8304            &[
8305                "NPM_TOKEN",
8306                "TZ",
8307                "MY_SERVICE_SECRET",
8308                "NPM_TOKEN",
8309                "AWS_REGION",
8310            ],
8311            &["*_SERVICE_SECRET"],
8312        ));
8313
8314        assert_eq!(
8315            entries,
8316            vec![
8317                super::StrippedEnvAllowEntry {
8318                    name: "NPM_TOKEN".to_string(),
8319                    pattern: "NPM_TOKEN".to_string(),
8320                    source: crate::shell::BackstopPatternSource::Builtin,
8321                },
8322                super::StrippedEnvAllowEntry {
8323                    name: "MY_SERVICE_SECRET".to_string(),
8324                    pattern: "*_SERVICE_SECRET".to_string(),
8325                    source: crate::shell::BackstopPatternSource::StripEnv,
8326                },
8327                super::StrippedEnvAllowEntry {
8328                    name: "AWS_REGION".to_string(),
8329                    pattern: "AWS_*".to_string(),
8330                    source: crate::shell::BackstopPatternSource::Builtin,
8331                },
8332            ]
8333        );
8334    }
8335
8336    #[test]
8337    fn env_allow_names_the_backstop_keeps_pass_the_check() {
8338        assert!(super::check_env_allow_reaches_guests(&env_allow_policy(
8339            &["TZ", "PRIVATE_KEY", "DATABASE_PASSWORD"],
8340            &[],
8341        ))
8342        .is_ok());
8343        assert!(super::check_env_allow_reaches_guests(&CapabilityPolicy::default()).is_ok());
8344    }
8345
8346    /// The message names every entry with its pattern and that pattern's source.
8347    #[test]
8348    fn the_refusal_names_each_entry_its_pattern_and_the_patterns_source() {
8349        let error = super::check_env_allow_reaches_guests(&env_allow_policy(
8350            &["GITHUB_TOKEN", "MY_SERVICE_SECRET"],
8351            &["*_SERVICE_SECRET"],
8352        ))
8353        .unwrap_err();
8354        let message = error.to_string();
8355
8356        assert!(message.contains("capabilities.env.allow"), "{message}");
8357        assert!(
8358            message.contains(
8359                "'GITHUB_TOKEN' (credential backstop pattern 'GITHUB_TOKEN'), 'MY_SERVICE_SECRET' \
8360                 (capabilities.shell.strip_env pattern '*_SERVICE_SECRET')"
8361            ),
8362            "{message}"
8363        );
8364    }
8365
8366    /// An `InferenceConfig` with every system-prompt field empty, for the prompt-resolution
8367    /// tests below to fill in one at a time.
8368    fn inference_without_prompt() -> murmur_artifact::InferenceConfig {
8369        murmur_artifact::InferenceConfig {
8370            transport: "http".to_string(),
8371            endpoint: Some("http://127.0.0.1:1".to_string()),
8372            model: "test-model".to_string(),
8373            api_key: None,
8374            driver: None,
8375            command: None,
8376            compaction: None,
8377            system_prompt: None,
8378            system_prompt_file: None,
8379            system_prompt_artifact: None,
8380            max_turns: 10,
8381            max_tokens: None,
8382            max_session_tokens: None,
8383        }
8384    }
8385
8386    #[test]
8387    fn resolve_system_prompt_returns_none_when_nothing_is_declared() {
8388        let tmp = TempDir::new().unwrap();
8389        let resolved =
8390            resolve_system_prompt(tmp.path(), tmp.path(), &inference_without_prompt()).unwrap();
8391        assert_eq!(resolved, None);
8392    }
8393
8394    #[test]
8395    fn resolve_system_prompt_returns_the_inline_prompt_verbatim() {
8396        let tmp = TempDir::new().unwrap();
8397        let inference = murmur_artifact::InferenceConfig {
8398            system_prompt: Some("Be terse.".to_string()),
8399            ..inference_without_prompt()
8400        };
8401        let resolved = resolve_system_prompt(tmp.path(), tmp.path(), &inference).unwrap();
8402        assert_eq!(resolved.as_deref(), Some("Be terse."));
8403    }
8404
8405    /// File contents are used exactly as they sit on disk — trailing newline included. The
8406    /// trimming that applies to the inline form happens at manifest parse time and has no
8407    /// counterpart here.
8408    #[test]
8409    fn resolve_system_prompt_reads_the_prompt_file_verbatim_relative_to_the_manifest_dir() {
8410        let manifest_dir = TempDir::new().unwrap();
8411        let workdir = TempDir::new().unwrap();
8412        fs::write(manifest_dir.path().join("conventions.md"), "  Be terse.\n").unwrap();
8413
8414        let inference = murmur_artifact::InferenceConfig {
8415            system_prompt_file: Some("conventions.md".to_string()),
8416            ..inference_without_prompt()
8417        };
8418        let resolved =
8419            resolve_system_prompt(manifest_dir.path(), workdir.path(), &inference).unwrap();
8420        assert_eq!(resolved.as_deref(), Some("  Be terse.\n"));
8421    }
8422
8423    #[test]
8424    fn resolve_system_prompt_errors_when_the_prompt_file_is_missing() {
8425        let tmp = TempDir::new().unwrap();
8426        let inference = murmur_artifact::InferenceConfig {
8427            system_prompt_file: Some("missing-conventions.md".to_string()),
8428            ..inference_without_prompt()
8429        };
8430        match resolve_system_prompt(tmp.path(), tmp.path(), &inference) {
8431            Err(RuntimeError::SystemPromptFileRead { path, .. }) => {
8432                assert!(path.ends_with("missing-conventions.md"), "got {path}");
8433            }
8434            other => panic!("expected SystemPromptFileRead, got {other:?}"),
8435        }
8436    }
8437
8438    /// The artifact branch reads the staged skill's `skill.md` out of the *workdir*, not the
8439    /// manifest directory — it is a resolved artifact, not a file the author wrote beside
8440    /// murmur.yaml.
8441    #[test]
8442    fn resolve_system_prompt_reads_skill_md_from_the_staged_artifact() {
8443        let manifest_dir = TempDir::new().unwrap();
8444        let workdir = TempDir::new().unwrap();
8445        let skill_dir = workdir.path().join("tools").join("house-style");
8446        fs::create_dir_all(&skill_dir).unwrap();
8447        fs::write(skill_dir.join("skill.md"), "# House style\nBe terse.").unwrap();
8448
8449        let inference = murmur_artifact::InferenceConfig {
8450            system_prompt_artifact: Some("house-style".to_string()),
8451            ..inference_without_prompt()
8452        };
8453        let resolved =
8454            resolve_system_prompt(manifest_dir.path(), workdir.path(), &inference).unwrap();
8455        assert_eq!(resolved.as_deref(), Some("# House style\nBe terse."));
8456    }
8457
8458    #[test]
8459    fn resolve_system_prompt_errors_when_the_prompt_artifact_has_no_skill_md() {
8460        let tmp = TempDir::new().unwrap();
8461        let inference = murmur_artifact::InferenceConfig {
8462            system_prompt_artifact: Some("house-style".to_string()),
8463            ..inference_without_prompt()
8464        };
8465        match resolve_system_prompt(tmp.path(), tmp.path(), &inference) {
8466            Err(RuntimeError::SystemPromptArtifactRead { name, .. }) => {
8467                assert_eq!(name, "house-style");
8468            }
8469            other => panic!("expected SystemPromptArtifactRead, got {other:?}"),
8470        }
8471    }
8472
8473    /// `mur run --system-prompt` overrides by clearing the other two fields and setting the
8474    /// inline one, so resolution never reaches a declaration the operator replaced — including
8475    /// a `system_prompt_file` pointing at a file that does not exist.
8476    #[test]
8477    fn resolve_system_prompt_ignores_cleared_declarations() {
8478        let tmp = TempDir::new().unwrap();
8479        let inference = murmur_artifact::InferenceConfig {
8480            system_prompt: Some("CLI prompt".to_string()),
8481            system_prompt_file: None,
8482            system_prompt_artifact: None,
8483            ..inference_without_prompt()
8484        };
8485        let resolved = resolve_system_prompt(tmp.path(), tmp.path(), &inference).unwrap();
8486        assert_eq!(resolved.as_deref(), Some("CLI prompt"));
8487    }
8488
8489    #[test]
8490    fn bash_and_network_together_trigger_warning() {
8491        let tmp = TempDir::new().unwrap();
8492        let policy = CapabilityPolicy {
8493            shell_allow: vec!["bash".to_string()],
8494            network_allow: vec!["https://api.example.com".to_string()],
8495            ..Default::default()
8496        };
8497        warn_if_bash_network_bypass(tmp.path(), &policy);
8498        let log = bootstrap_log_contents(tmp.path());
8499        assert!(log.contains("bash"), "log should mention bash: {log}");
8500        assert!(log.contains("network"), "log should mention network: {log}");
8501        assert!(
8502            log.contains(W_SEC_003),
8503            "log should carry its warning code: {log}"
8504        );
8505        assert!(
8506            log.contains(&security_warning_link(W_SEC_003)),
8507            "log should link to the diagnostics doc page: {log}"
8508        );
8509    }
8510
8511    fn compaction_config(
8512        system_prompt: Option<&str>,
8513        system_prompt_file: Option<&str>,
8514    ) -> murmur_artifact::CompactionConfig {
8515        murmur_artifact::CompactionConfig {
8516            threshold: None,
8517            model: Some("compaction-model".to_string()),
8518            system_prompt: system_prompt.map(str::to_string),
8519            system_prompt_file: system_prompt_file.map(str::to_string),
8520            dump_summaries: None,
8521        }
8522    }
8523
8524    /// `system_prompt_file` is read relative to the manifest directory — not the process
8525    /// cwd — and its contents reach the hook verbatim, newlines and all.
8526    #[test]
8527    fn compaction_system_prompt_file_resolves_relative_to_manifest_dir() {
8528        let manifest_dir = TempDir::new().unwrap();
8529        let body = "Summarize aggressively.\nKeep file paths.\n";
8530        fs::write(manifest_dir.path().join("compaction-instructions.md"), body).unwrap();
8531
8532        let resolved = resolve_compaction_system_prompt(
8533            manifest_dir.path(),
8534            Some(&compaction_config(None, Some("compaction-instructions.md"))),
8535        )
8536        .expect("file resolves");
8537
8538        assert_eq!(resolved, Some(body.to_string()));
8539    }
8540
8541    /// The inline field keeps its pre-existing behavior: returned as-is, no file touched.
8542    #[test]
8543    fn compaction_inline_system_prompt_resolves_without_reading_a_file() {
8544        let manifest_dir = TempDir::new().unwrap();
8545
8546        let resolved = resolve_compaction_system_prompt(
8547            manifest_dir.path(),
8548            Some(&compaction_config(Some("inline prompt"), None)),
8549        )
8550        .expect("inline prompt resolves");
8551
8552        assert_eq!(resolved, Some("inline prompt".to_string()));
8553    }
8554
8555    /// Neither prompt source set — and no `compaction:` block at all — both stay `None`;
8556    /// nothing on this path substitutes a default prompt.
8557    #[test]
8558    fn compaction_system_prompt_absent_resolves_to_none() {
8559        let manifest_dir = TempDir::new().unwrap();
8560
8561        assert_eq!(
8562            resolve_compaction_system_prompt(
8563                manifest_dir.path(),
8564                Some(&compaction_config(None, None))
8565            )
8566            .unwrap(),
8567            None
8568        );
8569        assert_eq!(
8570            resolve_compaction_system_prompt(manifest_dir.path(), None).unwrap(),
8571            None
8572        );
8573    }
8574
8575    /// A missing file fails with the compaction-specific variant — distinguishable by
8576    /// variant, not just message text, from the primary prompt's `SystemPromptFileRead` —
8577    /// and names the resolved path.
8578    #[test]
8579    fn compaction_system_prompt_file_missing_reports_compaction_variant() {
8580        let manifest_dir = TempDir::new().unwrap();
8581
8582        let err = resolve_compaction_system_prompt(
8583            manifest_dir.path(),
8584            Some(&compaction_config(None, Some("nope.md"))),
8585        )
8586        .expect_err("missing file must fail");
8587
8588        match &err {
8589            RuntimeError::CompactionSystemPromptFileRead { path, .. } => {
8590                assert!(
8591                    path.ends_with("nope.md"),
8592                    "error should name the resolved path, got {path}"
8593                );
8594                assert!(
8595                    path.starts_with(&manifest_dir.path().display().to_string()),
8596                    "path should be manifest-dir relative, got {path}"
8597                );
8598            }
8599            other => panic!("expected CompactionSystemPromptFileRead, got {other:?}"),
8600        }
8601        assert!(err
8602            .to_string()
8603            .contains("inference.compaction.system_prompt_file"));
8604    }
8605
8606    #[test]
8607    fn network_without_bash_does_not_warn() {
8608        let tmp = TempDir::new().unwrap();
8609        let policy = CapabilityPolicy {
8610            shell_allow: vec!["cargo".to_string(), "git".to_string()],
8611            network_allow: vec!["https://api.example.com".to_string()],
8612            ..Default::default()
8613        };
8614        warn_if_bash_network_bypass(tmp.path(), &policy);
8615        assert!(bootstrap_log_contents(tmp.path()).is_empty());
8616    }
8617
8618    #[test]
8619    fn bash_without_network_does_not_warn() {
8620        let tmp = TempDir::new().unwrap();
8621        let policy = CapabilityPolicy {
8622            shell_allow: vec!["bash".to_string()],
8623            network_allow: Vec::new(),
8624            ..Default::default()
8625        };
8626        warn_if_bash_network_bypass(tmp.path(), &policy);
8627        assert!(bootstrap_log_contents(tmp.path()).is_empty());
8628    }
8629
8630    #[test]
8631    fn neither_declared_does_not_warn() {
8632        let tmp = TempDir::new().unwrap();
8633        warn_if_bash_network_bypass(tmp.path(), &CapabilityPolicy::default());
8634        assert!(bootstrap_log_contents(tmp.path()).is_empty());
8635    }
8636
8637    #[test]
8638    fn non_bash_shell_interpreter_does_not_warn() {
8639        let tmp = TempDir::new().unwrap();
8640        let policy = CapabilityPolicy {
8641            shell_allow: vec!["sh".to_string()],
8642            network_allow: vec!["https://api.example.com".to_string()],
8643            ..Default::default()
8644        };
8645        warn_if_bash_network_bypass(tmp.path(), &policy);
8646        assert!(
8647            bootstrap_log_contents(tmp.path()).is_empty(),
8648            "exact match on \"bash\" literal must not fire for other shell interpreters like sh"
8649        );
8650    }
8651
8652    #[test]
8653    fn allowlist_blocks_unlisted_before_dispatch() {
8654        let allowlist = HashSet::from(["echo-tool".to_string()]);
8655        let mut dispatched = false;
8656
8657        let result = enforce_allowlist(&allowlist, "missing-tool", || {
8658            dispatched = true;
8659            Ok::<_, String>(())
8660        });
8661
8662        assert!(!dispatched);
8663        assert!(result.is_err());
8664        assert!(result
8665            .unwrap_err()
8666            .contains("not declared in manifest allowlist"));
8667    }
8668
8669    #[test]
8670    fn allowlist_dispatches_listed_tool() {
8671        let allowlist = HashSet::from(["echo-tool".to_string()]);
8672        let result = enforce_allowlist(&allowlist, "echo-tool", || Ok::<_, String>("ok")).unwrap();
8673        assert_eq!(result, "ok");
8674    }
8675
8676    /// Builds a component that exports a single empty instance under `iface`.
8677    /// `resolve_versioned_iface` only looks up the export index by instance
8678    /// name — it never inspects the instance's contents — so an empty instance
8679    /// is sufficient to exercise its versioned-name probe.
8680    fn iface_double(engine: &wasmtime::Engine, iface: &str) -> wasmtime::component::Component {
8681        let wat = format!(
8682            "(component\n\
8683             (instance $i)\n\
8684             (export \"{iface}\" (instance $i))\n\
8685             )"
8686        );
8687        let bytes = wat::parse_str(&wat).expect("component WAT parses");
8688        wasmtime::component::Component::new(engine, bytes).expect("component compiles")
8689    }
8690
8691    fn iface_test_engine() -> wasmtime::Engine {
8692        let mut config = wasmtime::Config::new();
8693        config.wasm_component_model(true);
8694        wasmtime::Engine::new(&config).expect("engine builds")
8695    }
8696
8697    fn instantiate_iface_double(
8698        engine: &wasmtime::Engine,
8699        iface: &str,
8700    ) -> (wasmtime::component::Instance, Store<()>) {
8701        let component = iface_double(engine, iface);
8702        let linker = wasmtime::component::Linker::new(engine);
8703        let mut store = Store::new(engine, ());
8704        let instance = linker
8705            .instantiate(&mut store, &component)
8706            .expect("component with no imports instantiates");
8707        (instance, store)
8708    }
8709
8710    /// A component exporting the versioned instance name (the shape a guest
8711    /// built against the semver'd WIT carries) resolves via the versioned probe.
8712    #[test]
8713    fn resolve_versioned_iface_finds_versioned_name() {
8714        let engine = iface_test_engine();
8715        let (instance, mut store) = instantiate_iface_double(&engine, WIT_CAPSULE_IFACE_VERSIONED);
8716        let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
8717        assert!(
8718            found.is_some(),
8719            "a component exporting the versioned name must resolve"
8720        );
8721    }
8722
8723    /// A component exporting only the legacy unversioned instance name no longer
8724    /// resolves — the fallback probe was removed, so the versioned-only lookup
8725    /// returns `None` and the call site surfaces a hard missing-export error.
8726    #[test]
8727    fn resolve_versioned_iface_rejects_unversioned_only_name() {
8728        let engine = iface_test_engine();
8729        let (instance, mut store) = instantiate_iface_double(&engine, "murmur:tool/run");
8730        let found = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED);
8731        assert!(
8732            found.is_none(),
8733            "a component exporting only the legacy unversioned name must no longer resolve"
8734        );
8735    }
8736
8737    /// A component exporting neither the versioned name nor any recognizable
8738    /// name resolves to `None` — the probe must not silently swallow a genuinely
8739    /// absent interface.
8740    #[test]
8741    fn resolve_versioned_iface_returns_none_when_neither_name_matches() {
8742        let engine = iface_test_engine();
8743        let (instance, mut store) = instantiate_iface_double(&engine, "murmur:capsule/nonexistent");
8744        let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
8745        assert!(
8746            found.is_none(),
8747            "a component exporting neither probed name must not resolve"
8748        );
8749    }
8750
8751    #[test]
8752    fn generate_session_id_is_unique() {
8753        let first = generate_session_id();
8754        let second = generate_session_id();
8755
8756        assert_ne!(first, second);
8757        assert!(first.starts_with("ses_"), "session id must start with ses_");
8758        assert_eq!(
8759            first.len(),
8760            36,
8761            "session id must be 36 chars (ses_ + 32 hex)"
8762        );
8763        uuid::Uuid::parse_str(&first[4..]).expect("session id suffix should be a valid UUID");
8764    }
8765
8766    #[test]
8767    fn sha_verification_happens_before_component_compile() {
8768        struct FakeRegistry;
8769
8770        impl Registry for FakeRegistry {
8771            fn resolve(
8772                &self,
8773                name: &str,
8774                version: &str,
8775            ) -> Result<ResolvedArtifact, RegistryError> {
8776                Ok(ResolvedArtifact {
8777                    meta: ArtifactMeta {
8778                        name: name.to_string(),
8779                        version: version.to_string(),
8780                        runtime: RuntimeType::Wasm,
8781                        artifact_runtime: "wasm".to_string(),
8782                        platforms: Vec::new(),
8783                        description: None,
8784                        tags: Vec::new(),
8785                        wit_contracts: None,
8786                    },
8787                    bytes: b"not-a-real-zip".to_vec().into(),
8788                    sha256: "definitely-wrong".to_string(),
8789                    platform_match: murmur_artifact::PlatformMatch::NotApplicable,
8790                })
8791            }
8792
8793            fn publish(
8794                &self,
8795                _meta: ArtifactMeta,
8796                _bytes: &[u8],
8797            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
8798                unreachable!()
8799            }
8800
8801            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
8802                unreachable!()
8803            }
8804        }
8805
8806        let tempdir = tempfile::tempdir().unwrap();
8807        let request = StageRequest {
8808            credentials_file: None,
8809            manifest_dir: tempdir.path().to_path_buf(),
8810            capsule_name: "test".to_string(),
8811            capsule_version: String::new(),
8812            capsule_component_bytes: b"not-a-component".to_vec(),
8813            artifacts: vec![crate::types::ArtifactRequest {
8814                name: "echo-tool".to_string(),
8815                version: "0.0.1".to_string(),
8816                runtime: ArtifactRuntime::Tool,
8817                source: None,
8818                on_overflow: Default::default(),
8819                capabilities: None,
8820                config: None,
8821            }],
8822            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
8823            lock_expectations: None,
8824            capability_policy: CapabilityPolicy::default(),
8825            inference: None,
8826            system_prompt_overridden: false,
8827            context: None,
8828            context_id: None,
8829            resume: None,
8830            otel_endpoint: None,
8831            eval_config_json: None,
8832            case_id: None,
8833            dataset_id: None,
8834            lifecycle: None,
8835            lifecycle_override: None,
8836            trace: None,
8837            workdir: None,
8838            bind_addr: "127.0.0.1".to_string(),
8839            internal_port: None,
8840            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
8841            exports: None,
8842            spawn_grant: None,
8843            machine_tokens_per_day: None,
8844        };
8845
8846        let err = match stage_session(Arc::new(FakeRegistry), request) {
8847            Ok(_) => panic!("expected stage_session to fail"),
8848            Err(err) => err,
8849        };
8850        assert!(matches!(
8851            err,
8852            RuntimeError::ArtifactIntegrityFailed { name, version }
8853                if name == "echo-tool" && version == "0.0.1"
8854        ));
8855    }
8856
8857    #[test]
8858    fn lock_expectation_mismatch_returns_integrity_failure() {
8859        struct FakeRegistry;
8860
8861        impl Registry for FakeRegistry {
8862            fn resolve(
8863                &self,
8864                name: &str,
8865                version: &str,
8866            ) -> Result<ResolvedArtifact, RegistryError> {
8867                Ok(ResolvedArtifact {
8868                    meta: ArtifactMeta {
8869                        name: name.to_string(),
8870                        version: version.to_string(),
8871                        runtime: RuntimeType::Wasm,
8872                        artifact_runtime: "wasm".to_string(),
8873                        platforms: Vec::new(),
8874                        description: None,
8875                        tags: Vec::new(),
8876                        wit_contracts: None,
8877                    },
8878                    bytes: b"not-a-real-zip".to_vec().into(),
8879                    sha256: murmur_artifact::sha256_hex(b"not-a-real-zip"),
8880                    platform_match: murmur_artifact::PlatformMatch::NotApplicable,
8881                })
8882            }
8883
8884            fn publish(
8885                &self,
8886                _meta: ArtifactMeta,
8887                _bytes: &[u8],
8888            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
8889                unreachable!()
8890            }
8891
8892            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
8893                unreachable!()
8894            }
8895        }
8896
8897        let tempdir = tempfile::tempdir().unwrap();
8898        let request = StageRequest {
8899            credentials_file: None,
8900            manifest_dir: tempdir.path().to_path_buf(),
8901            capsule_name: "test".to_string(),
8902            capsule_version: String::new(),
8903            capsule_component_bytes: Vec::new(),
8904            artifacts: vec![crate::types::ArtifactRequest {
8905                name: "echo-tool".to_string(),
8906                version: "0.0.1".to_string(),
8907                runtime: ArtifactRuntime::Tool,
8908                source: None,
8909                on_overflow: Default::default(),
8910                capabilities: None,
8911                config: None,
8912            }],
8913            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
8914            lock_expectations: Some(vec![crate::types::LockExpectation {
8915                name: "echo-tool".to_string(),
8916                resolved_version: "0.0.1".to_string(),
8917                sha256: "different".to_string(),
8918            }]),
8919            capability_policy: CapabilityPolicy::default(),
8920            inference: None,
8921            system_prompt_overridden: false,
8922            context: None,
8923            context_id: None,
8924            resume: None,
8925            otel_endpoint: None,
8926            eval_config_json: None,
8927            case_id: None,
8928            dataset_id: None,
8929            lifecycle: None,
8930            lifecycle_override: None,
8931            trace: None,
8932            workdir: None,
8933            bind_addr: "127.0.0.1".to_string(),
8934            internal_port: None,
8935            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
8936            exports: None,
8937            spawn_grant: None,
8938            machine_tokens_per_day: None,
8939        };
8940
8941        let err = match stage_session(Arc::new(FakeRegistry), request) {
8942            Ok(_) => panic!("expected stage_session to fail"),
8943            Err(err) => err,
8944        };
8945        assert!(matches!(
8946            err,
8947            RuntimeError::ArtifactIntegrityFailed { name, version }
8948                if name == "echo-tool" && version == "0.0.1"
8949        ));
8950    }
8951
8952    #[test]
8953    fn lock_expectations_require_entry_for_each_manifest_artifact() {
8954        struct FakeRegistry;
8955
8956        impl Registry for FakeRegistry {
8957            fn resolve(
8958                &self,
8959                _name: &str,
8960                _version: &str,
8961            ) -> Result<ResolvedArtifact, RegistryError> {
8962                unreachable!("lock validation should fail before any registry resolve call")
8963            }
8964
8965            fn publish(
8966                &self,
8967                _meta: ArtifactMeta,
8968                _bytes: &[u8],
8969            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
8970                unreachable!()
8971            }
8972
8973            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
8974                unreachable!()
8975            }
8976        }
8977
8978        let tempdir = tempfile::tempdir().unwrap();
8979        let request = StageRequest {
8980            credentials_file: None,
8981            manifest_dir: tempdir.path().to_path_buf(),
8982            capsule_name: "test".to_string(),
8983            capsule_version: String::new(),
8984            capsule_component_bytes: Vec::new(),
8985            artifacts: vec![crate::types::ArtifactRequest {
8986                name: "echo-tool".to_string(),
8987                version: "0.0.1".to_string(),
8988                runtime: ArtifactRuntime::Tool,
8989                source: None,
8990                on_overflow: Default::default(),
8991                capabilities: None,
8992                config: None,
8993            }],
8994            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
8995            lock_expectations: Some(vec![crate::types::LockExpectation {
8996                name: "different-tool".to_string(),
8997                resolved_version: "0.0.1".to_string(),
8998                sha256: "abc".to_string(),
8999            }]),
9000            capability_policy: CapabilityPolicy::default(),
9001            inference: None,
9002            system_prompt_overridden: false,
9003            context: None,
9004            context_id: None,
9005            resume: None,
9006            otel_endpoint: None,
9007            eval_config_json: None,
9008            case_id: None,
9009            dataset_id: None,
9010            lifecycle: None,
9011            lifecycle_override: None,
9012            trace: None,
9013            workdir: None,
9014            bind_addr: "127.0.0.1".to_string(),
9015            internal_port: None,
9016            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
9017            exports: None,
9018            spawn_grant: None,
9019            machine_tokens_per_day: None,
9020        };
9021
9022        let err = match stage_session(Arc::new(FakeRegistry), request) {
9023            Ok(_) => panic!("expected stage_session to fail"),
9024            Err(err) => err,
9025        };
9026        assert!(matches!(
9027            err,
9028            RuntimeError::LockMissingEntry { name } if name == "echo-tool"
9029        ));
9030    }
9031
9032    #[test]
9033    fn lock_expectation_version_mismatch_is_reported() {
9034        struct FakeRegistry;
9035
9036        impl Registry for FakeRegistry {
9037            fn resolve(
9038                &self,
9039                _name: &str,
9040                _version: &str,
9041            ) -> Result<ResolvedArtifact, RegistryError> {
9042                unreachable!("version mismatch should fail before registry resolve")
9043            }
9044
9045            fn publish(
9046                &self,
9047                _meta: ArtifactMeta,
9048                _bytes: &[u8],
9049            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9050                unreachable!()
9051            }
9052
9053            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9054                unreachable!()
9055            }
9056        }
9057
9058        let tempdir = tempfile::tempdir().unwrap();
9059        let request = StageRequest {
9060            credentials_file: None,
9061            manifest_dir: tempdir.path().to_path_buf(),
9062            capsule_name: "test".to_string(),
9063            capsule_version: String::new(),
9064            capsule_component_bytes: Vec::new(),
9065            artifacts: vec![crate::types::ArtifactRequest {
9066                name: "echo-tool".to_string(),
9067                version: "0.0.9".to_string(),
9068                runtime: ArtifactRuntime::Tool,
9069                source: None,
9070                on_overflow: Default::default(),
9071                capabilities: None,
9072                config: None,
9073            }],
9074            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
9075            lock_expectations: Some(vec![crate::types::LockExpectation {
9076                name: "echo-tool".to_string(),
9077                resolved_version: "0.0.1".to_string(),
9078                sha256: "abc".to_string(),
9079            }]),
9080            capability_policy: CapabilityPolicy::default(),
9081            inference: None,
9082            system_prompt_overridden: false,
9083            context: None,
9084            context_id: None,
9085            resume: None,
9086            otel_endpoint: None,
9087            eval_config_json: None,
9088            case_id: None,
9089            dataset_id: None,
9090            lifecycle: None,
9091            lifecycle_override: None,
9092            trace: None,
9093            workdir: None,
9094            bind_addr: "127.0.0.1".to_string(),
9095            internal_port: None,
9096            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
9097            exports: None,
9098            spawn_grant: None,
9099            machine_tokens_per_day: None,
9100        };
9101
9102        let err = match stage_session(Arc::new(FakeRegistry), request) {
9103            Ok(_) => panic!("expected stage_session to fail"),
9104            Err(err) => err,
9105        };
9106        assert!(matches!(
9107            err,
9108            RuntimeError::LockVersionMismatch {
9109                name,
9110                requested,
9111                pinned,
9112            } if name == "echo-tool" && requested == "0.0.9" && pinned == "0.0.1"
9113        ));
9114    }
9115
9116    // ── local-source skill resolution ──────────────────────────────────────
9117
9118    #[test]
9119    fn load_local_skill_md_reads_file_path() {
9120        let dir = tempfile::tempdir().unwrap();
9121        let skill = dir.path().join("skill.md");
9122        fs::write(&skill, b"# hi from file").unwrap();
9123        let bytes = load_local_skill_md(dir.path(), "skill.md").unwrap();
9124        assert_eq!(bytes, b"# hi from file");
9125    }
9126
9127    #[test]
9128    fn load_local_skill_md_finds_skill_md_in_directory_case_insensitive() {
9129        let manifest_dir = tempfile::tempdir().unwrap();
9130        let skill_dir = manifest_dir.path().join("skills").join("my-skill");
9131        fs::create_dir_all(&skill_dir).unwrap();
9132        // Uppercase filename — must still be found.
9133        fs::write(skill_dir.join("SKILL.MD"), b"# upper").unwrap();
9134        let bytes = load_local_skill_md(manifest_dir.path(), "skills/my-skill").unwrap();
9135        assert_eq!(bytes, b"# upper");
9136    }
9137
9138    #[test]
9139    fn load_local_skill_md_missing_path_errors_with_name() {
9140        let dir = tempfile::tempdir().unwrap();
9141        let err = load_local_skill_md(dir.path(), "does/not/exist").unwrap_err();
9142        match err {
9143            RuntimeError::SkillSourceNotFound { path } => {
9144                assert!(path.contains("does/not/exist"), "path was: {path}");
9145            }
9146            other => panic!("expected SkillSourceNotFound, got {other:?}"),
9147        }
9148    }
9149
9150    #[test]
9151    fn load_local_skill_md_directory_without_skill_md_errors() {
9152        let dir = tempfile::tempdir().unwrap();
9153        let empty = dir.path().join("empty");
9154        fs::create_dir_all(&empty).unwrap();
9155        let err = load_local_skill_md(dir.path(), "empty").unwrap_err();
9156        match err {
9157            RuntimeError::SkillSourceMissingSkillMd { path } => {
9158                assert!(path.contains("empty"), "path was: {path}");
9159            }
9160            other => panic!("expected SkillSourceMissingSkillMd, got {other:?}"),
9161        }
9162    }
9163
9164    #[test]
9165    fn load_local_skill_md_absolute_path_ignores_manifest_dir() {
9166        let manifest_dir = tempfile::tempdir().unwrap();
9167        let elsewhere = tempfile::tempdir().unwrap();
9168        let skill = elsewhere.path().join("skill.md");
9169        fs::write(&skill, b"# absolute").unwrap();
9170        let bytes = load_local_skill_md(manifest_dir.path(), &skill.to_string_lossy()).unwrap();
9171        assert_eq!(bytes, b"# absolute");
9172    }
9173
9174    /// A backstop-stripped `env.allow` entry refuses staging before the registry is consulted for
9175    /// the one declared artifact.
9176    #[test]
9177    fn stage_session_refuses_an_env_allow_entry_the_backstop_strips() {
9178        struct PanicRegistry;
9179        impl Registry for PanicRegistry {
9180            fn resolve(&self, _: &str, _: &str) -> Result<ResolvedArtifact, RegistryError> {
9181                panic!("the refusal must precede registry resolution");
9182            }
9183            fn publish(
9184                &self,
9185                _: ArtifactMeta,
9186                _: &[u8],
9187            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9188                panic!("the refusal must precede any registry call");
9189            }
9190            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9191                panic!("the refusal must precede any registry call");
9192            }
9193        }
9194
9195        let project = tempfile::tempdir().unwrap();
9196        let request = StageRequest {
9197            credentials_file: None,
9198            manifest_dir: project.path().to_path_buf(),
9199            capsule_name: "test".to_string(),
9200            capsule_version: "0.0.1".to_string(),
9201            capsule_component_bytes: Vec::new(),
9202            artifacts: vec![crate::types::ArtifactRequest {
9203                name: "some-tool".to_string(),
9204                version: "0.1.0".to_string(),
9205                runtime: ArtifactRuntime::Tool,
9206                source: None,
9207                on_overflow: Default::default(),
9208                capabilities: None,
9209                config: None,
9210            }],
9211            allowlisted_tools: HashSet::new(),
9212            lock_expectations: None,
9213            capability_policy: env_allow_policy(&["GITHUB_TOKEN"], &[]),
9214            inference: None,
9215            system_prompt_overridden: false,
9216            context: None,
9217            context_id: None,
9218            resume: None,
9219            otel_endpoint: None,
9220            eval_config_json: None,
9221            case_id: None,
9222            dataset_id: None,
9223            lifecycle: None,
9224            lifecycle_override: None,
9225            trace: None,
9226            workdir: None,
9227            bind_addr: "127.0.0.1".to_string(),
9228            internal_port: None,
9229            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
9230            exports: None,
9231            spawn_grant: None,
9232            machine_tokens_per_day: None,
9233        };
9234
9235        match stage_session(Arc::new(PanicRegistry), request) {
9236            Err(RuntimeError::EnvAllowStrippedByBackstop { entries }) => assert_eq!(
9237                entries,
9238                vec![super::StrippedEnvAllowEntry {
9239                    name: "GITHUB_TOKEN".to_string(),
9240                    pattern: "GITHUB_TOKEN".to_string(),
9241                    source: crate::shell::BackstopPatternSource::Builtin,
9242                }]
9243            ),
9244            Err(other) => panic!("expected EnvAllowStrippedByBackstop, got {other}"),
9245            Ok(_) => panic!("staging must refuse a backstop-stripped env.allow entry"),
9246        }
9247    }
9248
9249    #[test]
9250    fn stage_session_installs_local_source_skill_without_registry() {
9251        use murmur_artifact::{InferenceConfig, InferenceDriver};
9252
9253        // Registry must never be consulted for a local-source skill.
9254        struct PanicRegistry;
9255        impl Registry for PanicRegistry {
9256            fn resolve(&self, _: &str, _: &str) -> Result<ResolvedArtifact, RegistryError> {
9257                panic!("registry must not be called for a local-source skill");
9258            }
9259            fn publish(
9260                &self,
9261                _: ArtifactMeta,
9262                _: &[u8],
9263            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9264                unreachable!()
9265            }
9266            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9267                unreachable!()
9268            }
9269        }
9270
9271        let project = tempfile::tempdir().unwrap();
9272        let skill_dir = project.path().join("skills").join("my-skill");
9273        fs::create_dir_all(&skill_dir).unwrap();
9274        fs::write(skill_dir.join("skill.md"), b"# my local skill").unwrap();
9275
9276        let inference = InferenceConfig {
9277            transport: "http".into(),
9278            endpoint: Some("http://localhost".into()),
9279            model: "claude-3-haiku".into(),
9280            api_key: None,
9281            driver: Some(InferenceDriver {
9282                artifact: "test-driver".into(),
9283                config: None,
9284            }),
9285            command: None,
9286            compaction: None,
9287            system_prompt: None,
9288            system_prompt_file: None,
9289            system_prompt_artifact: None,
9290            max_turns: 10,
9291            max_tokens: None,
9292            max_session_tokens: None,
9293        };
9294
9295        let request = StageRequest {
9296            credentials_file: None,
9297            manifest_dir: project.path().to_path_buf(),
9298            capsule_name: "test".to_string(),
9299            capsule_version: "0.0.1".to_string(),
9300            capsule_component_bytes: Vec::new(),
9301            artifacts: vec![crate::types::ArtifactRequest {
9302                name: "my-skill".to_string(),
9303                version: "local".to_string(),
9304                runtime: ArtifactRuntime::Skill,
9305                source: Some("skills/my-skill".to_string()),
9306                on_overflow: Default::default(),
9307                capabilities: None,
9308                config: None,
9309            }],
9310            allowlisted_tools: HashSet::new(),
9311            lock_expectations: None,
9312            capability_policy: CapabilityPolicy::default(),
9313            inference: Some(inference),
9314            system_prompt_overridden: false,
9315            context: None,
9316            context_id: None,
9317            resume: None,
9318            otel_endpoint: None,
9319            eval_config_json: None,
9320            case_id: None,
9321            dataset_id: None,
9322            lifecycle: None,
9323            lifecycle_override: None,
9324            trace: None,
9325            workdir: None,
9326            bind_addr: "127.0.0.1".to_string(),
9327            internal_port: None,
9328            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
9329            exports: None,
9330            spawn_grant: None,
9331            machine_tokens_per_day: None,
9332        };
9333
9334        let staged = stage_session(Arc::new(PanicRegistry), request).unwrap();
9335        let installed = staged
9336            .workdir
9337            .join("tools")
9338            .join("my-skill")
9339            .join("skill.md");
9340        assert!(
9341            installed.exists(),
9342            "skill.md not installed at {}",
9343            installed.display()
9344        );
9345        assert_eq!(fs::read(&installed).unwrap(), b"# my local skill");
9346        // No lock artifact recorded for a local-source skill.
9347        assert!(staged.resolved_lock_artifacts.is_empty());
9348        assert_eq!(staged.installed_artifacts.len(), 1);
9349        assert_eq!(staged.installed_artifacts[0].version, "local");
9350        // MURMUR.md (written during staging) lists the skill as callable.
9351        let murmur_md = fs::read_to_string(staged.workdir.join("MURMUR.md")).unwrap();
9352        assert!(
9353            murmur_md.contains("**my-skill**"),
9354            "MURMUR.md missing skill listing:\n{murmur_md}"
9355        );
9356        assert!(
9357            murmur_md.contains("call by name to load guidance"),
9358            "MURMUR.md missing callable skill hint:\n{murmur_md}"
9359        );
9360    }
9361
9362    // ── manage.pull() ──────────────────────────────────────────────────────────
9363
9364    fn zip_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
9365        use std::io::Write as _;
9366        let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
9367        {
9368            let mut zip = zip::ZipWriter::new(&mut cursor);
9369            let options = zip::write::SimpleFileOptions::default();
9370            for (name, bytes) in files {
9371                zip.start_file(*name, options).unwrap();
9372                zip.write_all(bytes).unwrap();
9373            }
9374            zip.finish().unwrap();
9375        }
9376        cursor.into_inner()
9377    }
9378
9379    fn build_test_state(
9380        registry: Arc<dyn Registry>,
9381        workdir: PathBuf,
9382        lock_path: PathBuf,
9383    ) -> CapsuleStoreState {
9384        let engine = build_engine().unwrap();
9385        let wasi = build_wasi_ctx(
9386            &workdir,
9387            None,
9388            None,
9389            None,
9390            &[],
9391            &CapabilityPolicy::default(),
9392        )
9393        .unwrap();
9394        CapsuleStoreState {
9395            limits: crate::limits::ExecutionLimits::default().limiter(),
9396            table: ResourceTable::new(),
9397            wasi,
9398            http: WasiHttpCtx::new(),
9399            http_hooks: NetworkPolicyHooks {
9400                network_allow_rules: Vec::new(),
9401                inference_gateway: None,
9402            },
9403            network_allow_rules: Vec::new(),
9404            peer_fetch_rules: Vec::new(),
9405            peer_plane: None,
9406            peer_own_audience: String::new(),
9407            peer_trace: None,
9408            plan_trace: None,
9409            plan_counter: AtomicU64::new(0),
9410            delegation: None,
9411            inference_env: Vec::new(),
9412            inference_gateway: None,
9413            spend: Arc::new(SpendMeter::unlimited()),
9414            engine,
9415            workdir: workdir.clone(),
9416            accessible_workdir: workdir,
9417            tool_components: HashMap::new(),
9418            artifact_grants: HashMap::new(),
9419            allowlisted_tools: HashSet::new(),
9420            installed_artifacts: Vec::new(),
9421            session_id: "ses_test".to_string(),
9422            pending_a2a_events: Vec::new(),
9423            capability_policy: CapabilityPolicy::default(),
9424            protected_paths: ProtectedPaths::default(),
9425            tool_annotations: ToolAnnotationMap::default(),
9426            shell_enforcement: sandbox::ShellEnforcement::environment_only(),
9427            current_traceparent: None,
9428            current_task_provenance: None,
9429            current_context_id: None,
9430            live_delegations: Arc::new(crate::cancel::LiveDelegations::new()),
9431            detached: None,
9432            shell_grace_secs: 0,
9433            a2a_task_registry: None,
9434            a2a_sse: None,
9435            a2a_task_id: None,
9436            input_timeout_secs: None,
9437            a2a_chunk_event_id: Arc::new(AtomicU64::new(0)),
9438            a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
9439            registry,
9440            lock_path,
9441            driver_continuation_id: None,
9442            driver_continuation_context_id: None,
9443            driver_continuation_acked_len: 0,
9444        }
9445    }
9446
9447    // ── Driver continuation bookkeeping on CapsuleStoreState ─────────────
9448
9449    fn continuation_test_state() -> CapsuleStoreState {
9450        let dir = tempfile::tempdir().unwrap();
9451        let workdir = dir.path().to_path_buf();
9452        let lock_path = workdir.join("murmur.lock");
9453        // Registry/workdir are irrelevant to continuation bookkeeping; reuse the helper.
9454        build_test_state(
9455            Arc::new(FakeSkillRegistry::new(Vec::new())),
9456            workdir,
9457            lock_path,
9458        )
9459    }
9460
9461    #[test]
9462    fn continuation_default_is_none_and_active_query_returns_none() {
9463        let state = continuation_test_state();
9464        assert!(state.driver_continuation_id.is_none());
9465        assert!(state.active_continuation(Some("ctx-a")).is_none());
9466        assert!(state.active_continuation(None).is_none());
9467    }
9468
9469    #[test]
9470    fn continuation_record_then_active_requires_matching_context() {
9471        // Scenario 6: a held continuation is only reused under the same context_id.
9472        let mut state = continuation_test_state();
9473        state.record_continuation("cont-1".into(), Some("ctx-a".into()), 2);
9474
9475        assert_eq!(
9476            state.active_continuation(Some("ctx-a")),
9477            Some(("cont-1", 2)),
9478            "same context → continuation is active"
9479        );
9480        assert!(
9481            state.active_continuation(Some("ctx-b")).is_none(),
9482            "different context → continuation must not be reused"
9483        );
9484        assert!(
9485            state.active_continuation(None).is_none(),
9486            "absent context → continuation must not be reused"
9487        );
9488    }
9489
9490    #[test]
9491    fn continuation_clear_drops_all_bookkeeping() {
9492        // Scenarios 3 & 5: driver silence / replace-context commit drops the held id.
9493        let mut state = continuation_test_state();
9494        state.record_continuation("cont-1".into(), Some("ctx-a".into()), 5);
9495        state.clear_continuation();
9496        assert!(state.driver_continuation_id.is_none());
9497        assert_eq!(state.driver_continuation_acked_len, 0);
9498        assert!(state.active_continuation(Some("ctx-a")).is_none());
9499    }
9500
9501    #[test]
9502    fn advance_acked_len_only_affects_same_context_held_continuation() {
9503        // Scenario 7: end_turn persists an assistant message the driver already knows; the
9504        // acked length advances so the next same-context Task wires only its new user message.
9505        let mut state = continuation_test_state();
9506        state.record_continuation("cont-1".into(), Some("ctx-a".into()), 1);
9507
9508        state.advance_continuation_acked_len(Some("ctx-b"), 9);
9509        assert_eq!(
9510            state.driver_continuation_acked_len, 1,
9511            "advancing under a different context must be a no-op"
9512        );
9513
9514        state.advance_continuation_acked_len(Some("ctx-a"), 2);
9515        assert_eq!(
9516            state.active_continuation(Some("ctx-a")),
9517            Some(("cont-1", 2))
9518        );
9519
9520        state.clear_continuation();
9521        state.advance_continuation_acked_len(Some("ctx-a"), 7);
9522        assert_eq!(
9523            state.driver_continuation_acked_len, 0,
9524            "advancing with no held continuation must be a no-op"
9525        );
9526    }
9527
9528    struct FakeSkillRegistry {
9529        bytes: Vec<u8>,
9530        sha256: String,
9531    }
9532
9533    impl FakeSkillRegistry {
9534        fn new(bytes: Vec<u8>) -> Self {
9535            let sha256 = murmur_artifact::sha256_hex(&bytes);
9536            Self { bytes, sha256 }
9537        }
9538    }
9539
9540    impl Registry for FakeSkillRegistry {
9541        fn resolve(&self, name: &str, version: &str) -> Result<ResolvedArtifact, RegistryError> {
9542            Ok(ResolvedArtifact {
9543                meta: ArtifactMeta {
9544                    name: name.to_string(),
9545                    version: version.to_string(),
9546                    runtime: RuntimeType::Static,
9547                    artifact_runtime: "skill".to_string(),
9548                    platforms: Vec::new(),
9549                    description: None,
9550                    tags: Vec::new(),
9551                    wit_contracts: None,
9552                },
9553                bytes: self.bytes.clone().into(),
9554                sha256: self.sha256.clone(),
9555                platform_match: murmur_artifact::PlatformMatch::NotApplicable,
9556            })
9557        }
9558
9559        fn publish(
9560            &self,
9561            _meta: ArtifactMeta,
9562            _bytes: &[u8],
9563        ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9564            unreachable!()
9565        }
9566
9567        fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9568            unreachable!()
9569        }
9570    }
9571
9572    #[test]
9573    fn pull_happy_path_installs_artifact_and_updates_lock() {
9574        let artifact_bytes = zip_with_files(&[
9575            (
9576                PACKED_MANIFEST_ENTRY,
9577                b"name: my-skill\nversion: 1.0.0\nruntime: skill\n",
9578            ),
9579            ("skill.md", b"# guidance"),
9580        ]);
9581        let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
9582        let expected_sha256 = registry.sha256.clone();
9583
9584        let project = tempfile::tempdir().unwrap();
9585        let workdir = project.path().join("workdir");
9586        fs::create_dir_all(&workdir).unwrap();
9587        let lock_path = project.path().join("murmur.lock");
9588
9589        let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
9590
9591        let summary = manage::Host::pull(&mut state, "my-skill".to_string(), "1.0.0".to_string())
9592            .expect("pull should succeed");
9593        assert_eq!(summary.name, "my-skill");
9594        assert_eq!(summary.version, "1.0.0");
9595
9596        let installed_skill_md = workdir.join("tools").join("my-skill").join("skill.md");
9597        assert!(installed_skill_md.exists());
9598        assert_eq!(fs::read(&installed_skill_md).unwrap(), b"# guidance");
9599
9600        assert!(state
9601            .installed_artifacts
9602            .iter()
9603            .any(|a| a.name == "my-skill" && a.version == "1.0.0"));
9604
9605        let lock = read_lockfile(&lock_path).expect("murmur.lock should have been written");
9606        let entry = lock
9607            .artifact_for("my-skill")
9608            .expect("lock entry for my-skill");
9609        assert_eq!(entry.resolved_version, "1.0.0");
9610        assert_eq!(entry.sha256.any.as_deref().unwrap(), expected_sha256);
9611    }
9612
9613    /// A mid-session `manage.pull()` lands on disk but does not reach the wire: the agent loop
9614    /// holds one tool inventory for the whole session precisely so a pull cannot reorder or grow
9615    /// the tool array that is part of the provider's cached prompt prefix.
9616    #[test]
9617    fn pull_does_not_refresh_the_held_tool_inventory() {
9618        let artifact_bytes = zip_with_files(&[
9619            (
9620                PACKED_MANIFEST_ENTRY,
9621                b"name: aaa-late-skill\nversion: 1.0.0\nruntime: skill\n",
9622            ),
9623            ("skill.md", b"# guidance"),
9624        ]);
9625        let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
9626
9627        let project = tempfile::tempdir().unwrap();
9628        let workdir = project.path().join("workdir");
9629        // One tool already installed at launch. The pulled artifact sorts before it, so a
9630        // refreshed inventory would not merely append — it would shift the whole array.
9631        let existing = workdir.join("tools").join("zzz-existing-tool");
9632        fs::create_dir_all(&existing).unwrap();
9633        fs::write(
9634            existing.join(PACKED_MANIFEST_ENTRY),
9635            "name: zzz-existing-tool\nversion: 1.0.0\nruntime: tool\n",
9636        )
9637        .unwrap();
9638        let lock_path = project.path().join("murmur.lock");
9639
9640        let mut state = build_test_state(registry, workdir.clone(), lock_path);
9641
9642        // What run_agent_loop does once, before the turn loop.
9643        let snapshot = crate::agent::inventory::build_tool_inventory(&workdir, None);
9644        assert_eq!(snapshot.len(), 1);
9645
9646        manage::Host::pull(
9647            &mut state,
9648            "aaa-late-skill".to_string(),
9649            "1.0.0".to_string(),
9650        )
9651        .expect("pull should succeed");
9652
9653        // (a) The held snapshot is untouched by the pull.
9654        assert_eq!(snapshot.len(), 1);
9655        assert_eq!(snapshot[0]["name"], "zzz-existing-tool");
9656
9657        // (b) Every payload built from it still carries exactly that array.
9658        let payload = crate::agent::build_driver_payload(
9659            "m",
9660            8192,
9661            &[serde_json::json!({"role": "user", "content": []})],
9662            &snapshot,
9663            "sys",
9664            None,
9665            Some("cap:1.0.0"),
9666        );
9667        assert_eq!(payload["tools"], serde_json::json!(snapshot));
9668
9669        // (c) The pull really did land: a fresh build sees it, and sorts it first.
9670        let fresh = crate::agent::inventory::build_tool_inventory(&workdir, None);
9671        assert_eq!(fresh.len(), 2);
9672        assert_eq!(fresh[0]["name"], "aaa-late-skill");
9673        assert_eq!(fresh[1]["name"], "zzz-existing-tool");
9674    }
9675
9676    #[test]
9677    fn pull_rejects_tampered_bytes_and_writes_nothing() {
9678        struct TamperedRegistry;
9679        impl Registry for TamperedRegistry {
9680            fn resolve(
9681                &self,
9682                name: &str,
9683                version: &str,
9684            ) -> Result<ResolvedArtifact, RegistryError> {
9685                Ok(ResolvedArtifact {
9686                    meta: ArtifactMeta {
9687                        name: name.to_string(),
9688                        version: version.to_string(),
9689                        runtime: RuntimeType::Static,
9690                        artifact_runtime: "skill".to_string(),
9691                        platforms: Vec::new(),
9692                        description: None,
9693                        tags: Vec::new(),
9694                        wit_contracts: None,
9695                    },
9696                    bytes: b"tampered-bytes".to_vec().into(),
9697                    sha256: "not-the-real-hash".to_string(),
9698                    platform_match: murmur_artifact::PlatformMatch::NotApplicable,
9699                })
9700            }
9701
9702            fn publish(
9703                &self,
9704                _meta: ArtifactMeta,
9705                _bytes: &[u8],
9706            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9707                unreachable!()
9708            }
9709
9710            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9711                unreachable!()
9712            }
9713        }
9714
9715        let project = tempfile::tempdir().unwrap();
9716        let workdir = project.path().join("workdir");
9717        fs::create_dir_all(&workdir).unwrap();
9718        let lock_path = project.path().join("murmur.lock");
9719
9720        let mut state = build_test_state(
9721            Arc::new(TamperedRegistry),
9722            workdir.clone(),
9723            lock_path.clone(),
9724        );
9725
9726        let err = manage::Host::pull(&mut state, "evil-tool".to_string(), "1.0.0".to_string())
9727            .expect_err("tampered bytes must be rejected");
9728        assert!(err.contains("integrity"), "unexpected error message: {err}");
9729
9730        assert!(!workdir.join("tools").join("evil-tool").exists());
9731        assert!(!lock_path.exists());
9732        assert!(state.installed_artifacts.is_empty());
9733    }
9734
9735    #[test]
9736    fn pull_rejects_lock_conflict_and_writes_nothing() {
9737        let artifact_bytes = zip_with_files(&[
9738            (
9739                PACKED_MANIFEST_ENTRY,
9740                b"name: my-skill\nversion: 2.0.0\nruntime: skill\n",
9741            ),
9742            ("skill.md", b"# guidance v2"),
9743        ]);
9744        let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
9745
9746        let project = tempfile::tempdir().unwrap();
9747        let workdir = project.path().join("workdir");
9748        fs::create_dir_all(&workdir).unwrap();
9749        let lock_path = project.path().join("murmur.lock");
9750
9751        // Pin a different version/hash for this artifact ahead of time.
9752        write_lockfile_atomic(
9753            &lock_path,
9754            &MurmurLock {
9755                lock_version: LOCK_VERSION,
9756                artifacts: vec![LockedArtifact {
9757                    name: "my-skill".to_string(),
9758                    resolved_version: "1.0.0".to_string(),
9759                    sha256: LockedSha256::any("pinned-hash-from-earlier-pull".to_string()),
9760                }],
9761            },
9762        )
9763        .unwrap();
9764
9765        let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
9766
9767        let err = manage::Host::pull(&mut state, "my-skill".to_string(), "2.0.0".to_string())
9768            .expect_err("lock conflict must be rejected");
9769        assert!(
9770            err.contains("murmur.lock conflict"),
9771            "unexpected error message: {err}"
9772        );
9773
9774        assert!(!workdir
9775            .join("tools")
9776            .join("my-skill")
9777            .join("skill.md")
9778            .exists());
9779        assert!(state.installed_artifacts.is_empty());
9780
9781        // Lock must be left exactly as it was.
9782        let lock = read_lockfile(&lock_path).unwrap();
9783        let entry = lock.artifact_for("my-skill").unwrap();
9784        assert_eq!(entry.resolved_version, "1.0.0");
9785        assert_eq!(
9786            entry.sha256.any.as_deref().unwrap(),
9787            "pinned-hash-from-earlier-pull"
9788        );
9789    }
9790
9791    /// Writes an executable shell script native-tool fixture that echoes the given env
9792    /// var names (space-separated `NAME=value`, empty string for an unset var) into the
9793    /// `data` field of a passing ToolResult JSON payload on stdout.
9794    fn write_env_echo_native_tool(dir: &Path, name: &str, env_vars: &[&str]) -> std::path::PathBuf {
9795        use std::os::unix::fs::PermissionsExt;
9796
9797        let script_path = dir.join(name);
9798        let echoes: String = env_vars
9799            .iter()
9800            .map(|var| format!(r#"echo -n "{var}=${var} ""#))
9801            .collect::<Vec<_>>()
9802            .join("\n");
9803        let script = format!(
9804            "#!/bin/sh\ncat >/dev/null\nprintf '{{\"status\":\"passed\",\"data\":\"'\n{echoes}\nprintf '\"}}'\n"
9805        );
9806        fs::write(&script_path, script).unwrap();
9807        fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
9808        script_path
9809    }
9810
9811    /// A plan `shell` step and the equivalent direct shell tool call are shown the decision
9812    /// point the same way, so a command refused as one is refused as the other.
9813    ///
9814    /// The two resolvers are separate because the two forms are: a tool call carries a `command`
9815    /// string the runtime always hands to the interpreter as `-c`, a plan step carries a whole
9816    /// argv the scheduler execs directly. What must agree is the value the checks read — the
9817    /// binary, the argv, and the `-c` body whose redirections are live.
9818    #[test]
9819    fn a_plan_shell_step_resolves_as_the_equivalent_shell_tool_call_does() {
9820        let tmp = TempDir::new().unwrap();
9821        let policy = CapabilityPolicy {
9822            shell_allow: vec!["bash".to_string()],
9823            ..CapabilityPolicy::default()
9824        };
9825
9826        let direct = resolve_shell_call(
9827            "bash",
9828            &murmur::tool::run::ToolInput {
9829                data: Some(r#"{"command":"printf x > out.txt"}"#.to_string()),
9830                log_path: None,
9831            },
9832            tmp.path(),
9833            &policy,
9834        )
9835        .expect("a declared binary with a usable command resolves");
9836
9837        let step = GatedStepCall::Shell {
9838            binary: "bash".to_string(),
9839            command: "bash -c 'printf x > out.txt'".to_string(),
9840            argv: vec!["-c".to_string(), "printf x > out.txt".to_string()],
9841        }
9842        .resolve(tmp.path());
9843
9844        let ResolvedCall::Shell {
9845            binary,
9846            argv,
9847            script,
9848            ..
9849        } = step
9850        else {
9851            panic!("a shell step resolves to a shell call");
9852        };
9853        assert_eq!(binary, direct.binary);
9854        assert_eq!(argv, direct.argv);
9855        assert_eq!(script, direct.script);
9856    }
9857
9858    /// The wiring the shell-event fix depends on: `dispatch_shell_tool` carries the resolved
9859    /// binary out of `execute_shell` and into `ShellDispatchInfo`, which is the only channel
9860    /// by which `agent.rs` can put it on the hook event and the trace record. `command` keeps
9861    /// its pre-existing meaning — the argument list alone, never the binary name.
9862    #[test]
9863    fn dispatch_shell_tool_reports_the_invoked_binary_separately_from_the_command() {
9864        let tmp = TempDir::new().unwrap();
9865        let policy = CapabilityPolicy {
9866            shell_allow: vec!["bash".to_string()],
9867            ..CapabilityPolicy::default()
9868        };
9869
9870        let outcome = dispatch_shell_tool(
9871            "bash",
9872            murmur::tool::run::ToolInput {
9873                data: Some(r#"{"command":"echo hi"}"#.to_string()),
9874                log_path: None,
9875            },
9876            tmp.path(),
9877            tmp.path(),
9878            &[],
9879            &policy,
9880            &sandbox::ShellEnforcement::environment_only(),
9881            None,
9882        );
9883
9884        let shell = outcome
9885            .shell
9886            .expect("a successful shell call reports itself");
9887        assert!(
9888            Path::new(&shell.binary).is_absolute() && shell.binary.ends_with("bash"),
9889            "binary must be the resolved path of what ran, got {:?}",
9890            shell.binary
9891        );
9892        assert_eq!(
9893            shell.command, "echo hi",
9894            "command must still carry only the argument list"
9895        );
9896        assert_eq!(shell.exit_code, 0);
9897    }
9898
9899    /// The `$ ` line is a command line, so for a non-interpreter it carries the binary the
9900    /// model was told to omit from `command`. The name printed is the declared short name and
9901    /// not `ShellResult::binary`, which is the resolved path.
9902    #[test]
9903    fn shell_tool_result_names_the_binary_a_non_interpreter_call_ran() {
9904        let tmp = TempDir::new().unwrap();
9905        let policy = CapabilityPolicy {
9906            shell_allow: vec!["ls".to_string()],
9907            ..CapabilityPolicy::default()
9908        };
9909
9910        let outcome = dispatch_shell_tool(
9911            "ls",
9912            murmur::tool::run::ToolInput {
9913                data: Some(r#"{"command":"-d ."}"#.to_string()),
9914                log_path: None,
9915            },
9916            tmp.path(),
9917            tmp.path(),
9918            &[],
9919            &policy,
9920            &sandbox::ShellEnforcement::environment_only(),
9921            None,
9922        );
9923
9924        let data = outcome.result.data.expect("a finished call renders text");
9925        let first = data.lines().next().expect("the text opens with the $ line");
9926        assert_eq!(first, "$ ls -d .");
9927        assert!(
9928            !first.starts_with("$ /"),
9929            "the resolved path must not reach the $ line, got {first:?}"
9930        );
9931        let printed_binary = first
9932            .strip_prefix("$ ")
9933            .and_then(|rest| rest.split_whitespace().next())
9934            .expect("the $ line names a binary");
9935        assert!(
9936            !printed_binary.contains('/'),
9937            "the $ line names the declared short name, got {printed_binary:?}"
9938        );
9939    }
9940
9941    /// An interpreter's `command` is a whole shell line already, so it stands alone: `$ bash
9942    /// echo hi` would read as bash invoked with `echo` as its script argument, which is not
9943    /// what ran.
9944    #[test]
9945    fn shell_tool_result_leaves_an_interpreter_command_line_alone() {
9946        let tmp = TempDir::new().unwrap();
9947        let policy = CapabilityPolicy {
9948            shell_allow: vec!["bash".to_string()],
9949            ..CapabilityPolicy::default()
9950        };
9951
9952        let outcome = dispatch_shell_tool(
9953            "bash",
9954            murmur::tool::run::ToolInput {
9955                data: Some(r#"{"command":"echo hi"}"#.to_string()),
9956                log_path: None,
9957            },
9958            tmp.path(),
9959            tmp.path(),
9960            &[],
9961            &policy,
9962            &sandbox::ShellEnforcement::environment_only(),
9963            None,
9964        );
9965
9966        let data = outcome.result.data.expect("a finished call renders text");
9967        let first = data.lines().next().expect("the text opens with the $ line");
9968        assert_eq!(first, "$ echo hi");
9969    }
9970
9971    /// The argv a policy hook decides on and the argv the spawn receives come from one
9972    /// resolution, not two: `resolve_shell_call` is what `dispatch_shell_tool` uses, so a
9973    /// drift between the approved call and the executed one is not expressible.
9974    #[test]
9975    fn resolve_shell_call_and_dispatch_shell_tool_agree_on_argv() {
9976        let tmp = TempDir::new().unwrap();
9977        let policy = CapabilityPolicy {
9978            shell_allow: vec!["bash".to_string()],
9979            ..CapabilityPolicy::default()
9980        };
9981        let input = murmur::tool::run::ToolInput {
9982            data: Some(r#"{"command":"echo one 'two three'"}"#.to_string()),
9983            log_path: None,
9984        };
9985
9986        let resolved =
9987            resolve_shell_call("bash", &input, tmp.path(), &policy).expect("bash resolves");
9988        let outcome = dispatch_shell_tool(
9989            "bash",
9990            input,
9991            tmp.path(),
9992            tmp.path(),
9993            &[],
9994            &policy,
9995            &sandbox::ShellEnforcement::environment_only(),
9996            None,
9997        );
9998
9999        let shell = outcome
10000            .shell
10001            .expect("a successful shell call reports itself");
10002        assert_eq!(resolved.argv, shell.argv);
10003        assert_eq!(resolved.script, shell.script);
10004        assert_eq!(
10005            resolved.argv,
10006            vec!["-c".to_string(), "echo one 'two three'".to_string()],
10007            "an interpreter takes the whole command as one -c body"
10008        );
10009    }
10010
10011    /// A non-interpreter binary is word-split and carries no script.
10012    #[test]
10013    fn resolve_shell_call_splits_a_non_interpreter_and_reports_no_script() {
10014        let tmp = TempDir::new().unwrap();
10015        let policy = CapabilityPolicy {
10016            shell_allow: vec!["curl".to_string()],
10017            ..CapabilityPolicy::default()
10018        };
10019        let resolved = resolve_shell_call(
10020            "curl",
10021            &murmur::tool::run::ToolInput {
10022                data: Some(r#"{"command":"-s http://example.com"}"#.to_string()),
10023                log_path: None,
10024            },
10025            tmp.path(),
10026            &policy,
10027        )
10028        .expect("curl resolves");
10029
10030        assert_eq!(resolved.argv, vec!["-s", "http://example.com"]);
10031        assert_eq!(resolved.script, None);
10032    }
10033
10034    /// Nothing resolves for a name the manifest never declared, and nothing resolves for input
10035    /// carrying no command — the two cases that make the decision point see a tool call rather
10036    /// than a shell call.
10037    #[test]
10038    fn resolve_shell_call_declines_an_undeclared_binary_and_unusable_input() {
10039        let tmp = TempDir::new().unwrap();
10040        let policy = CapabilityPolicy {
10041            shell_allow: vec!["bash".to_string()],
10042            ..CapabilityPolicy::default()
10043        };
10044        assert!(resolve_shell_call(
10045            "curl",
10046            &murmur::tool::run::ToolInput {
10047                data: Some(r#"{"command":"--version"}"#.to_string()),
10048                log_path: None,
10049            },
10050            tmp.path(),
10051            &policy,
10052        )
10053        .is_none());
10054        assert!(resolve_shell_call(
10055            "bash",
10056            &murmur::tool::run::ToolInput {
10057                data: Some(r#"{"nope":1}"#.to_string()),
10058                log_path: None,
10059            },
10060            tmp.path(),
10061            &policy,
10062        )
10063        .is_none());
10064    }
10065
10066    /// One resolution feeds the policy decision and the spawn for a recipe invocation too: the
10067    /// body the hook is shown arrives without moving the argv the executable receives.
10068    #[test]
10069    fn resolve_shell_call_and_dispatch_shell_tool_agree_on_a_recipe_invocation() {
10070        let tmp = TempDir::new().unwrap();
10071        std::fs::write(
10072            tmp.path().join("justfile"),
10073            "build:\n  echo RECIPE-BODY-MARKER\n",
10074        )
10075        .unwrap();
10076        let policy = CapabilityPolicy {
10077            shell_allow: vec!["just".to_string()],
10078            ..CapabilityPolicy::default()
10079        };
10080        let input = murmur::tool::run::ToolInput {
10081            data: Some(r#"{"command":"build"}"#.to_string()),
10082            log_path: None,
10083        };
10084
10085        let resolved =
10086            resolve_shell_call("just", &input, tmp.path(), &policy).expect("just resolves");
10087        let outcome = dispatch_shell_tool(
10088            "just",
10089            input,
10090            tmp.path(),
10091            tmp.path(),
10092            &[],
10093            &policy,
10094            &sandbox::ShellEnforcement::environment_only(),
10095            None,
10096        );
10097
10098        assert_eq!(
10099            resolved.recipe.as_deref(),
10100            Some("echo RECIPE-BODY-MARKER"),
10101            "the recipe body is read out of the workdir"
10102        );
10103        assert_eq!(
10104            resolved.argv,
10105            vec!["build".to_string()],
10106            "resolving a recipe does not move the argv"
10107        );
10108        // `just` need not exist on the host: what the resolution produced is the assertion, and
10109        // a spawn that fails still carries the argv it was given.
10110        if let Some(shell) = outcome.shell {
10111            assert_eq!(resolved.argv, shell.argv);
10112            assert_eq!(resolved.script, shell.script);
10113            assert_eq!(resolved.recipe, shell.recipe);
10114        }
10115    }
10116
10117    /// The dispatch-layer half of the composed-root failure path: a sealed session whose root
10118    /// could not be built does not come back as an ordinary failed tool call the capsule gets
10119    /// another turn to react to. The tool result is still filled in (so the trace records the
10120    /// attempt), but `fatal` carries the typed `RuntimeError` the agent turn loop returns and
10121    /// the CLI renders as `E-RUN-014`.
10122    ///
10123    /// Linux-only because the forced-failure seam lives in the Linux `pre_exec` path; the
10124    /// mechanism it stands in for is Linux-only too.
10125    #[cfg(target_os = "linux")]
10126    #[test]
10127    fn a_sealed_composed_root_failure_ends_the_session_not_just_the_tool_call() {
10128        if crate::network_namespace::skip_without_egress_namespace(
10129            "a_sealed_composed_root_failure_ends_the_session_not_just_the_tool_call",
10130        ) {
10131            return;
10132        }
10133        let tmp = TempDir::new().unwrap();
10134        let policy = CapabilityPolicy {
10135            shell_allow: vec!["bash".to_string()],
10136            ..CapabilityPolicy::default()
10137        };
10138
10139        let _guard = sandbox::ForceSealedRootFailureGuard::new();
10140        let outcome = dispatch_shell_tool(
10141            "bash",
10142            murmur::tool::run::ToolInput {
10143                data: Some(r#"{"command":"echo hi"}"#.to_string()),
10144                log_path: None,
10145            },
10146            tmp.path(),
10147            tmp.path(),
10148            &[],
10149            &policy,
10150            &sandbox::sealed_test_enforcement(),
10151            None,
10152        );
10153
10154        assert!(
10155            matches!(outcome.result.status, murmur::tool::run::Status::Error),
10156            "the failed call must still read as a failed call"
10157        );
10158        let data = outcome.result.data.as_deref().unwrap_or_default();
10159        assert!(
10160            data.contains("composed root"),
10161            "the tool result must still say what happened: {data}"
10162        );
10163        let fatal = outcome
10164            .fatal
10165            .expect("a composed-root failure must be carried out as session-fatal");
10166        assert!(
10167            matches!(fatal, RuntimeError::SealedRootConstructionFailed { .. }),
10168            "must be the variant murmur-cli maps to E-RUN-014: {fatal}"
10169        );
10170    }
10171
10172    /// The contrast: an ordinary shell failure leaves `fatal` unset, so the capsule keeps its
10173    /// turn. Without this, "always fatal" would pass the test above.
10174    #[test]
10175    fn an_ordinary_shell_failure_leaves_the_session_running() {
10176        let tmp = TempDir::new().unwrap();
10177        let outcome = dispatch_shell_tool(
10178            "bash",
10179            murmur::tool::run::ToolInput {
10180                data: Some(r#"{"command":"echo hi"}"#.to_string()),
10181                log_path: None,
10182            },
10183            tmp.path(),
10184            tmp.path(),
10185            &[],
10186            // Empty allowlist: `execute_shell` refuses before spawning anything.
10187            &CapabilityPolicy::default(),
10188            &sandbox::ShellEnforcement::environment_only(),
10189            None,
10190        );
10191
10192        assert!(matches!(
10193            outcome.result.status,
10194            murmur::tool::run::Status::Error
10195        ));
10196        assert!(
10197            outcome.fatal.is_none(),
10198            "a disallowed binary is the capsule's problem, not the session's"
10199        );
10200    }
10201
10202    // ── the tool-result fence ───────────────────────────────────────────────
10203
10204    fn tool_result_with(
10205        data: Option<&str>,
10206        summary: Option<&str>,
10207    ) -> murmur::tool::run::ToolResult {
10208        murmur::tool::run::ToolResult {
10209            status: murmur::tool::run::Status::Passed,
10210            summary: summary.map(str::to_string),
10211            data: data.map(str::to_string),
10212            data_path: None,
10213            truncated: false,
10214            metadata: Vec::new(),
10215        }
10216    }
10217
10218    /// Every shape a dispatch branch can hand back — data, summary only, neither — is fenced
10219    /// once. A second application anywhere would show up here as a second marker pair.
10220    #[test]
10221    fn fence_wraps_each_dispatch_result_shape_exactly_once() {
10222        let shapes = [
10223            tool_result_with(Some("stdout line"), Some("ran ok")),
10224            tool_result_with(Some("stdout line"), None),
10225            tool_result_with(None, Some("ran ok")),
10226            tool_result_with(None, None),
10227        ];
10228        for mut result in shapes {
10229            fence_tool_result("web-fetch", &mut result);
10230            let text = result.data.expect("the fenced text lands in data");
10231            assert_eq!(
10232                text.matches("<untrusted-content source=tool:web-fetch>")
10233                    .count(),
10234                1,
10235                "exactly one opening marker: {text}"
10236            );
10237            assert_eq!(
10238                text.matches(crate::fence::FENCE_CLOSE).count(),
10239                1,
10240                "exactly one closing marker: {text}"
10241            );
10242        }
10243    }
10244
10245    /// The reduction happens inside the dispatcher, so the `data.or(summary)` both model-facing
10246    /// callers write has nothing unfenced left to fall back to.
10247    #[test]
10248    fn fence_moves_a_summary_only_result_into_fenced_data() {
10249        let mut result = tool_result_with(None, Some("ran ok"));
10250        fence_tool_result("probe", &mut result);
10251        assert_eq!(
10252            result.data.as_deref(),
10253            Some("<untrusted-content source=tool:probe>\nran ok\n</untrusted-content>")
10254        );
10255        assert!(
10256            result.summary.is_none(),
10257            "no unfenced text may survive on the outcome"
10258        );
10259    }
10260
10261    /// A dispatch that produced nothing still reaches the model as a fenced block, so "the tool
10262    /// said nothing" is not the one result that reads as the runtime's own voice.
10263    #[test]
10264    fn fence_wraps_the_empty_dispatch_result_too() {
10265        let mut result = tool_result_with(None, None);
10266        fence_tool_result("probe", &mut result);
10267        assert_eq!(
10268            result.data.as_deref(),
10269            Some(
10270                "<untrusted-content source=tool:probe>\ntool returned no data\n</untrusted-content>"
10271            )
10272        );
10273    }
10274
10275    /// The fields the fence does not touch: everything the tool declared *about* the call, as
10276    /// opposed to the content shown to the model.
10277    #[test]
10278    fn fence_leaves_the_declarative_tool_result_fields_alone() {
10279        let mut result = murmur::tool::run::ToolResult {
10280            status: murmur::tool::run::Status::Error,
10281            summary: Some("failed".to_string()),
10282            data: Some("boom".to_string()),
10283            data_path: Some("out/log.txt".to_string()),
10284            truncated: true,
10285            metadata: vec![("state_effect".to_string(), "read".to_string())],
10286        };
10287        fence_tool_result("probe", &mut result);
10288        assert!(matches!(result.status, murmur::tool::run::Status::Error));
10289        assert_eq!(result.data_path.as_deref(), Some("out/log.txt"));
10290        assert!(result.truncated);
10291        assert_eq!(result.metadata.len(), 1);
10292    }
10293
10294    /// The composed path, on real inputs and a real file: dispatch a real shell tool, then
10295    /// hand its `ShellDispatchInfo` to a real `TraceWriter` exactly as `agent.rs` does, and
10296    /// read the resulting `workdir/trace.jsonl` back. This is the automated stand-in for
10297    /// eyeballing a session's trace after `mur run` — the two lines it mirrors in `agent.rs`
10298    /// are a straight field pass-through, but the JSONL key and its value are pinned here.
10299    #[test]
10300    fn shell_dispatch_writes_the_resolved_binary_into_trace_jsonl() {
10301        let tmp = TempDir::new().unwrap();
10302        let workdir = tmp.path().to_path_buf();
10303        let policy = CapabilityPolicy {
10304            shell_allow: vec!["bash".to_string()],
10305            ..CapabilityPolicy::default()
10306        };
10307
10308        let rt = tokio::runtime::Runtime::new().unwrap();
10309        let events: Vec<serde_json::Value> = rt.block_on(async {
10310            let outcome = dispatch_shell_tool(
10311                "bash",
10312                murmur::tool::run::ToolInput {
10313                    data: Some(r#"{"command":"echo hi"}"#.to_string()),
10314                    log_path: None,
10315                },
10316                &workdir,
10317                &workdir,
10318                &[],
10319                &policy,
10320                &sandbox::ShellEnforcement::environment_only(),
10321                None,
10322            );
10323            let shell = outcome.shell.expect("the shell call reports itself");
10324
10325            let mut trace = TraceWriter::open(
10326                &workdir,
10327                "ses_test".to_string(),
10328                "cap".to_string(),
10329                "0.1.0".to_string(),
10330                "test-model".to_string(),
10331                Vec::new(),
10332                crate::containment::scope_report_for_tier(
10333                    &policy,
10334                    murmur_artifact::ContainmentClass::Advisory,
10335                    sandbox::EnforcementTier::EnvironmentOnly,
10336                    None,
10337                    None,
10338                    None,
10339                    Vec::new(),
10340                    Vec::new(),
10341                    Vec::new(),
10342                    crate::cgroup::IoMaxReport::default(),
10343                ),
10344                murmur_artifact::TraceCapture::Meta,
10345                None,
10346                false,
10347                None,
10348                None,
10349                None,
10350                None,
10351            )
10352            .await
10353            .unwrap();
10354            trace
10355                .write_shell(
10356                    1,
10357                    shell.binary.clone(),
10358                    shell.command.clone(),
10359                    shell.exit_code,
10360                    shell.stdout_bytes,
10361                    shell.stderr_bytes,
10362                    shell.duration_ms,
10363                    shell.resource_limit.clone(),
10364                )
10365                .await
10366                .unwrap();
10367            trace.flush().await.unwrap();
10368
10369            fs::read_to_string(workdir.join("trace.jsonl"))
10370                .unwrap()
10371                .lines()
10372                .filter(|l| !l.is_empty())
10373                .map(|l| serde_json::from_str(l).unwrap())
10374                .collect()
10375        });
10376
10377        let shell_event = events
10378            .iter()
10379            .find(|e| e["event_type"] == "shell")
10380            .expect("trace.jsonl must carry a shell event");
10381        let binary = shell_event["binary"]
10382            .as_str()
10383            .expect("the shell event must carry a `binary` string");
10384        assert!(
10385            Path::new(binary).is_absolute() && binary.ends_with("bash"),
10386            "trace.jsonl's binary must be the resolved absolute path of what ran, got {binary:?}"
10387        );
10388        assert_eq!(shell_event["command"], "echo hi");
10389    }
10390
10391    #[test]
10392    fn dispatch_native_tool_gets_synthetic_home_matching_execute_shell() {
10393        let tmp = TempDir::new().unwrap();
10394        let binary = write_env_echo_native_tool(tmp.path(), "echo-home", &["HOME"]);
10395        let policy = CapabilityPolicy::default();
10396
10397        let result = dispatch_native_tool(
10398            "echo-home",
10399            murmur::tool::run::ToolInput {
10400                data: None,
10401                log_path: None,
10402            },
10403            &binary,
10404            tmp.path(),
10405            tmp.path(),
10406            &policy,
10407            &sandbox::ShellEnforcement::environment_only(),
10408        )
10409        .unwrap();
10410
10411        let expected_home = tmp.path().join(".capsule-home");
10412        let data = result.data.unwrap_or_default();
10413        assert!(
10414            data.contains(&format!("HOME={}", expected_home.display())),
10415            "expected synthetic HOME in native tool output, got: {data}"
10416        );
10417        assert!(expected_home.is_dir());
10418    }
10419
10420    #[test]
10421    fn dispatch_native_tool_strips_credential_shaped_var() {
10422        let tmp = TempDir::new().unwrap();
10423        let binary = write_env_echo_native_tool(tmp.path(), "echo-token", &["GITHUB_TOKEN"]);
10424        let policy = CapabilityPolicy::default();
10425
10426        std::env::set_var("GITHUB_TOKEN", "leaked-token");
10427        let result = dispatch_native_tool(
10428            "echo-token",
10429            murmur::tool::run::ToolInput {
10430                data: None,
10431                log_path: None,
10432            },
10433            &binary,
10434            tmp.path(),
10435            tmp.path(),
10436            &policy,
10437            &sandbox::ShellEnforcement::environment_only(),
10438        )
10439        .unwrap();
10440        std::env::remove_var("GITHUB_TOKEN");
10441
10442        let data = result.data.unwrap_or_default();
10443        assert!(
10444            !data.contains("leaked-token"),
10445            "GITHUB_TOKEN must not reach a native tool subprocess, got: {data}"
10446        );
10447    }
10448
10449    #[test]
10450    fn dispatch_native_tool_strips_wildcard_credential_pattern() {
10451        let tmp = TempDir::new().unwrap();
10452        let binary = write_env_echo_native_tool(tmp.path(), "echo-stripe", &["STRIPE_API_KEY"]);
10453        let policy = CapabilityPolicy::default();
10454
10455        std::env::set_var("STRIPE_API_KEY", "leaked-key");
10456        let result = dispatch_native_tool(
10457            "echo-stripe",
10458            murmur::tool::run::ToolInput {
10459                data: None,
10460                log_path: None,
10461            },
10462            &binary,
10463            tmp.path(),
10464            tmp.path(),
10465            &policy,
10466            &sandbox::ShellEnforcement::environment_only(),
10467        )
10468        .unwrap();
10469        std::env::remove_var("STRIPE_API_KEY");
10470
10471        let data = result.data.unwrap_or_default();
10472        assert!(
10473            !data.contains("leaked-key"),
10474            "*_API_KEY wildcard pattern must strip STRIPE_API_KEY from native tool subprocess, got: {data}"
10475        );
10476    }
10477
10478    #[test]
10479    fn dispatch_native_tool_keeps_safe_baseline_var() {
10480        let tmp = TempDir::new().unwrap();
10481        let binary = write_env_echo_native_tool(tmp.path(), "echo-cargo-home", &["CARGO_HOME"]);
10482        let policy = CapabilityPolicy::default();
10483
10484        std::env::set_var("CARGO_HOME", "/fake/cargo/home");
10485        let result = dispatch_native_tool(
10486            "echo-cargo-home",
10487            murmur::tool::run::ToolInput {
10488                data: None,
10489                log_path: None,
10490            },
10491            &binary,
10492            tmp.path(),
10493            tmp.path(),
10494            &policy,
10495            &sandbox::ShellEnforcement::environment_only(),
10496        )
10497        .unwrap();
10498        std::env::remove_var("CARGO_HOME");
10499
10500        let data = result.data.unwrap_or_default();
10501        assert!(
10502            data.contains("CARGO_HOME=/fake/cargo/home"),
10503            "safe baseline var CARGO_HOME must pass through to native tool subprocess, got: {data}"
10504        );
10505    }
10506
10507    #[test]
10508    fn dispatch_native_tool_composes_policy_strip_env() {
10509        let tmp = TempDir::new().unwrap();
10510        let binary =
10511            write_env_echo_native_tool(tmp.path(), "echo-mycompany", &["MYCOMPANY_SECRET"]);
10512        let policy = CapabilityPolicy {
10513            shell_strip_env: vec!["MYCOMPANY_*".to_string()],
10514            ..CapabilityPolicy::default()
10515        };
10516
10517        std::env::set_var("MYCOMPANY_SECRET", "leaked-secret");
10518        let result = dispatch_native_tool(
10519            "echo-mycompany",
10520            murmur::tool::run::ToolInput {
10521                data: None,
10522                log_path: None,
10523            },
10524            &binary,
10525            tmp.path(),
10526            tmp.path(),
10527            &policy,
10528            &sandbox::ShellEnforcement::environment_only(),
10529        )
10530        .unwrap();
10531        std::env::remove_var("MYCOMPANY_SECRET");
10532
10533        let data = result.data.unwrap_or_default();
10534        assert!(
10535            !data.contains("leaked-secret"),
10536            "policy.shell_strip_env pattern must compose for native tool subprocess, got: {data}"
10537        );
10538    }
10539
10540    // ── Per-artifact grant narrowing for tools and drivers ───────────────────────────
10541
10542    /// The capsule ceiling the narrowing tests clamp against. Loopback ports nothing listens
10543    /// on, so a policy decision is observable without any connection ever completing.
10544    fn narrowing_ceiling() -> Vec<NetworkAllowRule> {
10545        parse_network_allow_rules(&[
10546            "http://127.0.0.1:1".to_string(),
10547            "http://127.0.0.1:2".to_string(),
10548        ])
10549        .unwrap()
10550    }
10551
10552    fn grant_of(network: Option<Vec<&str>>, scope: Option<&str>) -> ToolCapabilityGrant {
10553        let caps = murmur_artifact::Capabilities {
10554            peer_fetch: None,
10555            network: network.map(|allow| murmur_artifact::NetworkCapabilities {
10556                allow: allow.into_iter().map(str::to_string).collect(),
10557                unix_sockets: false,
10558            }),
10559            filesystem: scope.map(|scope| murmur_artifact::FilesystemCapabilities {
10560                scope: Some(scope.to_string()),
10561                workdir_exec: false,
10562                read_only: Vec::new(),
10563            }),
10564            shell: None,
10565            spawn: None,
10566            env: None,
10567            limits: None,
10568            resources: None,
10569            state: None,
10570            task_io: None,
10571            conversation: None,
10572            plan: None,
10573            containment: None,
10574        };
10575        ToolCapabilityGrant::derive(Some(&caps), &narrowing_ceiling(), "test-capsule")
10576            .expect("grant is valid")
10577    }
10578
10579    /// Push a request through the very `NetworkPolicyHooks` a tool store is built with, so
10580    /// the assertion is on the real wasi-http gate rather than on the rule list.
10581    fn send_through_tool_hooks(rules: &[NetworkAllowRule], uri: &str, use_tls: bool) -> bool {
10582        use http_body_util::{BodyExt, Empty};
10583
10584        let mut hooks = NetworkPolicyHooks {
10585            network_allow_rules: rules.to_vec(),
10586            inference_gateway: None,
10587        };
10588        let body = Empty::<bytes::Bytes>::new()
10589            .map_err(|err| match err {})
10590            .boxed_unsync();
10591        let request = hyper::Request::builder()
10592            .uri(uri)
10593            .body(body)
10594            .expect("request builds");
10595        let config = wasmtime_wasi_http::p2::types::OutgoingRequestConfig {
10596            use_tls,
10597            connect_timeout: std::time::Duration::from_millis(1),
10598            first_byte_timeout: std::time::Duration::from_millis(1),
10599            between_bytes_timeout: std::time::Duration::from_millis(1),
10600        };
10601
10602        let rt = tokio::runtime::Runtime::new().unwrap();
10603        rt.block_on(async { hooks.send_request(request, config).is_ok() })
10604    }
10605
10606    fn gateway_test_config() -> wasmtime_wasi_http::p2::types::OutgoingRequestConfig {
10607        wasmtime_wasi_http::p2::types::OutgoingRequestConfig {
10608            use_tls: false,
10609            connect_timeout: std::time::Duration::from_millis(1),
10610            first_byte_timeout: std::time::Duration::from_millis(1),
10611            between_bytes_timeout: std::time::Duration::from_millis(1),
10612        }
10613    }
10614
10615    /// A tool store that is not the configured driver's gets no gateway, so its request to the
10616    /// gateway authority is an ordinary allow-list-checked request — denied here — and is never
10617    /// rewritten or given the key.
10618    #[test]
10619    fn gateway_is_driver_only() {
10620        use http_body_util::{BodyExt, Empty};
10621
10622        const KEY: &str = "sk-driver-only-marker";
10623        let gateway = Arc::new(
10624            InferenceGateway::new(
10625                "the-driver",
10626                "http://127.0.0.1:1",
10627                murmur_artifact::InferenceAuth {
10628                    header: "x-api-key".to_string(),
10629                    value: "{key}".to_string(),
10630                },
10631                Some(Arc::new(
10632                    InferenceCredential::resolve(&ApiKeyReference::Literal(KEY.to_string()), None)
10633                        .unwrap(),
10634                )),
10635                Arc::new(SpendMeter::unlimited()),
10636            )
10637            .unwrap(),
10638        );
10639        assert!(gateway_for_store(Some(&gateway), "the-driver").is_some());
10640        assert!(gateway_for_store(Some(&gateway), "other-tool").is_none());
10641
10642        let mut hooks = NetworkPolicyHooks {
10643            network_allow_rules: Vec::new(),
10644            inference_gateway: gateway_for_store(Some(&gateway), "other-tool"),
10645        };
10646        let request = hyper::Request::builder()
10647            .uri("http://127.0.0.1:9/")
10648            .body(
10649                Empty::<bytes::Bytes>::new()
10650                    .map_err(|err| match err {})
10651                    .boxed_unsync(),
10652            )
10653            .unwrap();
10654        let rt = tokio::runtime::Runtime::new().unwrap();
10655        let Err(denied) = rt.block_on(async { hooks.send_request(request, gateway_test_config()) })
10656        else {
10657            panic!("a non-driver store's request to the gateway authority is denied");
10658        };
10659        assert!(matches!(
10660            denied.downcast_ref(),
10661            Some(WasiHttpErrorCode::HttpRequestDenied)
10662        ));
10663        assert!(!format!("{denied:?}").contains(KEY));
10664    }
10665
10666    fn http_inference(endpoint: &str, api_key: Option<&str>) -> InferenceConfig {
10667        InferenceConfig {
10668            transport: "http".to_string(),
10669            endpoint: Some(endpoint.to_string()),
10670            model: "test-model".to_string(),
10671            api_key: api_key.map(|key| ApiKeyReference::Literal(key.to_string())),
10672            driver: Some(murmur_artifact::InferenceDriver {
10673                artifact: "the-driver".to_string(),
10674                config: None,
10675            }),
10676            command: None,
10677            compaction: None,
10678            system_prompt: None,
10679            system_prompt_file: None,
10680            system_prompt_artifact: None,
10681            max_turns: 10,
10682            max_tokens: None,
10683            max_session_tokens: None,
10684        }
10685    }
10686
10687    #[test]
10688    fn inference_env_pairs_never_carries_the_api_key() {
10689        const KEY: &str = "sk-env-pairs-marker";
10690        let inference = http_inference("https://api.moonshot.ai/v1", Some(KEY));
10691        let gateway = InferenceGateway::new(
10692            "the-driver",
10693            "https://api.moonshot.ai/v1",
10694            murmur_artifact::InferenceAuth {
10695                header: "Authorization".to_string(),
10696                value: "Bearer {key}".to_string(),
10697            },
10698            inference
10699                .api_key
10700                .as_ref()
10701                .map(|reference| Arc::new(InferenceCredential::resolve(reference, None).unwrap())),
10702            Arc::new(SpendMeter::unlimited()),
10703        )
10704        .unwrap();
10705        for pairs in [
10706            inference_env_pairs(&inference, Some(&gateway)),
10707            inference_env_pairs(&inference, None),
10708        ] {
10709            assert!(pairs
10710                .iter()
10711                .all(|(name, value)| name != "MURMUR_INFERENCE_API_KEY" && !value.contains(KEY)));
10712        }
10713        let pairs = inference_env_pairs(&inference, Some(&gateway));
10714        assert!(pairs.contains(&(
10715            "MURMUR_INFERENCE_ENDPOINT".to_string(),
10716            "http://127.0.0.1:9/v1".to_string()
10717        )));
10718    }
10719
10720    #[test]
10721    fn inference_endpoint_allow_entries_names_only_entries_matching_the_endpoint() {
10722        let allow = vec![
10723            "api.anthropic.com".to_string(),
10724            "https://api.anthropic.com".to_string(),
10725            "http://api.anthropic.com".to_string(),
10726            "example.com".to_string(),
10727        ];
10728        let inference = http_inference("https://api.anthropic.com", None);
10729        assert_eq!(
10730            inference_endpoint_allow_entries(&allow, Some(&inference)),
10731            vec!["api.anthropic.com", "https://api.anthropic.com"]
10732        );
10733        let process = InferenceConfig {
10734            transport: "process".to_string(),
10735            endpoint: None,
10736            ..inference
10737        };
10738        assert!(inference_endpoint_allow_entries(&allow, Some(&process)).is_empty());
10739    }
10740
10741    /// The no-op invariant, network half: a tool with no per-artifact entry dispatches on the
10742    /// full ceiling, reaching everything the capsule may reach.
10743    #[test]
10744    fn ungranted_tool_keeps_the_whole_ceiling() {
10745        let ceiling = narrowing_ceiling();
10746        let rules = effective_tool_network_rules(None, &ceiling);
10747
10748        assert!(send_through_tool_hooks(
10749            rules,
10750            "http://127.0.0.1:1/x",
10751            false
10752        ));
10753        assert!(send_through_tool_hooks(
10754            rules,
10755            "http://127.0.0.1:2/x",
10756            false
10757        ));
10758    }
10759
10760    /// A narrowed tool reaches only its declared host; the ceiling's other host is gone for
10761    /// that artifact even though a sibling tool still reaches it.
10762    #[test]
10763    fn narrowed_tool_reaches_only_its_declared_host() {
10764        let ceiling = narrowing_ceiling();
10765        let grant = grant_of(Some(vec!["http://127.0.0.1:1"]), None);
10766        let narrowed = effective_tool_network_rules(Some(&grant), &ceiling);
10767
10768        assert!(
10769            send_through_tool_hooks(narrowed, "http://127.0.0.1:1/x", false),
10770            "the declared host stays reachable"
10771        );
10772        assert!(
10773            !send_through_tool_hooks(narrowed, "http://127.0.0.1:2/x", false),
10774            "the rest of the ceiling is dropped for this artifact"
10775        );
10776        // The sibling with no entry is unaffected by its neighbour's narrowing.
10777        let sibling = effective_tool_network_rules(None, &ceiling);
10778        assert!(send_through_tool_hooks(
10779            sibling,
10780            "http://127.0.0.1:2/x",
10781            false
10782        ));
10783    }
10784
10785    /// An entry outside the ceiling is dropped rather than granted, and reported so staging
10786    /// can raise `W-SEC-007`.
10787    #[test]
10788    fn out_of_ceiling_entry_is_dropped_and_reported() {
10789        let ceiling = narrowing_ceiling();
10790        let grant = grant_of(
10791            Some(vec!["http://127.0.0.1:1", "https://evil.example.com"]),
10792            None,
10793        );
10794        let narrowed = effective_tool_network_rules(Some(&grant), &ceiling);
10795
10796        assert!(!send_through_tool_hooks(
10797            narrowed,
10798            "https://evil.example.com/x",
10799            true
10800        ));
10801        assert_eq!(
10802            grant.dropped_network_entries,
10803            vec!["https://evil.example.com".to_string()]
10804        );
10805    }
10806
10807    /// Without a scope the preopened root is the workdir itself — observable because
10808    /// `preopened_dir` requires the directory to exist, so a missing workdir is an error.
10809    #[test]
10810    fn tool_without_filesystem_scope_preopens_the_workdir_itself() {
10811        let root = TempDir::new().unwrap();
10812        let missing = root.path().join("does-not-exist");
10813
10814        assert!(
10815            build_wasi_ctx(
10816                &missing,
10817                None,
10818                None,
10819                None,
10820                &[],
10821                &CapabilityPolicy::default()
10822            )
10823            .is_err(),
10824            "an unscoped tool preopens the workdir, which must exist"
10825        );
10826        build_wasi_ctx(
10827            root.path(),
10828            None,
10829            None,
10830            None,
10831            &[],
10832            &CapabilityPolicy::default(),
10833        )
10834        .expect("an existing workdir preopens as before");
10835        assert!(
10836            !missing.exists(),
10837            "the unscoped path must not create anything"
10838        );
10839    }
10840
10841    /// With a scope the preopened root is `<workdir>/<scope>`, created if absent. Sibling
10842    /// paths under the workdir are never mounted, so a guest has no descriptor for them.
10843    #[test]
10844    fn tool_with_filesystem_scope_preopens_only_the_scoped_subtree() {
10845        let root = TempDir::new().unwrap();
10846        std::fs::write(root.path().join("secret.txt"), b"capsule state").unwrap();
10847
10848        build_wasi_ctx(
10849            root.path(),
10850            Some("cache"),
10851            None,
10852            None,
10853            &[],
10854            &CapabilityPolicy::default(),
10855        )
10856        .expect("a granted scope is created and preopened");
10857
10858        let scoped = root.path().join("cache");
10859        assert!(scoped.is_dir(), "the granted scope is created if missing");
10860        std::fs::write(scoped.join("entry.json"), b"{}").unwrap();
10861        assert!(scoped.join("entry.json").exists());
10862        assert!(
10863            root.path().join("secret.txt").exists(),
10864            "nothing outside the scope was touched"
10865        );
10866    }
10867
10868    /// A scope that cannot be created is a hard error naming the scope, never a silent
10869    /// widening back to the whole workdir.
10870    #[test]
10871    fn unusable_filesystem_scope_is_a_hard_error() {
10872        let root = TempDir::new().unwrap();
10873        // A regular file where the scope directory would go: `create_dir_all` cannot proceed.
10874        std::fs::write(root.path().join("cache"), b"not a directory").unwrap();
10875
10876        let policy = CapabilityPolicy::default();
10877        let err = match build_wasi_ctx(root.path(), Some("cache"), None, None, &[], &policy) {
10878            Ok(_) => panic!("an uncreatable scope must fail loudly"),
10879            Err(err) => err,
10880        };
10881        assert!(
10882            err.to_string().contains("cache"),
10883            "the error must name the scope, got: {err}"
10884        );
10885    }
10886
10887    /// End-to-end through the one dispatch body every WASM tool and the inference driver
10888    /// share: a real component is instantiated and called under a grant, and the scoped
10889    /// directory it was granted appears on the host filesystem.
10890    #[test]
10891    fn real_dispatch_applies_the_filesystem_scope() {
10892        let engine = build_engine().unwrap();
10893        let workdir = TempDir::new().unwrap();
10894        let component = crate::inference_import::test_support::driver_double(
10895            &engine,
10896            0,
10897            r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"ok"}]}"#,
10898        );
10899        let ceiling = narrowing_ceiling();
10900        let grant = grant_of(None, Some("tool-cache"));
10901
10902        let rt = tokio::runtime::Runtime::new().unwrap();
10903        let result = rt.block_on(async {
10904            invoke_tool_component(
10905                ToolInvokeEnv {
10906                    engine: &engine,
10907                    accessible_workdir: workdir.path(),
10908                    inference_env: &[],
10909                    capability_policy: &CapabilityPolicy::default(),
10910                    network_allow_rules: &ceiling,
10911                    artifact_grant: Some(&grant),
10912                    inference_gateway: None,
10913                },
10914                ToolA2aWiring::silent(),
10915                "scoped-tool",
10916                &component,
10917                murmur::tool::run::ToolInput {
10918                    data: Some("{}".to_string()),
10919                    log_path: None,
10920                },
10921            )
10922            .await
10923        });
10924
10925        assert!(result.is_ok(), "the scoped tool still runs: {result:?}");
10926        assert!(
10927            workdir.path().join("tool-cache").is_dir(),
10928            "the real dispatch path preopened the granted scope"
10929        );
10930    }
10931
10932    /// The same dispatch with no grant is byte-for-byte the pre-narrowing behavior: the whole
10933    /// accessible workdir is the preopen root and no subtree is created.
10934    #[test]
10935    fn real_dispatch_without_a_grant_is_unchanged() {
10936        let engine = build_engine().unwrap();
10937        let workdir = TempDir::new().unwrap();
10938        let component = crate::inference_import::test_support::driver_double(
10939            &engine,
10940            0,
10941            r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"ok"}]}"#,
10942        );
10943        let ceiling = narrowing_ceiling();
10944
10945        let rt = tokio::runtime::Runtime::new().unwrap();
10946        let result = rt.block_on(async {
10947            invoke_tool_component(
10948                ToolInvokeEnv {
10949                    engine: &engine,
10950                    accessible_workdir: workdir.path(),
10951                    inference_env: &[],
10952                    capability_policy: &CapabilityPolicy::default(),
10953                    network_allow_rules: &ceiling,
10954                    artifact_grant: None,
10955                    inference_gateway: None,
10956                },
10957                ToolA2aWiring::silent(),
10958                "plain-tool",
10959                &component,
10960                murmur::tool::run::ToolInput {
10961                    data: Some("{}".to_string()),
10962                    log_path: None,
10963                },
10964            )
10965            .await
10966        });
10967
10968        assert!(
10969            result.is_ok(),
10970            "an ungranted tool runs as before: {result:?}"
10971        );
10972        assert_eq!(
10973            std::fs::read_dir(workdir.path()).unwrap().count(),
10974            0,
10975            "no per-artifact subtree is created when nothing was granted"
10976        );
10977    }
10978
10979    /// Staging lowers a grant only for artifacts that declared one, and only from the
10980    /// operator's own entry — the map is the single source dispatch consults.
10981    #[test]
10982    fn staging_records_grants_only_for_declaring_artifacts() {
10983        let ceiling = narrowing_ceiling();
10984        let mut grants = HashMap::new();
10985
10986        let declared = ArtifactRequest {
10987            name: "scoped-tool".to_string(),
10988            version: "1.0.0".to_string(),
10989            runtime: ArtifactRuntime::Tool,
10990            source: None,
10991            on_overflow: Default::default(),
10992            config: None,
10993            capabilities: Some(murmur_artifact::Capabilities {
10994                peer_fetch: None,
10995                network: Some(murmur_artifact::NetworkCapabilities {
10996                    allow: vec!["http://127.0.0.1:1".to_string()],
10997                    unix_sockets: false,
10998                }),
10999                filesystem: None,
11000                shell: None,
11001                spawn: None,
11002                env: None,
11003                limits: None,
11004                resources: None,
11005                state: None,
11006                task_io: None,
11007                conversation: None,
11008                plan: None,
11009                containment: None,
11010            }),
11011        };
11012        let silent = ArtifactRequest {
11013            name: "plain-tool".to_string(),
11014            version: "1.0.0".to_string(),
11015            runtime: ArtifactRuntime::Tool,
11016            source: None,
11017            on_overflow: Default::default(),
11018            capabilities: None,
11019            config: None,
11020        };
11021
11022        stage_artifact_grant(&declared, &ceiling, "test-capsule", &mut grants).unwrap();
11023        stage_artifact_grant(&silent, &ceiling, "test-capsule", &mut grants).unwrap();
11024
11025        assert!(grants.contains_key("scoped-tool"));
11026        assert!(
11027            !grants.contains_key("plain-tool"),
11028            "an artifact with no capabilities block must stay absent so dispatch falls back \
11029             to the ceiling"
11030        );
11031    }
11032
11033    /// `config:` alone stages a grant, and that grant narrows nothing and widens nothing: every
11034    /// field but `config_json` equals [`ToolCapabilityGrant::default`], so the artifact keeps
11035    /// inheriting the capsule ceiling wholesale and simply gains one environment variable.
11036    ///
11037    /// `capabilities:` and `config:` are independent keys on one entry, so either alone has to
11038    /// produce an entry dispatch can find by name.
11039    #[test]
11040    fn config_alone_stages_a_grant_that_changes_nothing_else() {
11041        let ceiling = narrowing_ceiling();
11042        let mut grants = HashMap::new();
11043
11044        let configured = ArtifactRequest {
11045            name: "config-echo".to_string(),
11046            version: "1.0.0".to_string(),
11047            runtime: ArtifactRuntime::Driver,
11048            source: None,
11049            on_overflow: Default::default(),
11050            capabilities: None,
11051            config: Some(serde_yaml::from_str("who: a\n").unwrap()),
11052        };
11053
11054        stage_artifact_grant(&configured, &ceiling, "test-capsule", &mut grants).unwrap();
11055
11056        let grant = grants
11057            .get("config-echo")
11058            .expect("config alone stages a grant");
11059        assert_eq!(grant.config_json.as_deref(), Some(r#"{"who":"a"}"#));
11060        assert_eq!(
11061            grant,
11062            &ToolCapabilityGrant {
11063                config_json: grant.config_json.clone(),
11064                ..ToolCapabilityGrant::default()
11065            },
11066            "declaring config must leave every other field at the inherit-everything default"
11067        );
11068    }
11069
11070    // ── Protected paths on the store state ───────────────────────────────
11071
11072    /// The single boolean the dispatch path branches on. A capsule that declared nothing answers
11073    /// `false`, which is what keeps it from resolving a call at all.
11074    #[test]
11075    fn has_protected_paths_is_false_without_a_declaration_and_true_with_one() {
11076        let dir = tempfile::tempdir().unwrap();
11077        let workdir = dir.path().to_path_buf();
11078        let lock_path = workdir.join("murmur.lock");
11079        let mut state = build_test_state(
11080            Arc::new(FakeSkillRegistry::new(Vec::new())),
11081            workdir,
11082            lock_path,
11083        );
11084        assert!(!state.has_protected_paths());
11085        assert!(state
11086            .check_protected_paths(&ResolvedCall::Tool {
11087                tool_name: "writer".to_string(),
11088                input: r#"{"path":"tests/a","content":"x"}"#.to_string(),
11089                input_bytes: 34,
11090            })
11091            .is_none());
11092
11093        state.protected_paths = ProtectedPaths::from_declared(&["tests".to_string()]).unwrap();
11094        assert!(state.has_protected_paths());
11095        let refusal = state
11096            .check_protected_paths(&ResolvedCall::Tool {
11097                tool_name: "writer".to_string(),
11098                input: r#"{"path":"tests/a","content":"x"}"#.to_string(),
11099                input_bytes: 34,
11100            })
11101            .expect("a declared subtree is checked");
11102        assert_eq!(refusal.rule, "tests");
11103        assert_eq!(refusal.path, "tests/a");
11104    }
11105
11106    /// A `read_only` entry that cannot be a workdir subtree refuses the launch at lowering, so no
11107    /// session ever runs against a rule the runtime could not build.
11108    #[test]
11109    fn a_malformed_read_only_entry_refuses_before_a_session_exists() {
11110        for entry in ["/etc", "../outside", "tests/../../outside"] {
11111            let err = ProtectedPaths::from_declared(&[entry.to_string()])
11112                .expect_err("staging must refuse");
11113            assert!(
11114                matches!(&err, RuntimeError::InvalidReadOnlyPath { path, .. } if path == entry),
11115                "{entry}: {err}"
11116            );
11117        }
11118    }
11119
11120    /// A malformed `config:` block fails staging by artifact name, before any component is
11121    /// instantiated — the same treatment a malformed capability grant beside it gets.
11122    #[test]
11123    fn staging_rejects_a_malformed_config_block() {
11124        let mut grants = HashMap::new();
11125        let artifact = ArtifactRequest {
11126            name: "config-echo".to_string(),
11127            version: "1.0.0".to_string(),
11128            runtime: ArtifactRuntime::Tool,
11129            source: None,
11130            on_overflow: Default::default(),
11131            capabilities: None,
11132            config: Some(serde_yaml::from_str("[a, b]").unwrap()),
11133        };
11134
11135        let err =
11136            stage_artifact_grant(&artifact, &narrowing_ceiling(), "test-capsule", &mut grants)
11137                .expect_err("a sequence must fail staging");
11138        assert!(matches!(err, RuntimeError::InvalidArtifactConfig { .. }));
11139        assert!(err.to_string().contains("config-echo"), "{err}");
11140        assert!(grants.is_empty());
11141    }
11142
11143    /// A malformed grant fails staging rather than surfacing as a confusing denial once the
11144    /// tool is already running.
11145    #[test]
11146    fn staging_rejects_an_escaping_filesystem_scope() {
11147        let mut grants = HashMap::new();
11148        let artifact = ArtifactRequest {
11149            name: "escaping-tool".to_string(),
11150            version: "1.0.0".to_string(),
11151            runtime: ArtifactRuntime::Tool,
11152            source: None,
11153            on_overflow: Default::default(),
11154            config: None,
11155            capabilities: Some(murmur_artifact::Capabilities {
11156                peer_fetch: None,
11157                network: None,
11158                filesystem: Some(murmur_artifact::FilesystemCapabilities {
11159                    scope: Some("../escape".to_string()),
11160                    workdir_exec: false,
11161                    read_only: Vec::new(),
11162                }),
11163                shell: None,
11164                spawn: None,
11165                env: None,
11166                limits: None,
11167                resources: None,
11168                state: None,
11169                task_io: None,
11170                conversation: None,
11171                plan: None,
11172                containment: None,
11173            }),
11174        };
11175
11176        let err =
11177            stage_artifact_grant(&artifact, &narrowing_ceiling(), "test-capsule", &mut grants)
11178                .expect_err("an escaping scope must fail staging");
11179        assert!(matches!(err, RuntimeError::InvalidFilesystemScope { .. }));
11180        assert!(grants.is_empty());
11181    }
11182
11183    /// The inert-sub-block set is shared with the hook warning: `network`/`filesystem` are
11184    /// consumed, everything else is reported.
11185    #[test]
11186    fn inert_sub_blocks_are_exactly_the_unconsumed_ones() {
11187        let caps = murmur_artifact::Capabilities {
11188            peer_fetch: None,
11189            network: Some(murmur_artifact::NetworkCapabilities {
11190                allow: Vec::new(),
11191                unix_sockets: false,
11192            }),
11193            filesystem: Some(murmur_artifact::FilesystemCapabilities {
11194                scope: None,
11195                workdir_exec: false,
11196                read_only: Vec::new(),
11197            }),
11198            shell: Some(murmur_artifact::ShellCapabilities {
11199                allow: vec!["bash".to_string()],
11200                strip_env: None,
11201                baseline_env: None,
11202                interpreter_runtime: Vec::new(),
11203                staged_runtime: Vec::new(),
11204            }),
11205            spawn: None,
11206            env: Some(murmur_artifact::EnvCapabilities { allow: Vec::new() }),
11207            limits: None,
11208            resources: None,
11209            // Declared here precisely to assert its absence from the list below: `state` is a
11210            // sub-block per-artifact narrowing *does* read, so reporting it as inert would tell
11211            // an operator their durable store was ignored when it was granted.
11212            state: Some(murmur_artifact::StateCapabilities { store: None }),
11213            task_io: None,
11214            conversation: None,
11215            plan: None,
11216            containment: Some(murmur_artifact::ContainmentClass::Sealed),
11217        };
11218
11219        assert_eq!(
11220            inert_capability_sub_blocks(Some(&caps)),
11221            vec!["shell", "env", "containment"]
11222        );
11223        assert!(inert_capability_sub_blocks(None).is_empty());
11224    }
11225
11226    // ── End-to-end reopen loop (real Wasmtime hook + real process transport) ──────
11227
11228    /// Write an executable fake `claude`-dialect CLI that, on each spawn, consumes its
11229    /// stdin then emits exactly one assistant text turn and a success result — so every
11230    /// agent-loop attempt burns exactly one turn and returns `Ok`.
11231    fn write_fake_claude_cli(dir: &Path) -> PathBuf {
11232        let script = dir.join("claude");
11233        fs::write(
11234            &script,
11235            "#!/bin/sh\ncat > /dev/null\nprintf '%s\\n' '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"done\"}]}}'\nprintf '%s\\n' '{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"done\"}'\n",
11236        )
11237        .unwrap();
11238        #[cfg(unix)]
11239        {
11240            use std::os::unix::fs::PermissionsExt;
11241            let mut perms = fs::metadata(&script).unwrap().permissions();
11242            perms.set_mode(0o755);
11243            fs::set_permissions(&script, perms).unwrap();
11244        }
11245        script
11246    }
11247
11248    /// A current-version (`@0.8.0`, 7-case `hook-output`) `on-task-end` hook double that
11249    /// returns `reopen-task(reason)` on its first `reopen_limit` invocations (tracked by a
11250    /// mutable core global that persists across a blocking hook's reused store) and `none`
11251    /// thereafter. `reopen_limit` large ⇒ "always reopen".
11252    fn on_task_end_reopen_double(
11253        engine: &wasmtime::Engine,
11254        reopen_limit: u32,
11255        reason: &str,
11256    ) -> wasmtime::component::Component {
11257        let reason_len = reason.len();
11258        let stubs = [
11259            "on-session-start",
11260            "on-inference",
11261            "on-tool-call",
11262            "on-shell",
11263            "on-compaction",
11264            "on-session-end",
11265        ]
11266        .iter()
11267        .map(|n| format!("    (export \"{n}\" (func $noop))"))
11268        .collect::<Vec<_>>()
11269        .join("\n");
11270        let wat = format!(
11271            r#"(component
11272  (core module $m
11273    (memory (export "memory") 1)
11274    (global $count (mut i32) (i32.const 0))
11275    (data (i32.const 300) "{reason}")
11276    (func (export "realloc") (param i32 i32 i32 i32) (result i32) i32.const 512)
11277    (func (export "ontaskend") (param i32 i32 i32 i32) (result i32)
11278      (i32.store (i32.const 128) (i32.const 0))
11279      (if (result i32) (i32.lt_u (global.get $count) (i32.const {reopen_limit}))
11280        (then
11281          (global.set $count (i32.add (global.get $count) (i32.const 1)))
11282          (i32.store (i32.const 132) (i32.const 4))
11283          (i32.store (i32.const 136) (i32.const 300))
11284          (i32.store (i32.const 140) (i32.const {reason_len}))
11285          (i32.const 128))
11286        (else
11287          (i32.store (i32.const 132) (i32.const 0))
11288          (i32.const 128))))
11289    (func (export "noop"))
11290  )
11291  (core instance $i (instantiate $m))
11292  (alias core export $i "memory" (core memory $mem))
11293  (alias core export $i "realloc" (core func $realloc))
11294
11295  (type $message (record
11296    (field "role" string)
11297    (field "content" string)
11298    (field "id" (option string))
11299    (field "source-id" (option string))))
11300  (type $tool-manifest (record (field "binary-name" string) (field "content" string)))
11301  (type $hook-output (variant
11302    (case "none")
11303    (case "replace-context" (list $message))
11304    (case "write-manifests" (list $tool-manifest))
11305    (case "artifact" string)
11306    (case "reopen-task" string)
11307    (case "seed-context" (list $message))
11308    (case "deny" string)))
11309  (type $task-end-event (record
11310    (field "task-id" string)
11311    (field "exit-status" string)))
11312  (type $ft (func (param "event" $task-end-event) (result (result $hook-output (error string)))))
11313
11314  (func $te (type $ft)
11315    (canon lift (core func $i "ontaskend") (memory $mem) (realloc $realloc) string-encoding=utf8))
11316  (func $noop (canon lift (core func $i "noop")))
11317
11318  (instance $lc
11319    (export "message" (type $message))
11320    (export "tool-manifest" (type $tool-manifest))
11321    (export "hook-output" (type $hook-output))
11322    (export "task-end-event" (type $task-end-event))
11323    (export "on-task-end" (func $te))
11324{stubs}
11325  )
11326  (export "murmur:hook/lifecycle@0.8.0" (instance $lc))
11327)"#
11328        );
11329        let bytes = wat::parse_str(&wat).expect("on-task-end reopen double WAT parses");
11330        wasmtime::component::Component::new(engine, &bytes)
11331            .expect("on-task-end reopen double compiles")
11332    }
11333
11334    /// Drive `run_task_with_reopens` once, with a real process-transport agent loop (fake
11335    /// `claude` CLI, one turn per attempt) and a real Wasmtime `on-task-end` hook that
11336    /// reopens `reopen_limit` times. Returns the task result and the parsed `trace.jsonl`.
11337    async fn run_reopen_scenario(
11338        reopen_limit: u32,
11339        max_task_reopens: u32,
11340        max_turns: u32,
11341    ) -> (Result<(), RuntimeError>, Vec<serde_json::Value>) {
11342        let dir = tempfile::tempdir().unwrap();
11343        let workdir = dir.path().to_path_buf();
11344        fs::create_dir_all(workdir.join("tools")).unwrap();
11345        fs::write(workdir.join("task.md"), "Original task: build the thing.").unwrap();
11346        let cli = write_fake_claude_cli(dir.path());
11347
11348        let inference = InferenceConfig {
11349            transport: "process".into(),
11350            endpoint: None,
11351            model: "test-model".into(),
11352            api_key: None,
11353            driver: None,
11354            command: Some(cli.to_string_lossy().to_string()),
11355            compaction: None,
11356            system_prompt: None,
11357            system_prompt_file: None,
11358            system_prompt_artifact: None,
11359            max_turns,
11360            max_tokens: None,
11361            max_session_tokens: None,
11362        };
11363
11364        let mut state = build_test_state(
11365            Arc::new(FakeSkillRegistry::new(Vec::new())),
11366            workdir.clone(),
11367            workdir.join("murmur.lock"),
11368        );
11369
11370        let mut trace = TraceWriter::open(
11371            &workdir,
11372            "ses_test".to_string(),
11373            "cap".to_string(),
11374            "0.1.0".to_string(),
11375            "test-model".to_string(),
11376            Vec::new(),
11377            crate::containment::scope_report_for_tier(
11378                &CapabilityPolicy::default(),
11379                murmur_artifact::ContainmentClass::Advisory,
11380                sandbox::EnforcementTier::EnvironmentOnly,
11381                None,
11382                None,
11383                None,
11384                Vec::new(),
11385                Vec::new(),
11386                Vec::new(),
11387                crate::cgroup::IoMaxReport::default(),
11388            ),
11389            murmur_artifact::TraceCapture::Meta,
11390            None,
11391            false,
11392            None,
11393            None,
11394            None,
11395            None,
11396        )
11397        .await
11398        .unwrap();
11399
11400        let mut otel = OtelEmitter::new(None, &workdir, "cap".to_string(), "0.1.0".to_string());
11401
11402        let staged_hook = StagedHookArtifact {
11403            name: "gatekeeper".to_string(),
11404            version: "0.0.1".to_string(),
11405            component: on_task_end_reopen_double(&state.engine, reopen_limit, "tests still fail"),
11406            config: murmur_artifact::HookConfig {
11407                binding: HookBinding::OnTaskEnd,
11408                execution_mode: murmur_artifact::HookExecutionMode::Blocking,
11409                commit_policy: murmur_artifact::HookCommitPolicy::ReopenTask,
11410            },
11411            grant: HookCapabilityGrant::default(),
11412            on_overflow: Default::default(),
11413        };
11414
11415        let mut hooks = HookRuntime::new(
11416            &state.engine,
11417            &workdir,
11418            &workdir,
11419            vec![staged_hook],
11420            SessionContextData {
11421                capsule_name: "cap".to_string(),
11422                capsule_version: "0.1.0".to_string(),
11423                session_id: "ses_test".to_string(),
11424                model: "test-model".to_string(),
11425                capabilities: Vec::new(),
11426            },
11427            HookEnvVars::default(),
11428            crate::limits::ExecutionLimits::default(),
11429            None,
11430            None,
11431        )
11432        .await
11433        .unwrap();
11434
11435        let run_config = agent::AgentRunConfig {
11436            context_window: 0,
11437            compaction_threshold: 0.98,
11438            compaction_model: None,
11439            compaction_system_prompt: None,
11440            compaction_dump_summaries: false,
11441            max_output_tokens: 1024,
11442            seed_budget: murmur_artifact::DEFAULT_SEED_BUDGET,
11443            seed_overflow_margin: murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN,
11444            conversation_root: None,
11445            record_owner: None,
11446            resume: None,
11447        };
11448
11449        // Caller resets per-task counters via write_task_start before the reopen loop.
11450        trace
11451            .write_task_start(
11452                "tsk_1",
11453                "ctx_1",
11454                "task_md",
11455                TaskProvenance::derive(TaskOrigin::User, None),
11456                None,
11457                8,
11458            )
11459            .await
11460            .unwrap();
11461
11462        let result = run_task_with_reopens(
11463            &mut state,
11464            &workdir,
11465            &inference,
11466            max_task_reopens,
11467            None,
11468            run_config,
11469            &mut hooks,
11470            &mut trace,
11471            &mut otel,
11472            None,
11473            None,
11474            &workdir,
11475            "cap",
11476            "0.1.0",
11477            ConversationMode::Stateless,
11478            Some("ctx_1".to_string()),
11479            "tsk_1",
11480            None,
11481            None,
11482        )
11483        .await;
11484
11485        trace.flush().await.unwrap();
11486        let content = fs::read_to_string(workdir.join("trace.jsonl")).unwrap();
11487        let events: Vec<serde_json::Value> = content
11488            .lines()
11489            .filter(|l| !l.is_empty())
11490            .map(|l| serde_json::from_str(l).unwrap())
11491            .collect();
11492        (result.map(|_| ()), events)
11493    }
11494
11495    // ── murmur:task-io/read end to end ────────────────────────────────────────
11496
11497    use crate::task_io_import::test_support::{reader_double, REPORT_SEP};
11498
11499    /// A `claude` CLI double that emits a different result sentinel on each invocation,
11500    /// counted through a file next to the script. A reopened task runs the agent loop more
11501    /// than once, and telling attempt 2's output from attempt 1's is the whole point of
11502    /// clearing the slot at attempt start.
11503    fn write_counting_fake_claude_cli(dir: &Path) -> PathBuf {
11504        let script = dir.join("claude-counting");
11505        let counter = dir.join("attempt-counter");
11506        fs::write(
11507            &script,
11508            format!(
11509                "#!/bin/sh\ncat > /dev/null\nn=$(cat '{c}' 2>/dev/null || echo 0)\n\
11510                 n=$((n+1))\necho \"$n\" > '{c}'\n\
11511                 printf '%s\\n' \"{{\\\"type\\\":\\\"assistant\\\",\\\"message\\\":\
11512                 {{\\\"content\\\":[{{\\\"type\\\":\\\"text\\\",\\\"text\\\":\
11513                 \\\"RESULT-$n\\\"}}]}}}}\"\n\
11514                 printf '%s\\n' \"{{\\\"type\\\":\\\"result\\\",\\\"subtype\\\":\
11515                 \\\"success\\\",\\\"result\\\":\\\"RESULT-$n\\\"}}\"\n",
11516                c = counter.display()
11517            ),
11518        )
11519        .unwrap();
11520        #[cfg(unix)]
11521        {
11522            use std::os::unix::fs::PermissionsExt;
11523            let mut perms = fs::metadata(&script).unwrap().permissions();
11524            perms.set_mode(0o755);
11525            fs::set_permissions(&script, perms).unwrap();
11526        }
11527        script
11528    }
11529
11530    /// The task text every task-io scenario starts from — a sentinel a hook can only have
11531    /// obtained through the import, since the hook holds no filesystem scope.
11532    const TASK_SENTINEL: &str = "TASK-SENTINEL: build the thing.";
11533
11534    /// Drive `run_task_with_reopens` end to end with the `murmur:task-io/read` reader double
11535    /// as the `on-task-end` hook, on either transport.
11536    ///
11537    /// The hook's grant is task-io only: no network rules, no filesystem scope. Everything it
11538    /// reports in its `reopen-task` reason therefore came through the import. Returns the
11539    /// task result and the parsed `trace.jsonl`.
11540    async fn run_task_io_scenario(
11541        transport: &str,
11542        task_io_read: bool,
11543        max_task_reopens: u32,
11544    ) -> (Result<(), RuntimeError>, Vec<serde_json::Value>) {
11545        let dir = tempfile::tempdir().unwrap();
11546        let workdir = dir.path().to_path_buf();
11547        fs::create_dir_all(workdir.join("tools")).unwrap();
11548        fs::write(workdir.join("task.md"), TASK_SENTINEL).unwrap();
11549
11550        let mut state = build_test_state(
11551            Arc::new(FakeSkillRegistry::new(Vec::new())),
11552            workdir.clone(),
11553            workdir.join("murmur.lock"),
11554        );
11555
11556        let inference = if transport == "process" {
11557            let cli = write_counting_fake_claude_cli(dir.path());
11558            InferenceConfig {
11559                transport: "process".into(),
11560                command: Some(cli.to_string_lossy().to_string()),
11561                driver: None,
11562                ..task_io_inference_config()
11563            }
11564        } else {
11565            // The http transport dispatches a WASM driver component out of `tools/<name>`;
11566            // the directory's existence is what `run_agent_loop` checks before instantiating.
11567            fs::create_dir_all(workdir.join("tools").join("mock-driver")).unwrap();
11568            state.tool_components.insert(
11569                "mock-driver".to_string(),
11570                crate::inference_import::test_support::driver_double(
11571                    &state.engine,
11572                    0,
11573                    r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"RESULT-1"}]}"#,
11574                ),
11575            );
11576            InferenceConfig {
11577                transport: "http".into(),
11578                command: None,
11579                driver: Some(murmur_artifact::InferenceDriver {
11580                    artifact: "mock-driver".to_string(),
11581                    config: None,
11582                }),
11583                ..task_io_inference_config()
11584            }
11585        };
11586
11587        let mut trace = TraceWriter::open(
11588            &workdir,
11589            "ses_test".to_string(),
11590            "cap".to_string(),
11591            "0.1.0".to_string(),
11592            "test-model".to_string(),
11593            Vec::new(),
11594            crate::containment::scope_report_for_tier(
11595                &CapabilityPolicy::default(),
11596                murmur_artifact::ContainmentClass::Advisory,
11597                sandbox::EnforcementTier::EnvironmentOnly,
11598                None,
11599                None,
11600                None,
11601                Vec::new(),
11602                Vec::new(),
11603                Vec::new(),
11604                crate::cgroup::IoMaxReport::default(),
11605            ),
11606            murmur_artifact::TraceCapture::Meta,
11607            None,
11608            false,
11609            None,
11610            None,
11611            None,
11612            None,
11613        )
11614        .await
11615        .unwrap();
11616        let mut otel = OtelEmitter::new(None, &workdir, "cap".to_string(), "0.1.0".to_string());
11617
11618        let staged_hook = StagedHookArtifact {
11619            name: "gatekeeper".to_string(),
11620            version: "0.0.1".to_string(),
11621            component: reader_double(&state.engine),
11622            config: murmur_artifact::HookConfig {
11623                binding: HookBinding::OnTaskEnd,
11624                execution_mode: murmur_artifact::HookExecutionMode::Blocking,
11625                commit_policy: murmur_artifact::HookCommitPolicy::ReopenTask,
11626            },
11627            grant: crate::network_policy::HookCapabilityGrant {
11628                network_allow_rules: Vec::new(),
11629                filesystem_scope: None,
11630                task_io_read,
11631                conversation_read: false,
11632                state_store: None,
11633                state_dir: None,
11634                config_json: None,
11635            },
11636            on_overflow: Default::default(),
11637        };
11638
11639        let mut hooks = HookRuntime::new(
11640            &state.engine,
11641            &workdir,
11642            &workdir,
11643            vec![staged_hook],
11644            SessionContextData {
11645                capsule_name: "cap".to_string(),
11646                capsule_version: "0.1.0".to_string(),
11647                session_id: "ses_test".to_string(),
11648                model: "test-model".to_string(),
11649                capabilities: Vec::new(),
11650            },
11651            HookEnvVars::default(),
11652            crate::limits::ExecutionLimits::default(),
11653            None,
11654            None,
11655        )
11656        .await
11657        .unwrap();
11658
11659        let run_config = agent::AgentRunConfig {
11660            context_window: 0,
11661            compaction_threshold: 0.98,
11662            compaction_model: None,
11663            compaction_system_prompt: None,
11664            compaction_dump_summaries: false,
11665            max_output_tokens: 1024,
11666            seed_budget: murmur_artifact::DEFAULT_SEED_BUDGET,
11667            seed_overflow_margin: murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN,
11668            conversation_root: None,
11669            record_owner: None,
11670            resume: None,
11671        };
11672
11673        trace
11674            .write_task_start(
11675                "tsk_1",
11676                "ctx_1",
11677                "task_md",
11678                TaskProvenance::derive(TaskOrigin::User, None),
11679                None,
11680                8,
11681            )
11682            .await
11683            .unwrap();
11684
11685        let result = run_task_with_reopens(
11686            &mut state,
11687            &workdir,
11688            &inference,
11689            max_task_reopens,
11690            None,
11691            run_config,
11692            &mut hooks,
11693            &mut trace,
11694            &mut otel,
11695            None,
11696            None,
11697            &workdir,
11698            "cap",
11699            "0.1.0",
11700            ConversationMode::Stateless,
11701            Some("ctx_1".to_string()),
11702            "tsk_1",
11703            None,
11704            None,
11705        )
11706        .await;
11707
11708        trace.flush().await.unwrap();
11709        let content = fs::read_to_string(workdir.join("trace.jsonl")).unwrap();
11710        let events: Vec<serde_json::Value> = content
11711            .lines()
11712            .filter(|l| !l.is_empty())
11713            .map(|l| serde_json::from_str(l).unwrap())
11714            .collect();
11715        (result.map(|_| ()), events)
11716    }
11717
11718    /// The fields both transports share in [`run_task_io_scenario`].
11719    fn task_io_inference_config() -> InferenceConfig {
11720        InferenceConfig {
11721            transport: String::new(),
11722            endpoint: None,
11723            model: "test-model".into(),
11724            api_key: None,
11725            driver: None,
11726            command: None,
11727            compaction: None,
11728            system_prompt: None,
11729            system_prompt_file: None,
11730            system_prompt_artifact: None,
11731            max_turns: 10,
11732            max_tokens: None,
11733            max_session_tokens: None,
11734        }
11735    }
11736
11737    /// Every `task_reopened` reason in `events`, in order, split into the reader double's
11738    /// five fields `[A, O, R, LI, LO]`.
11739    ///
11740    /// Split from the right: a reopened attempt's `as-given` is the task text plus the
11741    /// previous attempt's report injected as feedback, separators and all, so only the four
11742    /// trailing fields are positionally fixed. Everything before them is field `A`.
11743    fn reopen_reports(events: &[serde_json::Value]) -> Vec<Vec<String>> {
11744        events
11745            .iter()
11746            .filter(|e| e["event_type"] == "task_reopened")
11747            .map(|e| {
11748                let mut fields: Vec<String> = e["reason"]
11749                    .as_str()
11750                    .unwrap()
11751                    .rsplitn(5, REPORT_SEP)
11752                    .map(str::to_string)
11753                    .collect();
11754                fields.reverse();
11755                fields
11756            })
11757            .collect()
11758    }
11759
11760    /// The shippable outcome: a hook with no filesystem grant and no network grant, granted
11761    /// only `task_io.read`, reads the task it was given and the result the agent produced at
11762    /// `on-task-end` and returns both in its `reopen-task` reason.
11763    #[tokio::test(flavor = "multi_thread")]
11764    async fn granted_hook_reads_task_and_result_over_the_process_transport() {
11765        let (_, events) = run_task_io_scenario("process", true, 1).await;
11766        assert_eq!(
11767            reopen_reports(&events),
11768            vec![vec![
11769                format!("A={TASK_SENTINEL}"),
11770                format!("O={TASK_SENTINEL}"),
11771                "R=RESULT-1".to_string(),
11772                format!("LI={}", TASK_SENTINEL.len()),
11773                "LO=8".to_string(),
11774            ]],
11775            "the hook holds no filesystem scope, so neither sentinel could have come from disk"
11776        );
11777    }
11778
11779    /// Transport parity: the same hook and the same scenario through the WASM-driver loop in
11780    /// `agent.rs` rather than the subprocess loop in `agent/process.rs`. Each transport funnels
11781    /// its result-text write through its own recorder, so each transport's own result is what
11782    /// the hook reads.
11783    #[tokio::test(flavor = "multi_thread")]
11784    async fn granted_hook_reads_task_and_result_over_the_http_transport() {
11785        let (_, events) = run_task_io_scenario("http", true, 1).await;
11786        assert_eq!(
11787            reopen_reports(&events),
11788            vec![vec![
11789                format!("A={TASK_SENTINEL}"),
11790                format!("O={TASK_SENTINEL}"),
11791                "R=RESULT-1".to_string(),
11792                format!("LI={}", TASK_SENTINEL.len()),
11793                "LO=8".to_string(),
11794            ]]
11795        );
11796    }
11797
11798    /// Default-deny end to end: the same double under a grant with `task_io_read: false` still
11799    /// instantiates, is still dispatched, and still drives a reopen — its reason just carries
11800    /// the `not-granted` marker instead of either sentinel. The session is not aborted.
11801    #[tokio::test(flavor = "multi_thread")]
11802    async fn an_ungranted_hook_reads_nothing_end_to_end() {
11803        let (_, events) = run_task_io_scenario("process", false, 1).await;
11804        let reports = reopen_reports(&events);
11805        assert_eq!(
11806            reports,
11807            vec![vec!["A=!0", "O=!0", "R=!0", "LI=!0", "LO=!0"]],
11808            "every read is not-granted, and no length leaks either"
11809        );
11810    }
11811
11812    /// Reopen semantics across three attempts. `original` is byte-identical on every attempt;
11813    /// `as-given` picks up the `# Reopen feedback` section `build_reopen_task_md` writes; and
11814    /// the output read on attempt N is attempt N's own result, never attempt N-1's.
11815    #[tokio::test(flavor = "multi_thread")]
11816    async fn reopened_attempts_see_their_own_input_and_their_own_output() {
11817        let (result, events) = run_task_io_scenario("process", true, 2).await;
11818        assert!(
11819            result.is_err(),
11820            "the reader double always reopens, so the budget runs out"
11821        );
11822        let reports = reopen_reports(&events);
11823        assert_eq!(reports.len(), 2, "max_task_reopens: 2 ⇒ two reopens");
11824
11825        for (index, report) in reports.iter().enumerate() {
11826            assert_eq!(
11827                report[1],
11828                format!("O={TASK_SENTINEL}"),
11829                "`original` never changes across attempts"
11830            );
11831            assert_eq!(
11832                report[2],
11833                format!("R=RESULT-{}", index + 1),
11834                "attempt {} must read its own result, never the previous attempt's",
11835                index + 1
11836            );
11837        }
11838
11839        assert_eq!(
11840            reports[0][0],
11841            format!("A={TASK_SENTINEL}"),
11842            "attempt 1 was handed the pristine task"
11843        );
11844        let attempt_two_input = &reports[1][0];
11845        assert!(
11846            attempt_two_input.starts_with(&format!("A={TASK_SENTINEL}"))
11847                && attempt_two_input.contains("# Reopen feedback"),
11848            "attempt 2's `as-given` is the pristine task plus the injected feedback, got: \
11849             {attempt_two_input}"
11850        );
11851        assert_eq!(
11852            reports[1][3],
11853            format!("LI={}", attempt_two_input.len() - "A=".len()),
11854            "input-len reports the byte length of the very text read back"
11855        );
11856    }
11857
11858    fn count_type(events: &[serde_json::Value], ty: &str) -> usize {
11859        events.iter().filter(|e| e["event_type"] == ty).count()
11860    }
11861
11862    /// Happy path: one reopen, then the hook is satisfied. The agent loop runs twice, one
11863    /// `task_reopened` sits between the attempts naming the hook and its feedback, and the
11864    /// terminal `task_end` shows `reopen_count: 1` with the second attempt's real outcome.
11865    #[tokio::test(flavor = "multi_thread")]
11866    async fn reopen_once_then_satisfied_end_to_end() {
11867        let (result, events) = run_reopen_scenario(1, 5, 10).await;
11868        assert!(
11869            result.is_ok(),
11870            "a satisfied hook ends the task Ok: {result:?}"
11871        );
11872        assert_eq!(
11873            count_type(&events, "inference"),
11874            2,
11875            "agent loop ran exactly twice"
11876        );
11877        assert_eq!(count_type(&events, "task_reopened"), 1);
11878        let re = events
11879            .iter()
11880            .find(|e| e["event_type"] == "task_reopened")
11881            .unwrap();
11882        assert_eq!(re["hook_name"], "gatekeeper");
11883        assert_eq!(re["reason"], "tests still fail");
11884        assert_eq!(re["reopen_number"], 1);
11885        let end = events
11886            .iter()
11887            .find(|e| e["event_type"] == "task_end")
11888            .unwrap();
11889        assert_eq!(end["reopen_count"], 1);
11890        assert_eq!(end["exit_status"], "ok");
11891    }
11892
11893    /// Budget exhausted: a hook that always reopens with `lifecycle.max_task_reopens: 1` runs
11894    /// the loop exactly twice, then ends `reopen_budget_exhausted` (an `Err`, so downstream
11895    /// task-failure branches fire) with `reopen_count: 1`.
11896    #[tokio::test(flavor = "multi_thread")]
11897    async fn reopen_budget_exhausted_end_to_end() {
11898        let (result, events) = run_reopen_scenario(99, 1, 10).await;
11899        assert!(
11900            result.is_err(),
11901            "an exhausted reopen budget is a task failure"
11902        );
11903        assert_eq!(count_type(&events, "inference"), 2, "1 original + 1 reopen");
11904        assert_eq!(count_type(&events, "task_reopened"), 1);
11905        let end = events
11906            .iter()
11907            .find(|e| e["event_type"] == "task_end")
11908            .unwrap();
11909        assert_eq!(end["reopen_count"], 1);
11910        assert_eq!(end["exit_status"], "reopen_budget_exhausted");
11911    }
11912
11913    /// Turn ceiling respected: `inference.max_turns: 3`, `lifecycle.max_task_reopens: 5`, a
11914    /// hook that always reopens, one turn per attempt. Cumulative `inference` records never
11915    /// exceed 3, and the task ends `reopen_budget_exhausted` once turns run out even though
11916    /// reopens remain in the budget.
11917    #[tokio::test(flavor = "multi_thread")]
11918    async fn reopen_never_exceeds_max_turns_end_to_end() {
11919        let (result, events) = run_reopen_scenario(99, 5, 3).await;
11920        assert!(result.is_err());
11921        assert_eq!(
11922            count_type(&events, "inference"),
11923            3,
11924            "cumulative turns must never exceed max_turns"
11925        );
11926        assert_eq!(
11927            count_type(&events, "task_reopened"),
11928            2,
11929            "3 attempts ⇒ 2 reopens"
11930        );
11931        let end = events
11932            .iter()
11933            .find(|e| e["event_type"] == "task_end")
11934            .unwrap();
11935        assert_eq!(end["reopen_count"], 2);
11936        assert_eq!(end["exit_status"], "reopen_budget_exhausted");
11937    }
11938}