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};
10
11use murmur_artifact::{
12    current_platform, parse_hook_config_from_yaml, parse_tool_implementation_from_yaml,
13    read_lockfile, security_warning_link, verify_sha256, write_lockfile_atomic, AfterTask,
14    ArtifactImplementation, ArtifactRuntime, ContextConfig, ConversationMode, HookBinding,
15    InferenceConfig, InterpreterRuntimeGrant, LifecycleConfig, LockedArtifact, LockedSha256,
16    LockfileError, MurmurLock, Registry, RegistryError, RuntimeType, TaskAcceptance, LOCK_VERSION,
17    MANIFEST_FILENAME, PACKED_MANIFEST_ENTRY, W_SEC_003, W_SEC_006, W_SEC_007, W_SEC_008,
18    W_SEC_009, W_SEC_011,
19};
20use serde_yaml::Value;
21use wasmtime::{
22    component::{Component, HasSelf, Linker, ResourceTable},
23    Config, Engine, Store,
24};
25use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
26use wasmtime_wasi_http::{
27    p2::{
28        bindings::http::types::ErrorCode as WasiHttpErrorCode,
29        body::HyperOutgoingBody,
30        types::{HostFutureIncomingResponse, OutgoingRequestConfig},
31        HttpResult, WasiHttpCtxView, WasiHttpHooks, WasiHttpView,
32    },
33    WasiHttpCtx,
34};
35
36use crate::{
37    a2a::{IncomingTask, TaskRegistry, TaskState},
38    agent,
39    artifact::{extract_manifest_yaml, extract_native_binary, extract_root_wasm, extract_skill_md},
40    bindings::host::murmur::{
41        self, artifact_manager::manage, message::send, tool_registry::invoke,
42    },
43    cgroup,
44    containment::{achieved_containment_class, check_containment_floor},
45    errors::RuntimeError,
46    hooks::{
47        dispatch_stage, HookEnvVars, HookEvent, HookRuntime, SessionContextData, ShellDispatchInfo,
48        TaskReopen,
49    },
50    identity::{self, CapsuleIdentity},
51    inference_import::HookInferenceCtx,
52    limits::{classify_guest_failure, EpochTicker, ExecutionLimiter, GuestFailure},
53    murmur_md,
54    network_policy::{
55        effective_tool_network_rules, parse_network_allow_rules, resolve_scoped_dir,
56        validate_filesystem_scope, HookCapabilityGrant, NetworkAllowRule, RequestTarget,
57        ToolCapabilityGrant,
58    },
59    otel::OtelEmitter,
60    outgoing, resources, sandbox,
61    shell::{
62        build_shell_env, build_wasi_env_allowlist, execute_shell, is_shell_interpreter,
63        shell_tool_manifest_yaml, split_shell_words, ShellResult,
64    },
65    streaming::{
66        emit_chunk_sse, emit_sse, emit_thinking_chunk_sse, SseBroadcast, SseEventBuffer,
67        StreamStatus, TaskStatusUpdateEvent,
68    },
69    trace::TraceWriter,
70    types::{
71        ArtifactRequest, CapabilityPolicy, DispatchOutcome, InstalledArtifactSummary, LaunchResult,
72        ResolvedLockArtifact, StageRequest, StagedHookArtifact, StagedSession,
73    },
74};
75
76/// Versioned instance export name a guest built against the semver'd
77/// `murmur:capsule@0.1.0` WIT package carries. This is the only name the host
78/// resolves — the legacy unversioned fallback was removed after the dual-accept
79/// runtime shipped (see `wit/VERSIONING.md`).
80const WIT_CAPSULE_IFACE_VERSIONED: &str = "murmur:capsule/run@0.1.0";
81/// Versioned instance export name a guest built against `murmur:tool@0.1.0`
82/// carries. Only name the host resolves; see `WIT_CAPSULE_IFACE_VERSIONED`.
83const WIT_TOOL_IFACE_VERSIONED: &str = "murmur:tool/run@0.1.0";
84
85/// The host provides its guest-facing *import* interfaces under the versioned
86/// instance name only. The legacy unversioned provisions were dropped after the
87/// dual-accept runtime shipped; a guest importing only the
88/// unversioned name now fails to link. See `wit/VERSIONING.md`.
89const WIT_TOOL_REGISTRY_IFACE: &str = "murmur:tool-registry/invoke@0.1.0";
90const WIT_TEXT_CHUNKS_IFACE: &str = "murmur:text/chunks@0.1.0";
91const WIT_TASK_IFACE: &str = "murmur:task/task@0.1.0";
92
93/// Resolve a guest interface instance export by its versioned name. Returns
94/// `None` when the versioned name is absent, so a component that exports only
95/// the legacy unversioned name (or no recognizable name) surfaces as the
96/// missing-export error at the call site. See `wit/VERSIONING.md`.
97fn resolve_versioned_iface<T>(
98    instance: &wasmtime::component::Instance,
99    store: &mut Store<T>,
100    versioned: &str,
101) -> Option<wasmtime::component::ComponentExportIndex> {
102    instance.get_export_index(&mut *store, None, versioned)
103}
104
105/// Run one task's agent loop, honoring `on-task-end` `reopen-task` control decisions.
106///
107/// Fires `on-task-end` after every attempt (via [`HookRuntime::dispatch_task_end`]). If
108/// a blocking hook returns `reopen-task(reason)` and both budgets still allow it — fewer
109/// than `inference.max_task_reopens` reopens used AND cumulative task turns still below
110/// `inference.max_turns` — the task's `accessible_workdir/task.md` is rewritten as the
111/// original content plus every reopen's feedback so far, a `task_reopened` trace record
112/// is written, and the loop runs again. Reopening shares one cumulative turn budget with
113/// the original attempt: each attempt is handed only `max_turns - task_turns()` turns, so
114/// the whole task can never exceed the capsule's turn ceiling.
115///
116/// Writes the terminal `task_end` record (carrying the final `reopen_count`) itself, and
117/// the terminal `on-task-end` dispatch is simply the loop's last one. Returns the task's
118/// final result: the last attempt's own result when a hook was satisfied (or none was
119/// bound), or `Err` when a hook still wanted to reopen but the reopen budget or turn
120/// ceiling was reached — so every existing `.is_err()` branch at the call sites treats an
121/// exhausted reopen as a failed task. In that case the terminal record's `exit_status` is
122/// `"reopen_budget_exhausted"`; otherwise it is the last attempt's `"ok"`/`"failed"`.
123///
124/// `agent_task_id` is what [`agent::run_agent_loop`] receives (governs A2A SSE emission);
125/// `trace_task_id` is the id used for the `task_start`/`task_reopened`/`task_end` records
126/// and the `on-task-end` hook event. The two coincide on the A2A path and differ on the
127/// backward-compat `task.md` paths, which pass `agent_task_id = None`.
128#[allow(clippy::too_many_arguments)]
129async fn run_task_with_reopens(
130    state: &mut CapsuleStoreState,
131    workdir: &Path,
132    inference: &InferenceConfig,
133    system_prompt: Option<String>,
134    run_config: agent::AgentRunConfig,
135    hooks: &mut HookRuntime,
136    trace: &mut TraceWriter,
137    otel: &mut OtelEmitter,
138    agent_task_id: Option<String>,
139    sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
140    accessible_workdir: &Path,
141    capsule_name: &str,
142    capsule_version: &str,
143    mode: ConversationMode,
144    context_id: Option<String>,
145    trace_task_id: &str,
146) -> Result<(), RuntimeError> {
147    let task_md_path = accessible_workdir.join("task.md");
148    // Original task content, captured once before any feedback is appended, so repeated
149    // reopens re-inject a fresh copy of every feedback item rather than compounding.
150    let original_task = tokio::fs::read_to_string(&task_md_path)
151        .await
152        .unwrap_or_default();
153    // Every reopen's (hook_name, reason) so far — all re-injected on each reopen.
154    let mut feedback: Vec<(String, String)> = Vec::new();
155    let mut reopens_used: u32 = 0;
156
157    loop {
158        // Reopening never grants turns past `max_turns`: hand this attempt only the turns
159        // still unspent by prior attempts of the same task. On the first attempt
160        // `task_turns()` is 0 (reset by the preceding `write_task_start`), so it gets the
161        // full ceiling.
162        let remaining_turns = inference.max_turns.saturating_sub(trace.task_turns());
163        let mut attempt_inference = inference.clone();
164        attempt_inference.max_turns = remaining_turns;
165
166        let result = agent::run_agent_loop(
167            state,
168            workdir,
169            &attempt_inference,
170            system_prompt.clone(),
171            run_config.clone(),
172            hooks,
173            trace,
174            otel,
175            agent_task_id.clone(),
176            sse.clone(),
177            accessible_workdir,
178            capsule_name,
179            capsule_version,
180            mode.clone(),
181            context_id.clone(),
182        )
183        .await;
184
185        let exit_str = if result.is_ok() { "ok" } else { "failed" };
186
187        // Let the `on-task-end` hooks inspect this attempt and decide whether to reopen.
188        let reopen = hooks
189            .dispatch_task_end(trace_task_id.to_string(), exit_str.to_string())
190            .await;
191
192        match reopen {
193            Some(TaskReopen { hook_name, reason }) => {
194                // A hook wants more. Honor it only if a reopen remains in the budget AND
195                // turns remain under the ceiling; otherwise the request is exhausted and
196                // ends the task non-silently.
197                let budget_ok = reopens_used < inference.max_task_reopens;
198                let turns_ok = trace.task_turns() < inference.max_turns;
199                if budget_ok && turns_ok {
200                    reopens_used += 1;
201                    feedback.push((hook_name.clone(), reason.clone()));
202                    let _ = trace
203                        .write_task_reopened(trace_task_id, &hook_name, &reason, reopens_used)
204                        .await;
205                    // Rewrite task.md as original + all feedback so far; the resumed
206                    // attempt picks it up through its normal `read_task`, so neither
207                    // transport's message-building code needs to change.
208                    let rewritten = build_reopen_task_md(&original_task, &feedback);
209                    if let Err(e) = tokio::fs::write(&task_md_path, rewritten.as_bytes()).await {
210                        eprintln!(
211                            "[capsule-runtime] failed to inject reopen feedback into task.md: {e}"
212                        );
213                    }
214                    continue;
215                }
216                // Budget or turn ceiling reached while a hook still wanted to reopen: end
217                // the task as a distinct, non-silent failure rather than an ordinary
218                // completion. `Err` keeps every existing `.is_err()` downstream branch.
219                let _ = trace
220                    .write_task_end(trace_task_id, "reopen_budget_exhausted", reopens_used)
221                    .await;
222                return Err(RuntimeError::AgentLoopFailed(format!(
223                    "task reopen budget exhausted after {reopens_used} reopen(s): hook \
224                     '{hook_name}' still requested another reopen"
225                )));
226            }
227            None => {
228                // No hook asked to reopen — this attempt is terminal.
229                let _ = trace
230                    .write_task_end(trace_task_id, exit_str, reopens_used)
231                    .await;
232                return result;
233            }
234        }
235    }
236}
237
238/// Compose the reopened task's `task.md`: the original task content followed by a clearly
239/// delimited feedback section for every reopen so far, each naming the hook that produced
240/// it. Used by [`run_task_with_reopens`].
241fn build_reopen_task_md(original: &str, feedback: &[(String, String)]) -> String {
242    let mut out = original.trim_end().to_string();
243    out.push_str(
244        "\n\n---\n\n# Reopen feedback\n\nThe previous attempt was not accepted. Address the \
245         following feedback, then continue.\n",
246    );
247    for (i, (hook_name, reason)) in feedback.iter().enumerate() {
248        out.push_str(&format!(
249            "\n## Reopen {} — from hook `{}`\n\n{}\n",
250            i + 1,
251            hook_name,
252            reason.trim()
253        ));
254    }
255    out
256}
257
258/// Live-delivery buffer: only needs to cover the lag between fastest and slowest
259/// currently-connected reader.
260const SSE_BROADCAST_CAPACITY: usize = 128;
261
262/// Replay buffer: covers observers joining mid-task. Sized to handle the longest
263/// expected task turn without eviction. Can be tuned independently of broadcast capacity.
264/// Candidate for `LifecycleConfig` if operator tuning is needed in a future slice.
265const SSE_REPLAY_CAPACITY: usize = 512;
266
267/// Whether any staged hook can receive the `on-compaction` lifecycle event. Mirrors the
268/// binding match the runtime uses when it actually dispatches compaction, so MURMUR.md's
269/// "compaction configured" status reflects the real dispatch path rather than an artifact name.
270fn has_compaction_hook(hooks: &[StagedHookArtifact]) -> bool {
271    hooks.iter().any(|h| {
272        matches!(
273            h.config.binding,
274            HookBinding::OnCompaction | HookBinding::All
275        )
276    })
277}
278
279/// Resolves and verifies all artifacts, compiles components, and prepares session state.
280///
281/// This function is intentionally separate from `launch_session` so policy/capability
282/// checks can be layered in the seam between stage and launch without mixing concerns.
283pub fn stage_session(
284    registry: Arc<dyn Registry>,
285    request: StageRequest,
286) -> Result<StagedSession, RuntimeError> {
287    validate_capability_policy(&request.capability_policy)?;
288    // Before any registry pull, component compile or workdir creation: if this host cannot
289    // back the declared floor, refuse rather than launch something weaker than was asked for.
290    // `achieved` comes from a live kernel probe only — the manifest never gets a vote in what
291    // the host is reported to provide.
292    // `achieved` is the host's live kernel probe, capped by the one manifest property that can
293    // lower it: `capabilities.filesystem.workdir_exec`. The manifest still gets no vote in what the
294    // host is *reported* to provide — the cap can only ever subtract (see
295    // `containment::achieved_containment_class`).
296    let workdir_exec = request.capability_policy.workdir_exec_allowed;
297    // Both host probes happen exactly once per session, right here, and every later consumer —
298    // the refusal below and the `ScopeReport` recorded in the trace — reads these two bindings.
299    // A second probe could read differently from the first (an AppArmor profile loaded, a
300    // container's capabilities changed), and the trace would then describe a session that never
301    // ran under what it claims.
302    let enforcement_tier = sandbox::detect_enforcement_tier();
303    // Probed only so the refusal can name *which* part of the sealed mechanism is missing —
304    // the AppArmor profile, `CAP_SYS_ADMIN` inside a container, or the kernel itself.
305    let sealed_blocker = crate::containment::detect_sealed_blocker();
306    let achieved_containment = achieved_containment_class(enforcement_tier, workdir_exec);
307    check_containment_floor(
308        request.declared_containment_floor,
309        achieved_containment,
310        sealed_blocker,
311        workdir_exec,
312    )?;
313    // The complete grant set this session is about to run under, in exactly the shape
314    // `mur run --explain-scope --json` prints — same builder, same policy, same declared floor,
315    // and the same two probes taken above. Computed once here rather than at trace-open time so
316    // the record cannot drift from the decision that let the session start.
317    let scope_report = crate::containment::scope_report_for_tier(
318        &request.capability_policy,
319        request.declared_containment_floor,
320        enforcement_tier,
321        sealed_blocker,
322    );
323    // A second, independent floor question, deliberately asked right here next to the first: not
324    // "can this host back what was declared?" but "did the capsule declare enough for what it
325    // asks for?". A `staged_runtime` grant needs a composed root to be staged into, and one is
326    // only built for a capsule that declared `sealed` — so this refuses on the declared floor
327    // alone and never consults the host probe above.
328    crate::staged_runtime::check_staged_runtime_floor(
329        &request.capability_policy.shell_staged_runtime,
330        request.declared_containment_floor,
331    )?;
332    // The same question as the line above, asked of the other half of the same gap. That one
333    // catches a grant declared at too low a floor; this one catches a `sealed` capsule that
334    // declared no grant at all for a `shell.allow` entry that provably needs one — a `#!` script,
335    // whose ELF/DT_NEEDED closure is empty, so the staging that makes an ELF binary work stages
336    // nothing at all of what the script imports. Same declared-floor-only gating, same
337    // pre-registry-pull position, same "name every offender once" refusal shape.
338    crate::reachability::check_interpreted_entrypoints_reachable(
339        &request.capability_policy,
340        request.declared_containment_floor,
341    )?;
342    // A third refusal, in the same pre-staging seam and for the same fail-closed reason, but
343    // about a mechanism that sits outside the containment ladder entirely: a capsule that can
344    // spawn a native subprocess needs a network namespace to put it in, because that namespace —
345    // not a syscall filter — is now what makes `capabilities.network.allow` mean anything for
346    // that subprocess. See `RuntimeError::EgressNamespaceUnavailable` for why this refuses even
347    // when the allowlist is empty.
348    //
349    // Narrower than `cgroup::requires_process_bounding` on purpose, not by oversight: that check
350    // also fires for a capsule with a *native-implementation artifact* and neither `shell.allow`
351    // nor `spawn.allow`, but `has_native_artifact` comes from `staged.installed_artifacts`, which
352    // only exists after staging resolves the registry — i.e. after this very check has to have
353    // already run, per the "before any registry pull" rule above. A native-artifact-only capsule
354    // therefore does not get this clean refusal on a host that cannot build the namespace; it
355    // instead surfaces as a raw `io::Error` out of `create_capsule_netns`'s `pre_exec` failure
356    // when that artifact is actually launched.
357    crate::network_namespace::check_egress_namespace(
358        !request.capability_policy.shell_allow.is_empty()
359            || !request.capability_policy.spawn_allow.is_empty(),
360        crate::network_namespace::detect_egress_namespace_blocker(),
361    )?;
362    // Capsule-ceiling-level, not per-artifact: `interpreter_runtime` lives on the capsule's own
363    // top-level `capabilities.shell`, so warn here (before the per-artifact staging loop) rather
364    // than in `stage_artifact_grant`.
365    warn_on_interpreter_runtime_grants(&request.capability_policy.shell_interpreter_runtime);
366    // Same seam, same reason: a capsule-wide declaration whose cost the operator should see stated
367    // once, before anything else happens. Ordered after the refusals above so a manifest that is
368    // going to be rejected outright is not first warned about.
369    warn_on_workdir_exec(workdir_exec);
370    // The non-fatal half of the reachability check above. A compiler driver's helper binaries
371    // (`cc1`, `as`, `ld`, `collect2`) are exec'd by the driver itself and sit outside its own
372    // DT_NEEDED closure, inside the fixed sealed tree that is deliberately bound without the
373    // Landlock Execute right — so `cc` starts and the first real compile does not finish. This
374    // warns rather than refuses because the probe behind it is a heuristic about one driver
375    // family; see `reachability::warn_on_unreachable_toolchain_helpers`, which prints each
376    // `W-SEC-012` line itself so `mur doctor` and this call site cannot state it differently.
377    crate::reachability::warn_on_unreachable_toolchain_helpers(
378        &request.capability_policy,
379        request.declared_containment_floor,
380    );
381
382    let engine = build_engine()?;
383    // Start ticking before the first guest runs: `dispatch_stage` below invokes on-stage
384    // hooks, which are already subject to the epoch deadline.
385    let epoch_ticker = EpochTicker::spawn(&engine);
386    let lock_expectations = request.lock_expectations.map(|entries| {
387        entries
388            .into_iter()
389            .map(|entry| (entry.name.clone(), entry))
390            .collect::<HashMap<_, _>>()
391    });
392
393    let mut resolved_lock_artifacts = Vec::with_capacity(request.artifacts.len());
394    let mut installed_artifacts = Vec::with_capacity(request.artifacts.len());
395    let mut installed_manifests = Vec::with_capacity(request.artifacts.len());
396    let mut tool_components = HashMap::with_capacity(request.artifacts.len());
397    // Only tools/drivers that declare a `capabilities:` block get an entry; everything else
398    // stays absent and therefore runs on the unclamped ceiling.
399    let mut artifact_grants: HashMap<String, ToolCapabilityGrant> = HashMap::new();
400    let mut hook_components = Vec::new();
401    // The ceiling every per-artifact network grant is clamped against. Re-parsed here rather
402    // than at launch because narrowing is lowered at staging time; `validate_capability_policy`
403    // above already proved these entries parse.
404    let ceiling_network_allow_rules =
405        parse_network_allow_rules(&request.capability_policy.network_allow)?;
406    // (name, binary_bytes) for native tool artifacts — installed after workdir creation
407    let mut native_binaries: Vec<(String, Vec<u8>)> = Vec::new();
408    // (name, skill_md_bytes) for skill artifacts — installed after workdir creation
409    let mut skill_files: Vec<(String, Vec<u8>)> = Vec::new();
410
411    for artifact in &request.artifacts {
412        // Local-source skill: resolve skill.md directly from the filesystem and skip the
413        // registry/lock pipeline entirely. Validated as skill-only at manifest parse time.
414        if let Some(source) = &artifact.source {
415            let skill_md = load_local_skill_md(&request.manifest_dir, source)?;
416            skill_files.push((artifact.name.clone(), skill_md));
417            installed_artifacts.push(InstalledArtifactSummary {
418                name: artifact.name.clone(),
419                version: artifact.version.clone(),
420                runtime: artifact.runtime.clone(),
421                implementation: None,
422            });
423            continue;
424        }
425
426        let (resolved_version, expected_hash) = match &lock_expectations {
427            Some(expected_by_name) => {
428                let expected = expected_by_name.get(&artifact.name).ok_or_else(|| {
429                    RuntimeError::LockMissingEntry {
430                        name: artifact.name.clone(),
431                    }
432                })?;
433
434                if artifact.version != expected.resolved_version {
435                    return Err(RuntimeError::LockVersionMismatch {
436                        name: artifact.name.clone(),
437                        requested: artifact.version.clone(),
438                        pinned: expected.resolved_version.clone(),
439                    });
440                }
441
442                (
443                    expected.resolved_version.clone(),
444                    Some(expected.sha256_wasm.clone()),
445                )
446            }
447            None => (artifact.version.clone(), None),
448        };
449
450        // Always pass current_platform(). LocalRegistry and RemoteRegistry both implement a
451        // fallback: if no platform-specific file exists they return the generic file. This
452        // means WASM artifacts (which have no platform file) resolve transparently, while
453        // native artifacts get their correct platform variant. Nexus must preserve this
454        // fallback behaviour — if it ever becomes strict (error on no platform match), WASM
455        // resolution will break.
456        let resolved = registry
457            .resolve_with_platform(&artifact.name, &resolved_version, Some(current_platform()))
458            .map_err(|err| map_registry_error(&artifact.name, &resolved_version, err))?;
459
460        verify_sha256(
461            &artifact.name,
462            &resolved_version,
463            &resolved.bytes,
464            &resolved.sha256,
465        )
466        .map_err(|_| RuntimeError::artifact_integrity_failed(&artifact.name, &resolved_version))?;
467
468        if let Some(expected) = expected_hash {
469            if resolved.sha256 != expected {
470                return Err(RuntimeError::artifact_integrity_failed(
471                    &artifact.name,
472                    &resolved_version,
473                ));
474            }
475        }
476
477        let manifest_yaml =
478            extract_manifest_yaml(&artifact.name, &resolved_version, &resolved.bytes)?;
479
480        installed_manifests.push((artifact.name.clone(), manifest_yaml.clone()));
481
482        let mut artifact_implementation: Option<ArtifactImplementation> = None;
483
484        match artifact.runtime {
485            ArtifactRuntime::Tool => {
486                let implementation = parse_tool_implementation_from_yaml(&manifest_yaml);
487                artifact_implementation = Some(implementation.clone());
488                match implementation {
489                    ArtifactImplementation::Native => {
490                        // A native tool is a host subprocess, not a WASI guest: it never
491                        // reaches `invoke_tool_component`, so nothing would apply a
492                        // per-artifact grant to it. Say so rather than let the block read
493                        // as enforced.
494                        warn_on_unenforceable_native_capabilities(
495                            &artifact.name,
496                            artifact.capabilities.as_ref(),
497                        );
498                        let binary = extract_native_binary(
499                            &artifact.name,
500                            &resolved_version,
501                            &resolved.bytes,
502                        )?;
503                        native_binaries.push((artifact.name.clone(), binary));
504                    }
505                    ArtifactImplementation::Wasm => {
506                        stage_artifact_grant(
507                            artifact,
508                            &ceiling_network_allow_rules,
509                            &mut artifact_grants,
510                        )?;
511                        let tool_wasm =
512                            extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
513                        let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
514                            RuntimeError::ToolComponentCompile {
515                                name: artifact.name.clone(),
516                                version: resolved_version.clone(),
517                                message: err.to_string(),
518                            }
519                        })?;
520                        tool_components.insert(artifact.name.clone(), tool_component);
521                    }
522                }
523            }
524            ArtifactRuntime::Driver => {
525                // Same call as the WASM-tool arm above, and deliberately so: a driver is
526                // staged into `tool_components` and dispatched through
527                // `invoke_tool_component` like any tool, so narrowing needs no
528                // driver-specific enforcement anywhere downstream.
529                stage_artifact_grant(artifact, &ceiling_network_allow_rules, &mut artifact_grants)?;
530                let tool_wasm =
531                    extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
532                let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
533                    RuntimeError::ToolComponentCompile {
534                        name: artifact.name.clone(),
535                        version: resolved_version.clone(),
536                        message: err.to_string(),
537                    }
538                })?;
539                tool_components.insert(artifact.name.clone(), tool_component);
540            }
541            ArtifactRuntime::Hook => {
542                let hook_config = parse_hook_config_from_yaml(&manifest_yaml).map_err(|e| {
543                    RuntimeError::Runtime(format!(
544                        "hook {}@{} invalid config: {e}",
545                        artifact.name, resolved_version
546                    ))
547                })?;
548                let hook_wasm =
549                    extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
550                let hook_component = Component::new(&engine, hook_wasm).map_err(|err| {
551                    RuntimeError::ToolComponentCompile {
552                        name: artifact.name.clone(),
553                        version: resolved_version.clone(),
554                        message: err.to_string(),
555                    }
556                })?;
557                // The grant comes from `artifact` — the operator's own manifest entry for
558                // this hook — and never from `manifest_yaml`, the hook's bundled manifest
559                // parsed just above for its behavioral contract. A hook pulled from a
560                // registry therefore cannot widen what the host lets it do. Deriving here
561                // (rather than at instantiation) means a malformed grant fails staging,
562                // before any hook component runs.
563                let grant = HookCapabilityGrant::derive(artifact.capabilities.as_ref())?;
564                warn_on_inert_hook_capabilities(&artifact.name, artifact.capabilities.as_ref());
565                hook_components.push(StagedHookArtifact {
566                    name: artifact.name.clone(),
567                    version: resolved_version.clone(),
568                    component: hook_component,
569                    config: hook_config,
570                    grant,
571                    // Operator-sourced like `grant`, for the same reason: how much of the
572                    // agent's telemetry may be dropped to keep the loop moving is the
573                    // operator's call, not the hook author's.
574                    on_overflow: artifact.on_overflow,
575                });
576            }
577            ArtifactRuntime::Skill => {
578                let skill_md =
579                    extract_skill_md(&artifact.name, &resolved_version, &resolved.bytes)?;
580                skill_files.push((artifact.name.clone(), skill_md));
581            }
582        }
583
584        resolved_lock_artifacts.push(ResolvedLockArtifact {
585            name: artifact.name.clone(),
586            resolved_version: resolved_version.clone(),
587            sha256_wasm: resolved.sha256,
588        });
589        installed_artifacts.push(InstalledArtifactSummary {
590            name: artifact.name.clone(),
591            version: resolved_version,
592            runtime: artifact.runtime.clone(),
593            implementation: artifact_implementation,
594        });
595    }
596
597    // For agent capsules (inference configured, empty WASM bytes) skip component compilation.
598    // For script capsules, compile the WASM component.
599    let capsule_component =
600        if request.inference.is_some() && request.capsule_component_bytes.is_empty() {
601            None
602        } else if request.capsule_component_bytes.is_empty() {
603            return Err(RuntimeError::CapsuleCompile(
604                "capsule component bytes are required for non-agent capsules".to_string(),
605            ));
606        } else {
607            Some(
608                Component::new(&engine, &request.capsule_component_bytes)
609                    .map_err(|err| RuntimeError::CapsuleCompile(err.to_string()))?,
610            )
611        };
612
613    let session_id = generate_session_id();
614    let (workdir, accessible_workdir) = if let Some(ref user_dir) = request.workdir {
615        let user_dir = if user_dir.is_absolute() {
616            user_dir.clone()
617        } else {
618            std::env::current_dir()
619                .map_err(|e| RuntimeError::Runtime(format!("failed to get cwd: {e}")))?
620                .join(user_dir)
621        };
622        if !user_dir.exists() || !user_dir.is_dir() {
623            return Err(RuntimeError::Runtime(format!(
624                "workdir '{}' does not exist or is not a directory",
625                user_dir.display()
626            )));
627        }
628        let session_dir = user_dir.join(".murmur").join(&session_id);
629        (session_dir, user_dir)
630    } else {
631        let dir = request.manifest_dir.join("workdir").join(&session_id);
632        let accessible = dir.clone();
633        (dir, accessible)
634    };
635    fs::create_dir_all(workdir.join("tools")).map_err(|source| RuntimeError::CreateWorkdir {
636        path: workdir.display().to_string(),
637        source,
638    })?;
639
640    for (name, manifest_yaml) in installed_manifests {
641        write_tool_manifest(&workdir, &name, &manifest_yaml)?;
642    }
643
644    install_native_binaries(&workdir, native_binaries)?;
645    install_skill_files(&workdir, skill_files)?;
646
647    // Write generic manifests for any shell binary not already covered by a custom manifest.
648    write_shell_tool_manifests(&workdir, &request.capability_policy.shell_allow)?;
649
650    // Dispatch on-stage hooks synchronously now that manifests are in place.
651    let stage_env = HookEnvVars::default();
652    dispatch_stage(
653        &engine,
654        &workdir,
655        &hook_components,
656        request.capability_policy.shell_allow.clone(),
657        &stage_env,
658        request.capability_policy.hook_limits(),
659    )?;
660
661    // Write MURMUR.md now for agent sessions so that tooling that inspects the workdir after
662    // staging (e.g. tests, debuggers) can read it. launch_session overwrites this file after
663    // port binding with the complete identity including capsule_url.
664    if let Some(ref inference) = request.inference {
665        let partial_identity = CapsuleIdentity {
666            capsule_name: request.capsule_name.clone(),
667            capsule_version: request.capsule_version.clone(),
668            session_id: session_id.clone(),
669            capsule_url: String::new(),
670        };
671        murmur_md::write_murmur_md(
672            &workdir,
673            Some(inference),
674            request.context.as_ref(),
675            has_compaction_hook(&hook_components),
676            &request.capability_policy,
677            &partial_identity,
678        );
679    }
680
681    // When --workdir is used, copy the project manifest to the accessible workdir so the agent
682    // can reference it by relative path from ".".
683    if request.workdir.is_some() {
684        let src = request.manifest_dir.join(MANIFEST_FILENAME);
685        let dst = accessible_workdir.join(MANIFEST_FILENAME);
686        if src.exists() && !dst.exists() {
687            if let Err(e) = fs::copy(&src, &dst) {
688                eprintln!(
689                    "[capsule-runtime] warning: failed to copy {MANIFEST_FILENAME} to workdir: {e}"
690                );
691            }
692        }
693    }
694
695    // Resolve lifecycle: manifest config + any override
696    let lifecycle = resolve_lifecycle(request.lifecycle, request.lifecycle_override.as_ref());
697
698    Ok(StagedSession {
699        session_id,
700        workdir,
701        accessible_workdir,
702        manifest_dir: request.manifest_dir,
703        capsule_name: request.capsule_name,
704        capsule_version: request.capsule_version,
705        capsule_url: String::new(), // set by launch_session after port binding
706        resolved_lock_artifacts,
707        installed_artifacts,
708        inference: request.inference,
709        system_prompt_overridden: request.system_prompt_overridden,
710        context: request.context,
711        engine,
712        capsule_component,
713        tool_components,
714        artifact_grants,
715        hook_components,
716        allowlisted_tools: request.allowlisted_tools,
717        // The combined floor (manifest + workspace config + `--containment`) replaces the
718        // manifest-only value the policy was built with, so every later reader — including
719        // `ShellEnforcement::resolve`, which decides whether this session installs a composed
720        // root — sees the class that was actually asked for.
721        capability_policy: CapabilityPolicy {
722            containment_floor: request.declared_containment_floor,
723            ..request.capability_policy
724        },
725        otel_endpoint: request.otel_endpoint,
726        eval_config_json: request.eval_config_json,
727        case_id: request.case_id,
728        dataset_id: request.dataset_id,
729        lifecycle,
730        trace_include_tool_output: request
731            .trace
732            .as_ref()
733            .map(|t| t.include_tool_output)
734            .unwrap_or(false),
735        bind_addr: request.bind_addr,
736        internal_port: request.internal_port,
737        declared_containment_floor: request.declared_containment_floor,
738        scope_report,
739        registry,
740        _epoch_ticker: epoch_ticker,
741    })
742}
743
744/// Instantiates the staged capsule and executes it.
745///
746/// Agent capsules (inference configured) run the built-in native Rust loop.
747/// Script capsules (WASM component present) instantiate and call `murmur:capsule/run#run()`.
748pub fn launch_session(
749    mut staged: StagedSession,
750    on_url: impl FnOnce(&str),
751) -> Result<LaunchResult, RuntimeError> {
752    let network_allow_rules = parse_network_allow_rules(&staged.capability_policy.network_allow)?;
753
754    // --- Host-process bounding, before any WASM is instantiated ------------------------------
755    //
756    // A capsule that can reach a native subprocess by any route (`shell.allow`, `spawn.allow`,
757    // or a native-implementation artifact) needs a cgroup scope around that process tree. On
758    // Linux, failing to get one is fatal here — refusing the launch is strictly better than
759    // running the tree with no aggregate memory/pids/cpu ceiling, and it must happen before
760    // instantiation so no subprocess is ever spawned unbounded. Off Linux there is no cgroup to
761    // get, so `prepare_scope` returns `None` and the gap is reported as `W-SEC-010` instead.
762    let has_native_artifact = staged.installed_artifacts.iter().any(|artifact| {
763        matches!(
764            artifact.implementation,
765            Some(murmur_artifact::ArtifactImplementation::Native)
766        )
767    });
768    let requires_process_bounding =
769        cgroup::requires_process_bounding(&staged.capability_policy, has_native_artifact);
770    let cgroup_scope = cgroup::prepare_scope(
771        requires_process_bounding,
772        &staged.capability_policy.resources,
773        &staged.session_id,
774        &staged.workdir,
775    )
776    .map_err(|reason| RuntimeError::CgroupDelegationUnavailable { reason })?;
777    let workdir_guard = Some(resources::WorkdirGuard::spawn(
778        &staged.workdir,
779        staged.capability_policy.resources.workdir_max_bytes,
780    ));
781
782    let shell_enforcement = sandbox::ShellEnforcement::resolve(
783        &staged.capability_policy,
784        staged.declared_containment_floor,
785    )
786    .map_err(RuntimeError::Runtime)?
787    .with_host_bounding(cgroup_scope, workdir_guard);
788    let inference_env = staged
789        .inference
790        .as_ref()
791        .map(inference_env_pairs)
792        .unwrap_or_default();
793
794    if let Some(ref inference) = staged.inference {
795        let workdir = staged.workdir.clone();
796        let system_prompt = resolve_system_prompt(&staged.manifest_dir, &workdir, inference)?;
797        let compaction_system_prompt =
798            resolve_compaction_system_prompt(&staged.manifest_dir, inference.compaction.as_ref())?;
799        let session_id = staged.session_id.clone();
800        let accessible_workdir = staged.accessible_workdir.clone();
801        let context_window = resolve_context_window(staged.context.as_ref());
802
803        let run_config = agent::AgentRunConfig {
804            context_window,
805            compaction_threshold: inference
806                .compaction
807                .as_ref()
808                .and_then(|c| c.threshold)
809                .unwrap_or(0.98),
810            compaction_model: inference.compaction.as_ref().and_then(|c| c.model.clone()),
811            compaction_system_prompt,
812            compaction_dump_summaries: inference
813                .compaction
814                .as_ref()
815                .and_then(|c| c.dump_summaries)
816                .unwrap_or(false),
817            max_output_tokens: inference
818                .max_tokens
819                .unwrap_or(agent::DEFAULT_MAX_OUTPUT_TOKENS),
820        };
821
822        // --- Identity and HTTP server setup ---
823
824        let rt = tokio::runtime::Builder::new_multi_thread()
825            .enable_all()
826            .build()
827            .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
828
829        let (tcp_listener, external_port) = rt.block_on(identity::bind_local_port(
830            &staged.bind_addr,
831            staged.internal_port,
832        ))?;
833
834        let capsule_url = format!("localhost:{external_port}");
835        staged.capsule_url = capsule_url.clone();
836        on_url(&capsule_url);
837
838        let capsule_identity = CapsuleIdentity {
839            capsule_name: staged.capsule_name.clone(),
840            capsule_version: staged.capsule_version.clone(),
841            session_id: session_id.clone(),
842            capsule_url: capsule_url.clone(),
843        };
844
845        // Inject identity env vars into all WASI contexts for this session.
846        let mut all_env = inference_env.clone();
847        all_env.push((
848            "MURMUR_CAPSULE_NAME".to_string(),
849            staged.capsule_name.clone(),
850        ));
851        all_env.push((
852            "MURMUR_CAPSULE_VERSION".to_string(),
853            staged.capsule_version.clone(),
854        ));
855        all_env.push(("MURMUR_SESSION_ID".to_string(), session_id.clone()));
856        all_env.push(("MURMUR_CAPSULE_URL".to_string(), capsule_url.clone()));
857
858        murmur_md::write_murmur_md(
859            &workdir,
860            Some(inference),
861            staged.context.as_ref(),
862            has_compaction_hook(&staged.hook_components),
863            &staged.capability_policy,
864            &capsule_identity,
865        );
866
867        sandbox::warn_for_enforcement_tier(
868            shell_enforcement.tier,
869            &workdir,
870            &staged.capability_policy,
871        );
872        sandbox::warn_for_missing_aggregate_bounding(
873            &workdir,
874            requires_process_bounding,
875            shell_enforcement.cgroup_scope.is_some(),
876        );
877
878        let agent_card = identity::build_agent_card(
879            &capsule_identity,
880            &staged.installed_artifacts,
881            &staged.capability_policy,
882        );
883        let agent_card_json = agent_card.to_string();
884
885        // --- Lifecycle config ---
886        let effective_lifecycle = staged.lifecycle.clone();
887        let conversation_mode = effective_lifecycle.conversation_mode.clone();
888        let queue_capacity = match effective_lifecycle.task_acceptance {
889            TaskAcceptance::Queue => effective_lifecycle.queue_depth,
890            _ => 1,
891        };
892
893        // --- A2A task registry and incoming channel ---
894        let task_registry: Arc<Mutex<TaskRegistry>> = Arc::new(Mutex::new(TaskRegistry::new(
895            effective_lifecycle.queue_depth,
896            effective_lifecycle.task_acceptance.clone(),
897        )));
898        let (task_tx, mut task_rx) = tokio::sync::mpsc::channel::<IncomingTask>(queue_capacity);
899
900        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
901
902        // SSE broadcast channel and replay buffer for SSE clients
903        let (sse_tx, _) =
904            tokio::sync::broadcast::channel::<std::sync::Arc<String>>(SSE_BROADCAST_CAPACITY);
905        let sse_buffer = std::sync::Arc::new(Mutex::new(SseEventBuffer::new(SSE_REPLAY_CAPACITY)));
906
907        let capsule_name = staged.capsule_name.clone();
908        let capabilities = capability_names(&staged.capability_policy);
909        // Computed once at stage time, not re-derived here: `session_start` records what this
910        // session ran with, and the staged report is the single place that value already lives.
911        // It also carries the declared/achieved classes and `workdir_exec` the event's own
912        // top-level fields are written from.
913        let effective_grants = staged.scope_report.clone();
914
915        // Capture staged fields that move into the async block
916        let hook_components = staged.hook_components;
917        let tool_components = staged.tool_components;
918        let artifact_grants = staged.artifact_grants;
919        let allowlisted_tools = staged.allowlisted_tools.clone();
920        let installed_artifacts = staged.installed_artifacts;
921        let engine = staged.engine.clone();
922        let capability_policy = staged.capability_policy.clone();
923        let shell_enforcement_for_state = shell_enforcement.clone();
924        let otel_endpoint = staged.otel_endpoint;
925        let eval_config_json = staged.eval_config_json;
926        let case_id = staged.case_id;
927        let dataset_id = staged.dataset_id;
928        let capsule_version = staged.capsule_version.clone();
929        let inference_model = inference.model.clone();
930        // Non-empty only for the WASM-driver transport; a `transport: process`
931        // capsule has no driver artifact, so a hook's `run-inference` correctly
932        // reports "no driver configured" rather than silently doing nothing.
933        let inference_driver_name = inference
934            .driver
935            .as_ref()
936            .map(|d| d.artifact.clone())
937            .filter(|a| !a.is_empty());
938        let trace_include_tool_output = staged.trace_include_tool_output;
939        // The trace records the *resolved* prompt — what `resolve_system_prompt` returned, before
940        // `build_augmented_system_prompt` prepends the `[Capsule]` block — so a reader compares
941        // what the manifest (or `--system-prompt`) actually said, not the runtime's framing of it.
942        let trace_system_prompt = system_prompt.clone();
943        let system_prompt_overridden = staged.system_prompt_overridden;
944        let registry_for_pull = Arc::clone(&staged.registry);
945        let lock_path_for_pull = staged.manifest_dir.join("murmur.lock");
946
947        // task.md must live where the agent's own tools are preopened (accessible_workdir),
948        // not the internal `.murmur/<session_id>` bookkeeping dir (workdir) — otherwise this
949        // pre-seed check misses a `--task`-written file and the agent's own read of task.md
950        // 404s even after a task was delivered.
951        let workdir_task_md = accessible_workdir.join("task.md");
952
953        // Extra copies retained for the LaunchResult returned after block_on consumes the others.
954        let session_id_ret = session_id.clone();
955        let workdir_ret = workdir.clone();
956
957        // --- Agent loop inside a LocalSet (handles !Send WasiCtx) ---
958        let loop_result: Result<(), RuntimeError> = rt.block_on(async move {
959            let mut trace = TraceWriter::open(
960                &workdir,
961                session_id.clone(),
962                capsule_name.clone(),
963                capsule_version.clone(),
964                inference_model.clone(),
965                capabilities.clone(),
966                effective_grants,
967                trace_include_tool_output,
968                trace_system_prompt,
969                system_prompt_overridden,
970            )
971            .await
972            .map_err(|e| RuntimeError::AgentLoopFailed(format!("failed to open trace.jsonl: {e}")))?;
973
974            let mut otel = OtelEmitter::new(
975                otel_endpoint.clone(),
976                &workdir,
977                capsule_name.clone(),
978                capsule_version.clone(),
979            );
980
981            let local = tokio::task::LocalSet::new();
982            local
983                .run_until(async move {
984                    // Spawn HTTP server as a local task (uses spawn_local for !Send compat)
985                    let server_handle =
986                        tokio::task::spawn_local(identity::serve_http(
987                            tcp_listener,
988                            shutdown_rx,
989                            agent_card_json,
990                            Arc::clone(&task_registry),
991                            task_tx,
992                            effective_lifecycle.task_acceptance.clone(),
993                            sse_tx.clone(),
994                            Arc::clone(&sse_buffer),
995                            conversation_mode.clone(),
996                        ));
997
998                    // Read before `capability_policy` moves into the store state below. Hooks
999                    // get the same resource caps as every other guest but their own, lower
1000                    // deadline default — see `CapabilityPolicy::hook_limits`.
1001                    let hook_limits = capability_policy.hook_limits();
1002
1003                    // Build CapsuleStoreState ONCE — reused across all task iterations
1004                    let mut state = CapsuleStoreState {
1005                        // Agent capsules have no WASM component of their own, so this state
1006                        // never backs a `Store` and this limiter is never registered. It is
1007                        // the per-tool limiters built in `dispatch_tool_async` (from
1008                        // `capability_policy.limits`) that bound this path's guests.
1009                        limits: capability_policy.limits.limiter(),
1010                        table: ResourceTable::new(),
1011                        // The capsule's own store is the ceiling itself, never narrowed:
1012                        // per-artifact grants apply to staged tools/drivers, not to the
1013                        // agent loop's own context.
1014                        wasi: build_wasi_ctx(
1015                            &accessible_workdir,
1016                            None,
1017                            &all_env,
1018                            &capability_policy,
1019                        )?,
1020                        http: WasiHttpCtx::new(),
1021                        http_hooks: NetworkPolicyHooks {
1022                            network_allow_rules: network_allow_rules.clone(),
1023                        },
1024                        network_allow_rules,
1025                        inference_env: all_env,
1026                        engine: engine.clone(),
1027                        workdir: workdir.clone(),
1028                        accessible_workdir: accessible_workdir.clone(),
1029                        tool_components,
1030                        artifact_grants,
1031                        allowlisted_tools,
1032                        installed_artifacts,
1033                        session_id: session_id.clone(),
1034                        pending_a2a_events: Vec::new(),
1035                        capability_policy,
1036                        shell_enforcement: shell_enforcement_for_state,
1037                        current_traceparent: None,
1038                        a2a_task_registry: Some(Arc::clone(&task_registry)),
1039                        a2a_sse: Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
1040                        a2a_task_id: None,
1041                        input_timeout_secs: effective_lifecycle.input_timeout_secs,
1042                        a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
1043                        a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
1044                        registry: registry_for_pull,
1045                        lock_path: lock_path_for_pull,
1046                        driver_continuation_id: None,
1047                        driver_continuation_context_id: None,
1048                        driver_continuation_acked_len: 0,
1049                    };
1050
1051                    // Backing for the hooks' `murmur:runtime/inference` import.
1052                    // Sourced from `state` (built directly above) so a hook's
1053                    // `run-inference` runs the *same* driver component, under the
1054                    // same capability policy and network allowlist, as the agent
1055                    // loop's own turns. `None` when no driver artifact is staged.
1056                    let hook_inference = inference_driver_name
1057                        .as_ref()
1058                        .and_then(|driver_name| {
1059                            state
1060                                .tool_components
1061                                .get(driver_name)
1062                                .map(|component| (driver_name.clone(), component.clone()))
1063                        })
1064                        .map(|(driver_name, driver_component)| {
1065                            // Same grant `dispatch_tool_async` would apply to this driver, so
1066                            // a hook's `run-inference` cannot route around its narrowing.
1067                            let driver_grant = state.artifact_grants.get(&driver_name).cloned();
1068                            Arc::new(HookInferenceCtx {
1069                                driver_name,
1070                                driver_component,
1071                                model: inference_model.clone(),
1072                                engine: state.engine.clone(),
1073                                accessible_workdir: state.accessible_workdir.clone(),
1074                                inference_env: state.inference_env.clone(),
1075                                capability_policy: state.capability_policy.clone(),
1076                                network_allow_rules: state.network_allow_rules.clone(),
1077                                driver_grant,
1078                                records: std::sync::Mutex::new(Vec::new()),
1079                            })
1080                        });
1081
1082                    // Create hooks ONCE — session_start fires once per capsule lifetime
1083                    let mut hooks = HookRuntime::new(
1084                        &engine,
1085                        &workdir,
1086                        &accessible_workdir,
1087                        hook_components,
1088                        SessionContextData {
1089                            capsule_name: capsule_name.clone(),
1090                            capsule_version: capsule_version.clone(),
1091                            session_id: session_id.clone(),
1092                            model: inference_model.clone(),
1093                            capabilities: capabilities.clone(),
1094                        },
1095                        HookEnvVars {
1096                            otel_endpoint: otel_endpoint.as_deref(),
1097                            eval_config_json: eval_config_json.as_deref(),
1098                            case_id: case_id.as_deref(),
1099                            dataset_id: dataset_id.as_deref(),
1100                        },
1101                        hook_limits,
1102                        hook_inference,
1103                    )
1104                    .await?;
1105
1106                    // on-session-start fires ONCE per launch, before the task loop —
1107                    // regardless of task_acceptance. For queue capsules this is the single
1108                    // session boundary that the per-task on-task-start events nest inside.
1109                    hooks.emit(&workdir, HookEvent::SessionStart).await;
1110
1111                    let final_loop_result: Result<(), RuntimeError>;
1112
1113                    // ── LOOP BODY STARTS HERE ──────────────────────────────
1114                    // Each iteration processes one task. Single/none modes break after
1115                    // the first iteration; queue+sleep iterates until channel closes.
1116                    'task_loop: loop {
1117                        // ── WAIT FOR NEXT TASK ──
1118                        let incoming: IncomingTask = match effective_lifecycle.task_acceptance {
1119                            TaskAcceptance::None => {
1120                                // Does not accept incoming tasks; run from task.md if present
1121                                if workdir_task_md.exists() {
1122                                    let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
1123                                    let context_id = format!("ctx_{}", uuid::Uuid::now_v7().simple());
1124                                    let bytes = tokio::fs::metadata(&workdir_task_md)
1125                                        .await
1126                                        .map(|m| m.len())
1127                                        .unwrap_or(0);
1128                                    let _ = trace
1129                                        .write_task_start(
1130                                            &task_id, &context_id, "task_md", bytes,
1131                                        )
1132                                        .await;
1133                                    hooks
1134                                        .emit(
1135                                            &workdir,
1136                                            HookEvent::TaskStart {
1137                                                task_id: task_id.clone(),
1138                                                context_id: context_id.clone(),
1139                                                source: "task_md".to_string(),
1140                                                input_bytes: bytes,
1141                                            },
1142                                        )
1143                                        .await;
1144                                    otel.begin_session(None);
1145                                    state.current_traceparent = otel.outgoing_traceparent();
1146                                    // run_task_with_reopens fires on-task-end, honors any
1147                                    // reopen-task within budget, and writes the terminal
1148                                    // task_end (with reopen_count) itself.
1149                                    let result = run_task_with_reopens(
1150                                        &mut state,
1151                                        &workdir,
1152                                        inference,
1153                                        system_prompt,
1154                                        run_config,
1155                                        &mut hooks,
1156                                        &mut trace,
1157                                        &mut otel,
1158                                        None,
1159                                        None,
1160                                        &accessible_workdir,
1161                                        &capsule_name,
1162                                        &capsule_version,
1163                                        conversation_mode.clone(),
1164                                        Some(context_id.clone()),
1165                                        &task_id,
1166                                    )
1167                                    .await;
1168                                    final_loop_result = result;
1169                                    break 'task_loop;
1170                                } else {
1171                                    final_loop_result = Ok(());
1172                                    break 'task_loop;
1173                                }
1174                            }
1175                            TaskAcceptance::Single | TaskAcceptance::Queue => {
1176                                if workdir_task_md.exists() {
1177                                    // Backward compat: existing task.md → single run, no A2A
1178                                    let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
1179                                    let context_id = format!("ctx_{}", uuid::Uuid::now_v7().simple());
1180                                    let bytes = tokio::fs::metadata(&workdir_task_md)
1181                                        .await
1182                                        .map(|m| m.len())
1183                                        .unwrap_or(0);
1184                                    let _ = trace
1185                                        .write_task_start(
1186                                            &task_id, &context_id, "task_md", bytes,
1187                                        )
1188                                        .await;
1189                                    hooks
1190                                        .emit(
1191                                            &workdir,
1192                                            HookEvent::TaskStart {
1193                                                task_id: task_id.clone(),
1194                                                context_id: context_id.clone(),
1195                                                source: "task_md".to_string(),
1196                                                input_bytes: bytes,
1197                                            },
1198                                        )
1199                                        .await;
1200                                    otel.begin_session(None);
1201                                    state.current_traceparent = otel.outgoing_traceparent();
1202                                    let result = run_task_with_reopens(
1203                                        &mut state,
1204                                        &workdir,
1205                                        inference,
1206                                        system_prompt.clone(),
1207                                        run_config.clone(),
1208                                        &mut hooks,
1209                                        &mut trace,
1210                                        &mut otel,
1211                                        None,
1212                                        None,
1213                                        &accessible_workdir,
1214                                        &capsule_name,
1215                                        &capsule_version,
1216                                        conversation_mode.clone(),
1217                                        Some(context_id.clone()),
1218                                        &task_id,
1219                                    )
1220                                    .await;
1221                                    let _ = trace.flush().await;
1222                                    if matches!(effective_lifecycle.task_acceptance, TaskAcceptance::Single) || result.is_err() {
1223                                        final_loop_result = result;
1224                                        break 'task_loop;
1225                                    }
1226                                    // Queue mode: remove task.md so the next iteration
1227                                    // falls through to task_rx.recv() for queued subtasks.
1228                                    let _ = tokio::fs::remove_file(&workdir_task_md).await;
1229                                    continue 'task_loop;
1230                                }
1231                                // Wait for the next task from the mpsc channel.
1232                                // queue+sleep mode waits indefinitely — no self-terminating
1233                                // timeout. The host (mur-roost) is responsible for shutdown.
1234                                // All other modes apply MURMUR_A2A_TIMEOUT_SECS (default 30 s).
1235                                let is_queue_sleep =
1236                                    matches!(effective_lifecycle.task_acceptance, TaskAcceptance::Queue)
1237                                    && matches!(effective_lifecycle.after_task, AfterTask::Sleep);
1238
1239                                if is_queue_sleep {
1240                                    match task_rx.recv().await {
1241                                        Some(task) => task,
1242                                        None => {
1243                                            final_loop_result = Ok(());
1244                                            break 'task_loop;
1245                                        }
1246                                    }
1247                                } else {
1248                                    let idle_timeout_secs: u64 =
1249                                        std::env::var("MURMUR_A2A_TIMEOUT_SECS")
1250                                            .ok()
1251                                            .and_then(|v| v.parse().ok())
1252                                            .unwrap_or(30);
1253                                    match tokio::time::timeout(
1254                                        std::time::Duration::from_secs(idle_timeout_secs),
1255                                        task_rx.recv(),
1256                                    )
1257                                    .await
1258                                    {
1259                                        Ok(Some(task)) => task,
1260                                        Ok(None) => {
1261                                            final_loop_result = Ok(());
1262                                            break 'task_loop;
1263                                        }
1264                                        Err(_elapsed) => {
1265                                            eprintln!("[capsule-runtime] no A2A message received within timeout; running with empty task");
1266                                            otel.begin_session(None);
1267                                            state.current_traceparent = otel.outgoing_traceparent();
1268                                            final_loop_result = agent::run_agent_loop(
1269                                                &mut state,
1270                                                &workdir,
1271                                                inference,
1272                                                system_prompt,
1273                                                run_config,
1274                                                &mut hooks,
1275                                                &mut trace,
1276                                                &mut otel,
1277                                                None,
1278                                                None,
1279                                                &accessible_workdir,
1280                                                &capsule_name,
1281                                                &capsule_version,
1282                                                conversation_mode.clone(),
1283                                                None,
1284                                            )
1285                                            .await;
1286                                            break 'task_loop;
1287                                        }
1288                                    }
1289                                }
1290                            }
1291                        };
1292
1293                        // ── ACTIVATE TASK ──
1294                        {
1295                            let mut reg = task_registry.lock().unwrap();
1296                            reg.start_task(incoming.task_id.clone(), incoming.context_id.clone());
1297                        }
1298                        if let Err(e) =
1299                            tokio::fs::write(&workdir_task_md, &incoming.message_text).await
1300                        {
1301                            eprintln!(
1302                                "[capsule-runtime] failed to write A2A message to task.md: {e}"
1303                            );
1304                        }
1305                        let _ = trace
1306                            .write_a2a_task_received(
1307                                &incoming.task_id,
1308                                &incoming.context_id,
1309                                &incoming.message_id,
1310                                incoming.traceparent.as_deref(),
1311                            )
1312                            .await;
1313                        let _ = trace
1314                            .write_task_start(
1315                                &incoming.task_id,
1316                                &incoming.context_id,
1317                                "a2a",
1318                                incoming.message_text.len() as u64,
1319                            )
1320                            .await;
1321                        hooks
1322                            .emit(
1323                                &workdir,
1324                                HookEvent::TaskStart {
1325                                    task_id: incoming.task_id.clone(),
1326                                    context_id: incoming.context_id.clone(),
1327                                    source: "a2a".to_string(),
1328                                    input_bytes: incoming.message_text.len() as u64,
1329                                },
1330                            )
1331                            .await;
1332
1333                        // ── RUN AGENT LOOP ──
1334                        otel.begin_session(incoming.traceparent.as_deref());
1335                        state.current_traceparent = otel.outgoing_traceparent();
1336                        state.a2a_task_id = Some(incoming.task_id.clone());
1337                        let loop_result = run_task_with_reopens(
1338                            &mut state,
1339                            &workdir,
1340                            inference,
1341                            system_prompt.clone(),
1342                            run_config.clone(),
1343                            &mut hooks,
1344                            &mut trace,
1345                            &mut otel,
1346                            Some(incoming.task_id.clone()),
1347                            Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
1348                            &accessible_workdir,
1349                            &capsule_name,
1350                            &capsule_version,
1351                            conversation_mode.clone(),
1352                            Some(incoming.context_id.clone()),
1353                            &incoming.task_id,
1354                        )
1355                        .await;
1356
1357                        // ── POST-LOOP SLOT UPDATE ──
1358                        // task_end (with reopen_count) and the terminal on-task-end dispatch
1359                        // already happened inside run_task_with_reopens; an exhausted reopen
1360                        // budget surfaces here as loop_result.is_err(), i.e. a failed task.
1361                        let exit_state = if loop_result.is_ok() {
1362                            TaskState::Completed
1363                        } else {
1364                            TaskState::Failed
1365                        };
1366                        let _ = trace.flush().await;
1367                        {
1368                            let mut reg = task_registry.lock().unwrap();
1369                            reg.finish_task(exit_state);
1370                        }
1371
1372                        // ── DECIDE WHETHER TO CONTINUE ──
1373                        match effective_lifecycle.after_task {
1374                            AfterTask::Exit => {
1375                                final_loop_result = loop_result;
1376                                break 'task_loop;
1377                            }
1378                            AfterTask::Sleep => {
1379                                if matches!(
1380                                    effective_lifecycle.task_acceptance,
1381                                    TaskAcceptance::Single
1382                                ) {
1383                                    // single mode always exits after one task
1384                                    final_loop_result = loop_result;
1385                                    break 'task_loop;
1386                                }
1387                                // Queue+sleep: clear task.md and wait for next task
1388                                let _ = tokio::fs::remove_file(&workdir_task_md).await;
1389                                continue 'task_loop;
1390                            }
1391                        }
1392                    }
1393                    // ── LOOP BODY ENDS HERE ────────────────────────────────
1394
1395                    // on-session-end fires ONCE per launch, after the task loop exits.
1396                    // total_turns is the whole-launch aggregate accumulated by HookRuntime
1397                    // (one per Inference event across every task), and exit_status reflects
1398                    // the loop's final result.
1399                    let session_exit_status =
1400                        if final_loop_result.is_ok() { "ok" } else { "failed" };
1401                    let session_total_turns = hooks.total_turns();
1402                    hooks
1403                        .emit(
1404                            &workdir,
1405                            HookEvent::SessionEnd {
1406                                total_turns: session_total_turns,
1407                                exit_status: session_exit_status.to_string(),
1408                            },
1409                        )
1410                        .await;
1411
1412                    // Every async hook's queue is drained — and its worker awaited — while the
1413                    // `LocalSet` is still alive. Without this, `run_until` returning would drop
1414                    // the workers mid-call and take the session-end export with them. Bounded,
1415                    // so a wedged hook delays the exit by at most the drain budget; whatever it
1416                    // did not finish is reported through the same fault path as any other hook
1417                    // fault, which is why this runs *before* the flush below.
1418                    hooks.drain_async_hooks().await;
1419                    agent::flush_hook_dispatch_faults(&mut hooks, &mut trace).await;
1420                    let _ = trace.write_session_end_if_not_ended("failed").await;
1421                    otel.emit_session_end_if_not_ended("failed").await;
1422                    trace.flush().await.map_err(|e| {
1423                        RuntimeError::AgentLoopFailed(format!("failed to flush trace: {e}"))
1424                    })?;
1425
1426                    // Signal HTTP server to shut down, then wait for it
1427                    let _ = shutdown_tx.send(());
1428                    let _ = server_handle.await;
1429
1430                    final_loop_result
1431                })
1432                .await
1433        });
1434
1435        loop_result?;
1436
1437        return Ok(LaunchResult {
1438            session_id: session_id_ret,
1439            workdir: workdir_ret,
1440        });
1441    }
1442
1443    // Script capsule path — requires a compiled WASM component.
1444    let capsule_component = staged
1445        .capsule_component
1446        .ok_or(RuntimeError::CapsuleCompile(
1447            "non-agent capsule has no compiled component".to_string(),
1448        ))?;
1449
1450    let mut linker = Linker::new(&staged.engine);
1451    wasmtime_wasi::p2::add_to_linker_async(&mut linker)
1452        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1453    wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
1454        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1455
1456    // `murmur:tool-registry/invoke@0.1.0` is the one host-provided interface a
1457    // capsule guest imports. Register it by hand against the same `invoke::Host`
1458    // impl on `CapsuleStoreState` (the generated `invoke::add_to_linker` would do
1459    // the same, but this keeps the registration alongside the tool-side ones).
1460    {
1461        let iface = WIT_TOOL_REGISTRY_IFACE;
1462        linker
1463            .instance(iface)
1464            .map_err(|err| RuntimeError::Runtime(err.to_string()))?
1465            .func_wrap(
1466                "invoke",
1467                |mut store: wasmtime::StoreContextMut<'_, CapsuleStoreState>,
1468                 (tool_name, input): (String, murmur::tool::run::ToolInput)|
1469                 -> wasmtime::Result<(Result<murmur::tool::run::ToolResult, String>,)> {
1470                    Ok((invoke::Host::invoke(store.data_mut(), tool_name, input),))
1471                },
1472            )
1473            .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1474    }
1475    manage::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
1476        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1477    send::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
1478        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1479
1480    // Read before `staged.capability_policy` moves into the store state below.
1481    let capsule_limits = staged.capability_policy.limits;
1482
1483    let state = CapsuleStoreState {
1484        table: ResourceTable::new(),
1485        wasi: build_wasi_ctx(
1486            &staged.accessible_workdir,
1487            // The capsule component runs on the ceiling, not on any artifact's grant.
1488            None,
1489            &inference_env,
1490            &staged.capability_policy,
1491        )?,
1492        http: WasiHttpCtx::new(),
1493        http_hooks: NetworkPolicyHooks {
1494            network_allow_rules: network_allow_rules.clone(),
1495        },
1496        network_allow_rules,
1497        inference_env,
1498        engine: staged.engine.clone(),
1499        workdir: staged.workdir.clone(),
1500        accessible_workdir: staged.accessible_workdir.clone(),
1501        tool_components: staged.tool_components,
1502        artifact_grants: staged.artifact_grants,
1503        allowlisted_tools: staged.allowlisted_tools,
1504        installed_artifacts: staged.installed_artifacts,
1505        session_id: staged.session_id.clone(),
1506        pending_a2a_events: Vec::new(),
1507        capability_policy: staged.capability_policy,
1508        shell_enforcement: shell_enforcement.clone(),
1509        current_traceparent: None,
1510        a2a_task_registry: None,
1511        a2a_sse: None,
1512        a2a_task_id: None,
1513        input_timeout_secs: None,
1514        a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
1515        a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
1516        registry: Arc::clone(&staged.registry),
1517        lock_path: staged.manifest_dir.join("murmur.lock"),
1518        driver_continuation_id: None,
1519        driver_continuation_context_id: None,
1520        driver_continuation_acked_len: 0,
1521        limits: capsule_limits.limiter(),
1522    };
1523
1524    let mut store = Store::new(&staged.engine, state);
1525    // Must precede instantiation: `Store::limiter` latches the instance/table/memory counts
1526    // the store enforces, and instantiation itself allocates against them.
1527    store.limiter(|state| &mut state.limits);
1528
1529    // Script capsule uses a multi-thread runtime so block_in_place works inside send::Host::send
1530    let rt = tokio::runtime::Builder::new_multi_thread()
1531        .enable_all()
1532        .build()
1533        .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
1534
1535    rt.block_on(async {
1536        // Instantiation runs the guest's own start/init code, so it gets a deadline too.
1537        store.set_epoch_deadline(capsule_limits.deadline_ticks());
1538        let instantiated = linker
1539            .instantiate_async(&mut store, &capsule_component)
1540            .await;
1541        let instance = match instantiated {
1542            Ok(instance) => instance,
1543            Err(err) => return Err(capsule_guest_error(&err, &store.data().limits)),
1544        };
1545
1546        let capsule_iface =
1547            resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED)
1548                .ok_or(RuntimeError::CapsuleExportMissing)?;
1549        let capsule_run = instance
1550            .get_export_index(&mut store, Some(&capsule_iface), "run")
1551            .and_then(|idx| instance.get_func(&mut store, idx))
1552            .ok_or(RuntimeError::CapsuleExportMissing)?;
1553
1554        let run = capsule_run
1555            .typed::<(), ()>(&store)
1556            .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1557
1558        // Fresh budget for `run` itself, so instantiation cost cannot eat into it.
1559        store.set_epoch_deadline(capsule_limits.deadline_ticks());
1560        let called = run.call_async(&mut store, ()).await;
1561        if let Err(err) = called {
1562            return Err(capsule_guest_error(&err, &store.data().limits));
1563        }
1564
1565        Ok(())
1566    })?;
1567
1568    // Drain any buffered a2a_send trace events from the capsule run.
1569    let pending = std::mem::take(&mut store.data_mut().pending_a2a_events);
1570    if !pending.is_empty() {
1571        rt.block_on(async {
1572            if let Ok(mut trace) = TraceWriter::open(
1573                &staged.workdir,
1574                staged.session_id.clone(),
1575                staged.capsule_name.clone(),
1576                staged.capsule_version.clone(),
1577                String::new(),
1578                Vec::new(),
1579                staged.scope_report.clone(),
1580                false,
1581                None,
1582                false,
1583            )
1584            .await
1585            {
1586                for (peer_url, message_id, task_id, context_id, traceparent) in pending {
1587                    let _ = trace
1588                        .write_a2a_send(
1589                            &peer_url,
1590                            &message_id,
1591                            &task_id,
1592                            &context_id,
1593                            traceparent.as_deref(),
1594                        )
1595                        .await;
1596                }
1597                let _ = trace.flush().await;
1598            }
1599        });
1600    }
1601
1602    // Notify the caller that the capsule has started (no URL for script capsules).
1603    on_url("");
1604
1605    Ok(LaunchResult {
1606        session_id: staged.session_id,
1607        workdir: staged.workdir,
1608    })
1609}
1610
1611/// Reads a manifest-relative prompt file, returning the resolved path alongside any I/O
1612/// error so the caller can map it to its own `RuntimeError` variant.
1613fn read_prompt_file(manifest_dir: &Path, path: &str) -> Result<String, (PathBuf, std::io::Error)> {
1614    let prompt_path = manifest_dir.join(path);
1615    fs::read_to_string(&prompt_path).map_err(|source| (prompt_path, source))
1616}
1617
1618fn resolve_system_prompt(
1619    manifest_dir: &Path,
1620    workdir: &Path,
1621    inference: &murmur_artifact::InferenceConfig,
1622) -> Result<Option<String>, RuntimeError> {
1623    if let Some(prompt) = inference.system_prompt.as_ref() {
1624        return Ok(Some(prompt.clone()));
1625    }
1626
1627    if let Some(path) = inference.system_prompt_file.as_ref() {
1628        return read_prompt_file(manifest_dir, path)
1629            .map(Some)
1630            .map_err(|(prompt_path, source)| RuntimeError::SystemPromptFileRead {
1631                path: prompt_path.display().to_string(),
1632                source,
1633            });
1634    }
1635
1636    if let Some(art_name) = inference.system_prompt_artifact.as_ref() {
1637        let skill_path = workdir.join("tools").join(art_name).join("skill.md");
1638        return fs::read_to_string(&skill_path).map(Some).map_err(|source| {
1639            RuntimeError::SystemPromptArtifactRead {
1640                name: art_name.clone(),
1641                source,
1642            }
1643        });
1644    }
1645
1646    Ok(None)
1647}
1648
1649/// Resolves the compaction system prompt from the two mutually exclusive manifest
1650/// sources — the inline `inference.compaction.system_prompt` string, or the contents of
1651/// the file named by `inference.compaction.system_prompt_file`, read relative to the
1652/// manifest directory. Absence stays absence: no default prompt is substituted, and the
1653/// hook receives `option::none`.
1654///
1655/// Mutual exclusion is enforced at manifest parse time, so an inline prompt winning here
1656/// is unreachable for a manifest that loaded successfully.
1657fn resolve_compaction_system_prompt(
1658    manifest_dir: &Path,
1659    compaction: Option<&murmur_artifact::CompactionConfig>,
1660) -> Result<Option<String>, RuntimeError> {
1661    let Some(compaction) = compaction else {
1662        return Ok(None);
1663    };
1664
1665    if let Some(prompt) = compaction.system_prompt.as_ref() {
1666        return Ok(Some(prompt.clone()));
1667    }
1668
1669    if let Some(path) = compaction.system_prompt_file.as_ref() {
1670        return read_prompt_file(manifest_dir, path)
1671            .map(Some)
1672            .map_err(
1673                |(prompt_path, source)| RuntimeError::CompactionSystemPromptFileRead {
1674                    path: prompt_path.display().to_string(),
1675                    source,
1676                },
1677            );
1678    }
1679
1680    Ok(None)
1681}
1682
1683fn capability_names(policy: &CapabilityPolicy) -> Vec<String> {
1684    let mut names = Vec::new();
1685    if !policy.network_allow.is_empty() {
1686        names.push("network".to_string());
1687    }
1688    if policy.filesystem_scope.is_some() {
1689        names.push("filesystem".to_string());
1690    }
1691    if !policy.shell_allow.is_empty() {
1692        names.push("shell".to_string());
1693    }
1694    names
1695}
1696
1697/// Core message for the bash+network bypass warning (finding C-7 in
1698/// `murmur-security-assessment.md`) — shared verbatim between the stderr line and the
1699/// `logs/bootstrap.log` line so the two never drift apart.
1700const BASH_NETWORK_BYPASS_WARNING: &str = "capabilities.shell.allow includes \"bash\" and \
1701capabilities.network.allow is non-empty, but network.allow does not constrain bash's own \
1702outbound connections on this platform.";
1703
1704/// Warns (non-fatal — matches the warn-and-continue convention of `murmur_md::write_murmur_md`
1705/// and the murmur.yaml-copy warning above) when `policy.shell_allow` contains the exact binary
1706/// name `"bash"` and `policy.network_allow` is non-empty. The check is an exact match on the
1707/// literal `"bash"`, not `shell::is_shell_interpreter` (which also matches `sh`/`zsh`/`fish`/
1708/// `dash`/`ksh`) — a deliberate scope limit matching C-7's own scoping, not an oversight.
1709pub(crate) fn warn_if_bash_network_bypass(workdir: &Path, policy: &CapabilityPolicy) {
1710    let has_bash = policy.shell_allow.iter().any(|binary| binary == "bash");
1711    if has_bash && !policy.network_allow.is_empty() {
1712        let link = security_warning_link(W_SEC_003);
1713        eprintln!("[capsule-runtime] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})");
1714        agent::append_bootstrap_log(
1715            workdir,
1716            &format!(
1717                "[capability-policy] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})"
1718            ),
1719        );
1720    }
1721}
1722
1723/// Warns (non-fatal, once per declared grant) for every `capabilities.shell.interpreter_runtime`
1724/// entry. Declaring one narrows an allowlisted binary's Landlock scope to specific host
1725/// directories so a path-based interpreter can reach its stdlib — but it couples the capsule to a
1726/// specific host distro/interpreter-version layout (e.g. `/usr/lib/python3.11` stops resolving the
1727/// moment the host ships Python 3.12), which the operator should see plainly. The durable fix is
1728/// the still-unbuilt staged runtime bind-mount; this grant only bridges until then.
1729///
1730/// Shared verbatim between `mur run` (from [`stage_session`]) and `mur doctor`, so both surface
1731/// the same code, wording, and doc link. Like [`warn_on_inert_hook_capabilities`], it fires before
1732/// any session workdir exists (the capsule-ceiling check runs at the top of `stage_session`, and
1733/// `doctor` never launches a session at all), so it goes to stderr only, not `logs/bootstrap.log`.
1734pub fn warn_on_interpreter_runtime_grants(grants: &[InterpreterRuntimeGrant]) {
1735    for grant in grants {
1736        let link = security_warning_link(W_SEC_009);
1737        let dirs = grant
1738            .dirs
1739            .iter()
1740            .map(|dir| {
1741                let list = if dir.list_dir { "list_dir" } else { "no-list" };
1742                format!("{} ({list})", dir.path)
1743            })
1744            .collect::<Vec<_>>()
1745            .join(", ");
1746        eprintln!(
1747            "[capsule-runtime] warning[{W_SEC_009}]: capabilities.shell.interpreter_runtime grants \
1748             '{}' host directories outside the workdir [{dirs}] — this couples the capsule to a \
1749             specific host distro/interpreter-version layout (e.g. /usr/lib/python3.11 breaks the \
1750             moment the host ships Python 3.12); the durable fix is the staged runtime bind-mount, \
1751             which this grant only bridges until ({link})",
1752            grant.binary
1753        );
1754    }
1755}
1756
1757/// Warns (non-fatal, once per session) when a capsule declares
1758/// `capabilities.filesystem.workdir_exec: true`.
1759///
1760/// This is the one grant that trades away an enforcement property rather than widening a scope:
1761/// with the workdir's Landlock `Execute` right granted, `capabilities.shell.allow` stops being
1762/// something the kernel can hold the capsule to — a binary it compiles, downloads or renames inside
1763/// its own workdir runs regardless. The declaration is legitimate (compile-and-run workflows need
1764/// it) and the class report already says `advisory`, but a class in a JSON field is easy to miss
1765/// and the reason for it is not self-evident, so it is also stated in words, once, at staging.
1766///
1767/// Shared shape with [`warn_on_interpreter_runtime_grants`]: fires before any session workdir
1768/// exists, so it goes to stderr only, not `logs/bootstrap.log`.
1769pub fn warn_on_workdir_exec(workdir_exec: bool) {
1770    if !workdir_exec {
1771        return;
1772    }
1773    let link = security_warning_link(W_SEC_011);
1774    eprintln!(
1775        "[capsule-runtime] warning[{W_SEC_011}]: capabilities.filesystem.workdir_exec is true — \
1776         the session workdir keeps its Landlock Execute right, so anything the capsule writes \
1777         there can run regardless of capabilities.shell.allow; this capsule reports containment \
1778         class 'advisory' on every host, including a Landlock-capable one ({link})"
1779    );
1780}
1781
1782/// A per-hook `capabilities:` block reuses the whole [`murmur_artifact::Capabilities`]
1783/// vocabulary, but only `network` and `filesystem` govern a hook — the rest are capsule-wide
1784/// concerns nothing reads per-artifact. Warn rather than reject (the block is structurally
1785/// valid) so an operator who declared, say, `shell.allow` on a hook entry learns it is inert
1786/// instead of assuming it was applied. Infallible and non-fatal, like
1787/// [`warn_if_bash_network_bypass`], and carries the same `W-SEC-*` registry code + doc link
1788/// convention as every other non-fatal capability warning (see `security_warnings.rs`).
1789///
1790/// Not written to `logs/bootstrap.log`, unlike [`warn_if_bash_network_bypass`]: this fires
1791/// during artifact staging in [`stage_session`], before the session workdir
1792/// `warn_if_bash_network_bypass`/`sandbox::warn_for_enforcement_tier` write into even exists.
1793fn warn_on_inert_hook_capabilities(
1794    hook_name: &str,
1795    capabilities: Option<&murmur_artifact::Capabilities>,
1796) {
1797    let inert = inert_capability_sub_blocks(capabilities);
1798    if !inert.is_empty() {
1799        let link = security_warning_link(W_SEC_006);
1800        eprintln!(
1801            "[capsule-runtime] warning[{W_SEC_006}]: hook '{hook_name}' declares capabilities.{} \
1802             which the runtime does not apply per-hook — only capabilities.network and \
1803             capabilities.filesystem govern a hook ({link})",
1804            inert.join(", capabilities.")
1805        );
1806    }
1807}
1808
1809/// The sub-blocks a per-artifact `capabilities:` grant never reads, whichever role declared
1810/// it. Shared by the hook (`W-SEC-006`) and tool/driver (`W-SEC-008`) warnings, which differ
1811/// only in code and wording — the hazard, and the set of inert keys, is identical.
1812fn inert_capability_sub_blocks(
1813    capabilities: Option<&murmur_artifact::Capabilities>,
1814) -> Vec<&'static str> {
1815    let Some(capabilities) = capabilities else {
1816        return Vec::new();
1817    };
1818
1819    [
1820        ("shell", capabilities.shell.is_some()),
1821        ("spawn", capabilities.spawn.is_some()),
1822        ("env", capabilities.env.is_some()),
1823        ("limits", capabilities.limits.is_some()),
1824        // Same hazard as its siblings: host-process bounds are session-scoped (one cgroup scope,
1825        // one workdir guard, one set of rlimits per spawned process), so a per-artifact or
1826        // per-hook `resources:` block is structurally accepted and silently inert.
1827        ("resources", capabilities.resources.is_some()),
1828        // The containment floor is capsule-wide, resolved before staging — a per-artifact
1829        // declaration of it is read by nothing.
1830        ("containment", capabilities.containment.is_some()),
1831    ]
1832    .into_iter()
1833    .filter_map(|(name, present)| present.then_some(name))
1834    .collect()
1835}
1836
1837/// Lower one tool's or driver's per-artifact grant and record it, warning about anything the
1838/// operator declared that narrowing will not honor.
1839///
1840/// Called from the WASM-tool and driver staging arms only. Inserting nothing when the entry
1841/// declares no `capabilities:` block is what makes the absent case a strict no-op: dispatch
1842/// looks the artifact up by name and falls back to the session's own policy on a miss.
1843fn stage_artifact_grant(
1844    artifact: &ArtifactRequest,
1845    ceiling_network_allow_rules: &[NetworkAllowRule],
1846    artifact_grants: &mut HashMap<String, ToolCapabilityGrant>,
1847) -> Result<(), RuntimeError> {
1848    let Some(capabilities) = artifact.capabilities.as_ref() else {
1849        return Ok(());
1850    };
1851
1852    // Derived from `artifact` — the operator's own manifest entry — and never from the
1853    // artifact's bundled `murmur.yaml`, so a tool pulled from a registry cannot scope itself
1854    // up. Deriving at staging (not at dispatch) means a malformed grant fails the run before
1855    // any guest starts.
1856    let grant = ToolCapabilityGrant::derive(Some(capabilities), ceiling_network_allow_rules)?;
1857    warn_on_out_of_ceiling_network_entries(&artifact.name, &grant.dropped_network_entries);
1858    warn_on_inert_tool_capabilities(&artifact.name, Some(capabilities));
1859    artifact_grants.insert(artifact.name.clone(), grant);
1860    Ok(())
1861}
1862
1863/// A per-artifact `network.allow` entry the capsule-wide ceiling does not itself allow was
1864/// dropped rather than granted — narrowing only ever subtracts. Non-fatal on purpose: the
1865/// resulting posture is strictly *tighter* than the operator asked for, so failing staging
1866/// would punish a safe mistake, but a silent drop would leave them believing a host is
1867/// reachable when it is not.
1868fn warn_on_out_of_ceiling_network_entries(artifact_name: &str, dropped: &[String]) {
1869    if dropped.is_empty() {
1870        return;
1871    }
1872
1873    let link = security_warning_link(W_SEC_007);
1874    eprintln!(
1875        "[capsule-runtime] warning[{W_SEC_007}]: artifact '{artifact_name}' declares \
1876         capabilities.network.allow entries the capsule-wide ceiling does not allow ({}) — \
1877         they are dropped, not granted, because per-artifact capabilities can only narrow \
1878         ({link})",
1879        dropped.join(", ")
1880    );
1881}
1882
1883/// The tool/driver counterpart of [`warn_on_inert_hook_capabilities`]: per-artifact narrowing
1884/// reads only `network` and `filesystem`, so any other sub-block is structurally valid and
1885/// silently inert. Warn rather than reject, matching how every other capability-posture issue
1886/// is reported.
1887fn warn_on_inert_tool_capabilities(
1888    artifact_name: &str,
1889    capabilities: Option<&murmur_artifact::Capabilities>,
1890) {
1891    let inert = inert_capability_sub_blocks(capabilities);
1892    if !inert.is_empty() {
1893        let link = security_warning_link(W_SEC_008);
1894        eprintln!(
1895            "[capsule-runtime] warning[{W_SEC_008}]: artifact '{artifact_name}' declares \
1896             capabilities.{} which per-artifact narrowing does not apply — only \
1897             capabilities.network and capabilities.filesystem narrow a tool or driver ({link})",
1898            inert.join(", capabilities.")
1899        );
1900    }
1901}
1902
1903/// A `runtime: tool` artifact with a native (non-WASM) implementation runs as a host
1904/// subprocess under the capsule-wide shell/sandbox machinery, never through the WASI tool
1905/// path per-artifact grants are applied on. Declaring one is therefore wholly inert, which is
1906/// a sharper hazard than an inert sub-block and gets the same `W-SEC-008` treatment.
1907fn warn_on_unenforceable_native_capabilities(
1908    artifact_name: &str,
1909    capabilities: Option<&murmur_artifact::Capabilities>,
1910) {
1911    if capabilities.is_none() {
1912        return;
1913    }
1914
1915    let link = security_warning_link(W_SEC_008);
1916    eprintln!(
1917        "[capsule-runtime] warning[{W_SEC_008}]: artifact '{artifact_name}' declares \
1918         per-artifact 'capabilities:' but ships a native implementation — narrowing applies \
1919         only to WASM tools and drivers, so this grant is not enforced ({link})"
1920    );
1921}
1922
1923/// Builds the single `Engine` a session runs every guest on.
1924///
1925/// `epoch_interruption` compiles a deadline check into wasm loop back-edges and function
1926/// entries. It only arms the mechanism: a deadline fires solely when some store has called
1927/// `set_epoch_deadline` *and* an [`EpochTicker`] is advancing this engine's epoch, both of
1928/// which `stage_session` sets up.
1929fn build_engine() -> Result<Engine, RuntimeError> {
1930    let mut config = Config::new();
1931    config.wasm_component_model(true);
1932    config.epoch_interruption(true);
1933    Engine::new(&config).map_err(|err| RuntimeError::Runtime(err.to_string()))
1934}
1935
1936/// Map a failed capsule-guest invocation onto the most specific `RuntimeError` available,
1937/// so a deadline or resource-limit trap is distinguishable from a guest panic rather than
1938/// collapsing into the generic `CapsuleTrap` bucket.
1939fn capsule_guest_error(error: &wasmtime::Error, limiter: &ExecutionLimiter) -> RuntimeError {
1940    match classify_guest_failure(error, limiter) {
1941        GuestFailure::DeadlineExceeded { seconds } => {
1942            RuntimeError::CapsuleDeadlineExceeded { seconds }
1943        }
1944        GuestFailure::ResourceLimit { message } => RuntimeError::CapsuleResourceLimit { message },
1945        GuestFailure::Other => RuntimeError::CapsuleTrap(error.to_string()),
1946    }
1947}
1948
1949fn map_registry_error(name: &str, version: &str, error: RegistryError) -> RuntimeError {
1950    match error {
1951        RegistryError::NotFound { .. } => RuntimeError::artifact_not_found(name, version),
1952        RegistryError::IntegrityMismatch { .. } => {
1953            RuntimeError::artifact_integrity_failed(name, version)
1954        }
1955        other => RuntimeError::Runtime(other.to_string()),
1956    }
1957}
1958
1959/// Build a guest's WASI context with a scoped environment: no host inheritance, only what
1960/// `policy.env_allow` declares (credential-filtered) plus the runtime's own `extra_env`.
1961///
1962/// `extra_env` is applied last so a manifest cannot shadow a runtime-owned `MURMUR_*` value
1963/// by allowlisting its name.
1964///
1965/// `filesystem_scope` is a per-artifact narrowing of what gets preopened as `"."`: `None`
1966/// preopens `workdir` itself, which is what every caller without a per-artifact grant passes
1967/// and is the pre-existing behavior. `Some(scope)` preopens `workdir/scope` instead, created
1968/// if missing — already validated as relative and non-escaping by
1969/// [`ToolCapabilityGrant::derive`] at staging time.
1970fn build_wasi_ctx(
1971    workdir: &Path,
1972    filesystem_scope: Option<&str>,
1973    extra_env: &[(String, String)],
1974    policy: &CapabilityPolicy,
1975) -> Result<WasiCtx, RuntimeError> {
1976    let mut builder = WasiCtxBuilder::new();
1977    builder.inherit_stdio();
1978    for (key, value) in build_wasi_env_allowlist(policy) {
1979        builder.env(key, value);
1980    }
1981    for (key, value) in extra_env {
1982        builder.env(key, value);
1983    }
1984
1985    // Hard error rather than a silent fall back to the unscoped workdir (which would widen
1986    // the grant) or to no preopen at all (which would look like a guest bug).
1987    let preopen_root = match filesystem_scope {
1988        None => workdir.to_path_buf(),
1989        Some(scope) => resolve_scoped_dir(workdir, scope)?,
1990    };
1991
1992    builder
1993        .preopened_dir(&preopen_root, ".", DirPerms::all(), FilePerms::all())
1994        .map_err(|err| RuntimeError::wasi(preopen_root, err.to_string()))?;
1995
1996    Ok(builder.build())
1997}
1998
1999fn inference_env_pairs(inference: &murmur_artifact::InferenceConfig) -> Vec<(String, String)> {
2000    let mut pairs = vec![
2001        (
2002            "MURMUR_INFERENCE_TRANSPORT".to_string(),
2003            inference.transport.clone(),
2004        ),
2005        (
2006            "MURMUR_INFERENCE_ENDPOINT".to_string(),
2007            inference.endpoint.clone().unwrap_or_default(),
2008        ),
2009        (
2010            "MURMUR_INFERENCE_MODEL".to_string(),
2011            inference.model.clone(),
2012        ),
2013        (
2014            "MURMUR_INFERENCE_DRIVER".to_string(),
2015            inference
2016                .driver
2017                .as_ref()
2018                .map(|d| d.artifact.clone())
2019                .unwrap_or_default(),
2020        ),
2021    ];
2022
2023    if let Some(api_key) = inference.api_key.as_ref() {
2024        pairs.push(("MURMUR_INFERENCE_API_KEY".to_string(), api_key.clone()));
2025    }
2026
2027    if let Some(config) = inference.driver.as_ref().and_then(|d| d.config.as_ref()) {
2028        pairs.push(("MURMUR_INFERENCE_DRIVER_CONFIG".to_string(), config.clone()));
2029    }
2030
2031    pairs
2032}
2033
2034/// Suspend an A2A task in the InputRequired state, await external input, and resume.
2035///
2036/// Called from the `murmur:task/task#request-input` host function registered in the tool linker.
2037/// Emits SSE events for the state transitions. Returns `Err` on timeout (traps the WASM guest).
2038pub(crate) async fn request_input_impl(
2039    task_id: String,
2040    prompt: String,
2041    task_registry: Arc<Mutex<TaskRegistry>>,
2042    sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
2043    input_timeout_secs: Option<u64>,
2044) -> wasmtime::Result<String> {
2045    use std::time::Duration;
2046    use tokio::sync::oneshot;
2047
2048    let (tx, rx) = oneshot::channel::<String>();
2049    {
2050        let mut reg = task_registry.lock().unwrap();
2051        reg.set_input_required(&task_id, prompt.clone(), tx)
2052            .map_err(|e| wasmtime::Error::msg(format!("request-input: {e}")))?;
2053    }
2054
2055    // Use high IDs to avoid overlapping with agent-loop SSE event IDs (which start at 0).
2056    let mut sse_event_id: u64 = u64::MAX / 2;
2057    emit_sse(
2058        &sse,
2059        &mut sse_event_id,
2060        "status",
2061        &TaskStatusUpdateEvent {
2062            id: task_id.clone(),
2063            context_id: None,
2064            status: StreamStatus {
2065                state: "input-required".into(),
2066                message: prompt.clone(),
2067                response: None,
2068            },
2069            r#final: false,
2070        },
2071    )
2072    .await;
2073
2074    let result = match input_timeout_secs {
2075        Some(secs) => tokio::time::timeout(Duration::from_secs(secs), rx)
2076            .await
2077            .map_err(|_| ())
2078            .and_then(|r| r.map_err(|_| ())),
2079        None => rx.await.map_err(|_| ()),
2080    };
2081
2082    match result {
2083        Ok(text) => {
2084            emit_sse(
2085                &sse,
2086                &mut sse_event_id,
2087                "status",
2088                &TaskStatusUpdateEvent {
2089                    id: task_id.clone(),
2090                    context_id: None,
2091                    status: StreamStatus {
2092                        state: "working".into(),
2093                        message: "resumed".into(),
2094                        response: None,
2095                    },
2096                    r#final: false,
2097                },
2098            )
2099            .await;
2100            Ok(text)
2101        }
2102        Err(()) => {
2103            {
2104                let mut reg = task_registry.lock().unwrap();
2105                reg.finish_task(TaskState::Failed);
2106            }
2107            emit_sse(
2108                &sse,
2109                &mut sse_event_id,
2110                "status",
2111                &TaskStatusUpdateEvent {
2112                    id: task_id.clone(),
2113                    context_id: None,
2114                    status: StreamStatus {
2115                        state: "failed".into(),
2116                        message: "input-timeout".into(),
2117                        response: None,
2118                    },
2119                    r#final: true,
2120                },
2121            )
2122            .await;
2123            Err(wasmtime::Error::msg("input-timeout"))
2124        }
2125    }
2126}
2127
2128pub(crate) struct NetworkPolicyHooks {
2129    pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
2130}
2131
2132impl WasiHttpHooks for NetworkPolicyHooks {
2133    fn send_request(
2134        &mut self,
2135        request: hyper::Request<HyperOutgoingBody>,
2136        config: OutgoingRequestConfig,
2137    ) -> HttpResult<HostFutureIncomingResponse> {
2138        let target =
2139            RequestTarget::from_request(request.uri(), config.use_tls).ok_or_else(|| {
2140                wasmtime_wasi_http::p2::HttpError::from(WasiHttpErrorCode::HttpRequestDenied)
2141            })?;
2142
2143        if !self
2144            .network_allow_rules
2145            .iter()
2146            .any(|rule| rule.matches(&target))
2147        {
2148            return Err(wasmtime_wasi_http::p2::HttpError::from(
2149                WasiHttpErrorCode::HttpRequestDenied,
2150            ));
2151        }
2152
2153        Ok(wasmtime_wasi_http::p2::default_send_request(
2154            request, config,
2155        ))
2156    }
2157}
2158
2159pub(crate) struct CapsuleStoreState {
2160    /// Resource limiter for this store, registered via `Store::limiter`. Also the record of
2161    /// any growth request it denied, which `classify_guest_failure` reads to tell a
2162    /// limit trap apart from a guest panic.
2163    pub(crate) limits: ExecutionLimiter,
2164    pub(crate) table: ResourceTable,
2165    pub(crate) wasi: WasiCtx,
2166    pub(crate) http: WasiHttpCtx,
2167    pub(crate) http_hooks: NetworkPolicyHooks,
2168    pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
2169    pub(crate) inference_env: Vec<(String, String)>,
2170    pub(crate) engine: Engine,
2171    pub(crate) workdir: PathBuf,
2172    pub(crate) accessible_workdir: PathBuf,
2173    pub(crate) tool_components: HashMap<String, Component>,
2174    /// Per-artifact narrowing keyed by artifact name, moved over from
2175    /// [`StagedSession::artifact_grants`]. A name absent here dispatches on the full ceiling.
2176    pub(crate) artifact_grants: HashMap<String, ToolCapabilityGrant>,
2177    pub(crate) allowlisted_tools: HashSet<String>,
2178    pub(crate) installed_artifacts: Vec<InstalledArtifactSummary>,
2179    pub(crate) session_id: String,
2180    /// Buffered outgoing A2A send events — drained into trace.jsonl after the capsule run.
2181    /// (peer_url, message_id, task_id, context_id, traceparent)
2182    pub(crate) pending_a2a_events: Vec<(String, String, String, String, Option<String>)>,
2183    pub(crate) capability_policy: CapabilityPolicy,
2184    /// Host-detected kernel enforcement tier + resolved network allowlist IPs for this
2185    /// session's shell subprocesses. Kept separate from `CapabilityPolicy` (which stays
2186    /// purely manifest-derived) since this is host-probed data, not manifest data.
2187    pub(crate) shell_enforcement: sandbox::ShellEnforcement,
2188    /// W3C traceparent for outgoing murmur:message/send calls — set by the runtime loop
2189    /// after each begin_session so the active session span propagates to peer capsules.
2190    pub(crate) current_traceparent: Option<String>,
2191    // ── A2A request-input support ────────────────────────────────────────────────
2192    /// Shared task registry — Some in A2A mode, None for script capsules.
2193    pub(crate) a2a_task_registry: Option<Arc<Mutex<TaskRegistry>>>,
2194    /// SSE broadcast channel — Some in A2A mode, None for script capsules.
2195    pub(crate) a2a_sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
2196    /// Active A2A task ID — set per-task before run_agent_loop, None outside A2A context.
2197    pub(crate) a2a_task_id: Option<String>,
2198    /// Optional input timeout from lifecycle config.
2199    pub(crate) input_timeout_secs: Option<u64>,
2200    // ── A2A streaming text chunk support ─────────────────────────────────────────
2201    /// Monotonically increasing event ID counter for text chunk SSE events.
2202    /// Starts at u64::MAX/4 to avoid overlap with agent-loop status/artifact IDs (from 0).
2203    pub(crate) a2a_chunk_event_id: Arc<AtomicU64>,
2204    /// Set to true when any emit-chunk call is made during the current driver dispatch.
2205    /// Reset to false before each driver dispatch in run_agent_loop.
2206    pub(crate) a2a_chunks_emitted: Arc<AtomicBool>,
2207    /// Registry used to resolve additional artifacts requested at runtime via `manage.pull()`.
2208    pub(crate) registry: Arc<dyn Registry>,
2209    /// Path to this session's `murmur.lock`, consulted and updated by `manage.pull()`.
2210    pub(crate) lock_path: PathBuf,
2211    // ── Stateful-driver continuation ───────────────────────────────────
2212    /// Continuation id most recently returned by the inference driver via
2213    /// `tool-result.metadata["continuation_id"]`, held for the current session loop.
2214    /// `None` means "no continuation held" — every Turn resends the full `messages`
2215    /// array (the behavior of every driver shipped today, which never sets the key).
2216    pub(crate) driver_continuation_id: Option<String>,
2217    /// The `context_id` under which `driver_continuation_id` was established. Used as a
2218    /// cross-context safety guard: a held continuation is only reused when the current
2219    /// Turn's `context_id` matches this, so a driver-side continuation from one
2220    /// conversation is never carried into an unrelated Task within the same session loop.
2221    pub(crate) driver_continuation_context_id: Option<String>,
2222    /// Number of leading entries of the logical `messages` array the driver has already
2223    /// acknowledged. On an incremental Turn the host transmits only `messages[acked_len..]`.
2224    pub(crate) driver_continuation_acked_len: usize,
2225}
2226
2227impl CapsuleStoreState {
2228    /// Returns the held continuation `(id, acked_len)` iff a continuation is currently held
2229    /// **and** was established under `context_id`. The context-id guard is required for
2230    /// correctness: without it, an incremental send against a driver-side continuation from
2231    /// an unrelated conversation would silently corrupt the driver's context.
2232    pub(crate) fn active_continuation(&self, context_id: Option<&str>) -> Option<(&str, usize)> {
2233        let id = self.driver_continuation_id.as_deref()?;
2234        if self.driver_continuation_context_id.as_deref() != context_id {
2235            return None;
2236        }
2237        Some((id, self.driver_continuation_acked_len))
2238    }
2239
2240    /// Persist the continuation id a driver returned on this Turn, scoped to `context_id`,
2241    /// recording that the driver now knows the first `acked_len` entries of `messages`.
2242    pub(crate) fn record_continuation(
2243        &mut self,
2244        id: String,
2245        context_id: Option<String>,
2246        acked_len: usize,
2247    ) {
2248        self.driver_continuation_id = Some(id);
2249        self.driver_continuation_context_id = context_id;
2250        self.driver_continuation_acked_len = acked_len;
2251    }
2252
2253    /// Drop any held continuation id and its bookkeeping. Called when a driver stops
2254    /// returning the key ("not continuing") and at every `replace-context` commit.
2255    pub(crate) fn clear_continuation(&mut self) {
2256        self.driver_continuation_id = None;
2257        self.driver_continuation_context_id = None;
2258        self.driver_continuation_acked_len = 0;
2259    }
2260
2261    /// Advance the acked-message count for a currently-held, same-context continuation.
2262    /// Used when a Task's final `end_turn` persists an extra assistant message into the
2263    /// context history that the driver already knows (it generated it), so the next
2264    /// same-context Task sends only its new user message rather than re-including it.
2265    pub(crate) fn advance_continuation_acked_len(
2266        &mut self,
2267        context_id: Option<&str>,
2268        acked_len: usize,
2269    ) {
2270        if self.driver_continuation_id.is_some()
2271            && self.driver_continuation_context_id.as_deref() == context_id
2272        {
2273            self.driver_continuation_acked_len = acked_len;
2274        }
2275    }
2276}
2277
2278impl WasiView for CapsuleStoreState {
2279    fn ctx(&mut self) -> WasiCtxView<'_> {
2280        WasiCtxView {
2281            ctx: &mut self.wasi,
2282            table: &mut self.table,
2283        }
2284    }
2285}
2286
2287impl WasiHttpView for CapsuleStoreState {
2288    fn http(&mut self) -> WasiHttpCtxView<'_> {
2289        WasiHttpCtxView {
2290            ctx: &mut self.http,
2291            table: &mut self.table,
2292            hooks: &mut self.http_hooks,
2293        }
2294    }
2295}
2296
2297impl invoke::Host for CapsuleStoreState {
2298    fn invoke(
2299        &mut self,
2300        name: String,
2301        input: murmur::tool::run::ToolInput,
2302    ) -> Result<murmur::tool::run::ToolResult, String> {
2303        if !self.allowlisted_tools.contains(&name) {
2304            return Err(format!(
2305                "tool '{name}' is not declared in manifest allowlist"
2306            ));
2307        }
2308        tokio::task::block_in_place(|| {
2309            tokio::runtime::Handle::current().block_on(self.dispatch_tool_async(&name, input))
2310        })
2311    }
2312}
2313
2314impl send::Host for CapsuleStoreState {
2315    fn send(
2316        &mut self,
2317        peer_url: String,
2318        message: send::Message,
2319    ) -> Result<send::TaskResult, String> {
2320        // Enforce network policy
2321        let for_parse = if peer_url.contains("://") {
2322            peer_url.clone()
2323        } else {
2324            format!("http://{peer_url}")
2325        };
2326        let uri: http::Uri = for_parse
2327            .parse()
2328            .map_err(|e| format!("invalid peer URL '{peer_url}': {e}"))?;
2329        let target = RequestTarget::from_request(&uri, false)
2330            .ok_or_else(|| format!("invalid peer URL '{peer_url}'"))?;
2331        if !self
2332            .network_allow_rules
2333            .iter()
2334            .any(|rule| rule.matches(&target))
2335        {
2336            return Err(format!(
2337                "network policy: '{peer_url}' not in capabilities.network.allow"
2338            ));
2339        }
2340
2341        let message_id = message.message_id.clone();
2342        let outgoing_msg = outgoing::OutgoingMessage {
2343            message_id: message.message_id,
2344            context_id: message.context_id,
2345            text: message.text,
2346        };
2347
2348        // send_a2a_message is async; use block_in_place so we can call it from this sync
2349        // host function while inside a multi-thread Tokio runtime (script capsule path).
2350        let traceparent = self.current_traceparent.clone();
2351        let task = tokio::task::block_in_place(|| {
2352            tokio::runtime::Handle::current().block_on(outgoing::send_a2a_message(
2353                &peer_url,
2354                outgoing_msg,
2355                traceparent.clone(),
2356            ))
2357        })?;
2358
2359        self.pending_a2a_events.push((
2360            peer_url.clone(),
2361            message_id,
2362            task.id.clone(),
2363            task.context_id.clone(),
2364            traceparent,
2365        ));
2366        Ok(send::TaskResult {
2367            task_id: task.id,
2368            context_id: task.context_id,
2369            state: match task.status.state {
2370                crate::a2a::TaskState::Submitted => "submitted".to_string(),
2371                crate::a2a::TaskState::Working => "working".to_string(),
2372                crate::a2a::TaskState::InputRequired => "input-required".to_string(),
2373                crate::a2a::TaskState::Completed => "completed".to_string(),
2374                crate::a2a::TaskState::Failed => "failed".to_string(),
2375                crate::a2a::TaskState::Rejected => "rejected".to_string(),
2376            },
2377        })
2378    }
2379}
2380
2381impl manage::Host for CapsuleStoreState {
2382    fn list(&mut self) -> Vec<manage::ArtifactSummary> {
2383        let mut result: Vec<manage::ArtifactSummary> = self
2384            .installed_artifacts
2385            .iter()
2386            .filter(|a| a.runtime.is_llm_visible())
2387            .map(|artifact| manage::ArtifactSummary {
2388                name: artifact.name.clone(),
2389                version: artifact.version.clone(),
2390                runtime: runtime_type_to_wit(&artifact.runtime, artifact.implementation.as_ref()),
2391            })
2392            .collect();
2393
2394        let existing: std::collections::HashSet<String> =
2395            result.iter().map(|a| a.name.clone()).collect();
2396        for binary in &self.capability_policy.shell_allow {
2397            if !existing.contains(binary) {
2398                result.push(manage::ArtifactSummary {
2399                    name: binary.clone(),
2400                    version: "0.0.0".to_string(),
2401                    runtime: manage::RuntimeType::Native,
2402                });
2403            }
2404        }
2405
2406        result
2407    }
2408
2409    fn describe(&mut self, name: String) -> Result<manage::ArtifactInfo, String> {
2410        // Check installed artifacts first, then fall back to shell tool manifests on disk.
2411        let fallback_summary;
2412        let summary = if let Some(s) = self
2413            .installed_artifacts
2414            .iter()
2415            .find(|artifact| artifact.name == name)
2416        {
2417            s
2418        } else if self
2419            .capability_policy
2420            .shell_allow
2421            .iter()
2422            .any(|b| b == &name)
2423        {
2424            fallback_summary = InstalledArtifactSummary {
2425                name: name.clone(),
2426                version: "0.0.0".to_string(),
2427                runtime: ArtifactRuntime::Tool,
2428                implementation: Some(ArtifactImplementation::Native),
2429            };
2430            &fallback_summary
2431        } else {
2432            return Err(format!("artifact '{name}' is not installed"));
2433        };
2434
2435        let manifest_path = self
2436            .workdir
2437            .join("tools")
2438            .join(&name)
2439            .join(PACKED_MANIFEST_ENTRY);
2440
2441        read_artifact_info_from_manifest(&manifest_path, summary)
2442    }
2443
2444    fn search(&mut self, query: String) -> Result<Vec<manage::ArtifactSummary>, String> {
2445        Err(format!("not implemented (query: {query})"))
2446    }
2447
2448    fn pull(&mut self, name: String, version: String) -> Result<manage::ArtifactSummary, String> {
2449        // 1. Resolve + verify against the registry's own self-reported hash.
2450        let resolved = self
2451            .registry
2452            .resolve_with_platform(&name, &version, Some(current_platform()))
2453            .map_err(|err| format!("failed to resolve {name}@{version}: {err}"))?;
2454
2455        verify_sha256(&name, &version, &resolved.bytes, &resolved.sha256).map_err(|_| {
2456            format!("artifact integrity check failed for {name}@{version}: registry-reported hash does not match downloaded bytes")
2457        })?;
2458
2459        // 2. Cross-check against any existing murmur.lock pin — a runtime pull must never
2460        // silently override what's already pinned for this artifact.
2461        let mut lock = match read_lockfile(&self.lock_path) {
2462            Ok(lock) => lock,
2463            Err(LockfileError::NotFound(_)) => MurmurLock {
2464                lock_version: LOCK_VERSION,
2465                artifacts: Vec::new(),
2466            },
2467            Err(err) => return Err(format!("failed to read murmur.lock: {err}")),
2468        };
2469
2470        if let Some(existing) = lock.artifact_for(&name) {
2471            if existing.resolved_version != version || existing.sha256.wasm != resolved.sha256 {
2472                return Err(format!(
2473                    "murmur.lock conflict for '{name}': pinned {}@{} (sha256 {}), but the \
2474                     registry now resolves {name}@{version} (sha256 {}) — refusing to override \
2475                     a pinned artifact at runtime",
2476                    existing.name, existing.resolved_version, existing.sha256.wasm, resolved.sha256
2477                ));
2478            }
2479        }
2480
2481        // 3. Extract murmur.yaml, dispatch extraction by runtime type, and write files under
2482        // <workdir>/tools/<name>/ — no disk writes happen before steps 1-2 succeed.
2483        let manifest_yaml = extract_manifest_yaml(&name, &version, &resolved.bytes)
2484            .map_err(|err| err.to_string())?;
2485        write_tool_manifest(&self.workdir, &name, &manifest_yaml).map_err(|err| err.to_string())?;
2486
2487        let (artifact_runtime, implementation, wasm_component) = match resolved.meta.runtime {
2488            RuntimeType::Wasm => {
2489                let wasm_bytes = extract_root_wasm(&name, &version, &resolved.bytes)
2490                    .map_err(|err| err.to_string())?;
2491                let component = Component::new(&self.engine, &wasm_bytes)
2492                    .map_err(|err| format!("failed to compile pulled component '{name}': {err}"))?;
2493                (
2494                    ArtifactRuntime::Tool,
2495                    Some(ArtifactImplementation::Wasm),
2496                    Some(component),
2497                )
2498            }
2499            RuntimeType::Native => {
2500                let binary = extract_native_binary(&name, &version, &resolved.bytes)
2501                    .map_err(|err| err.to_string())?;
2502                install_native_binaries(&self.workdir, vec![(name.clone(), binary)])
2503                    .map_err(|err| err.to_string())?;
2504                (
2505                    ArtifactRuntime::Tool,
2506                    Some(ArtifactImplementation::Native),
2507                    None,
2508                )
2509            }
2510            RuntimeType::Static => {
2511                let skill_md = extract_skill_md(&name, &version, &resolved.bytes)
2512                    .map_err(|err| err.to_string())?;
2513                install_skill_files(&self.workdir, vec![(name.clone(), skill_md)])
2514                    .map_err(|err| err.to_string())?;
2515                (ArtifactRuntime::Skill, None, None)
2516            }
2517        };
2518
2519        // 4. Files are on disk — now, and only now, update murmur.lock.
2520        if let Some(entry) = lock.artifacts.iter_mut().find(|entry| entry.name == name) {
2521            entry.resolved_version = version.clone();
2522            entry.sha256 = LockedSha256 {
2523                wasm: resolved.sha256.clone(),
2524            };
2525        } else {
2526            lock.artifacts.push(LockedArtifact {
2527                name: name.clone(),
2528                resolved_version: version.clone(),
2529                sha256: LockedSha256 {
2530                    wasm: resolved.sha256.clone(),
2531                },
2532            });
2533        }
2534        write_lockfile_atomic(&self.lock_path, &lock)
2535            .map_err(|err| format!("failed to write murmur.lock: {err}"))?;
2536
2537        // 5. Reflect the pulled artifact in in-memory session state so list()/describe() (and,
2538        // for WASM tools, invoke()) see it immediately.
2539        if let Some(component) = wasm_component {
2540            self.tool_components.insert(name.clone(), component);
2541        }
2542
2543        let summary = InstalledArtifactSummary {
2544            name: name.clone(),
2545            version: version.clone(),
2546            runtime: artifact_runtime,
2547            implementation,
2548        };
2549        if let Some(existing) = self
2550            .installed_artifacts
2551            .iter_mut()
2552            .find(|artifact| artifact.name == name)
2553        {
2554            *existing = summary.clone();
2555        } else {
2556            self.installed_artifacts.push(summary.clone());
2557        }
2558
2559        Ok(manage::ArtifactSummary {
2560            name,
2561            version,
2562            runtime: runtime_type_to_wit(&summary.runtime, summary.implementation.as_ref()),
2563        })
2564    }
2565
2566    fn remove(&mut self, name: String) -> Result<bool, String> {
2567        Err(format!("not implemented (name: {name})"))
2568    }
2569
2570    fn diagnostics(&mut self) -> Result<manage::RuntimeState, String> {
2571        Ok(manage::RuntimeState {
2572            capsule_id: self.session_id.clone(),
2573            installed: self.list(),
2574            capabilities: "artifact-manager/search and remove are not implemented".to_string(),
2575        })
2576    }
2577}
2578
2579/// Borrowed half of a WASM tool invocation environment: everything
2580/// [`invoke_tool_component`] needs that is neither the component nor the A2A
2581/// wiring. Grouped into a struct so the hook runtime can assemble one from its
2582/// own owned copies without a >7-argument function.
2583pub(crate) struct ToolInvokeEnv<'a> {
2584    pub(crate) engine: &'a Engine,
2585    pub(crate) accessible_workdir: &'a Path,
2586    pub(crate) inference_env: &'a [(String, String)],
2587    pub(crate) capability_policy: &'a CapabilityPolicy,
2588    /// The capsule-wide ceiling. What actually gets enforced is this, clamped by
2589    /// `artifact_grant` when the operator declared one.
2590    pub(crate) network_allow_rules: &'a [NetworkAllowRule],
2591    /// This artifact's optional narrowing, from the operator's own manifest entry. `None` —
2592    /// no `capabilities:` block on the entry — means the ceiling applies untouched and the
2593    /// whole `accessible_workdir` is preopened, exactly as before narrowing existed.
2594    pub(crate) artifact_grant: Option<&'a ToolCapabilityGrant>,
2595}
2596
2597/// Per-session A2A wiring registered on a tool linker.
2598///
2599/// The two host interfaces it backs (`murmur:text/chunks`, `murmur:task/task`)
2600/// are always *defined* — a streaming driver imports them and would fail to
2601/// instantiate otherwise — but each function is a no-op when its channel is
2602/// absent. [`ToolA2aWiring::silent`] is that all-absent form, used for a
2603/// dispatch that is not part of an A2A task turn (a hook's `run-inference`
2604/// call, which must not stream chunks into the user's SSE stream or ask the
2605/// user for input).
2606pub(crate) struct ToolA2aWiring {
2607    sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
2608    task_id: Option<String>,
2609    chunk_event_id: Arc<AtomicU64>,
2610    chunks_emitted: Arc<AtomicBool>,
2611    task_registry: Option<Arc<Mutex<TaskRegistry>>>,
2612    input_timeout_secs: Option<u64>,
2613}
2614
2615impl ToolA2aWiring {
2616    pub(crate) fn silent() -> Self {
2617        Self {
2618            sse: None,
2619            task_id: None,
2620            chunk_event_id: Arc::new(AtomicU64::new(0)),
2621            chunks_emitted: Arc::new(AtomicBool::new(false)),
2622            task_registry: None,
2623            input_timeout_secs: None,
2624        }
2625    }
2626}
2627
2628/// Instantiate `component` in a fresh `Linker`/`Store` and call its
2629/// `murmur:tool/run@0.1.0#run` export.
2630///
2631/// This is the single WASM-tool (and therefore inference-driver) invocation
2632/// body in the runtime. [`CapsuleStoreState::dispatch_tool_async`] is a thin
2633/// wrapper that fills `env`/`a2a` from the capsule store; a hook's
2634/// `run-inference` host import fills them from its own owned copies. Neither
2635/// duplicates any part of the instantiate/type-check/call sequence below.
2636pub(crate) async fn invoke_tool_component(
2637    env: ToolInvokeEnv<'_>,
2638    a2a: ToolA2aWiring,
2639    name: &str,
2640    component: &Component,
2641    input: murmur::tool::run::ToolInput,
2642) -> Result<murmur::tool::run::ToolResult, String> {
2643    let ToolInvokeEnv {
2644        engine,
2645        accessible_workdir,
2646        inference_env,
2647        capability_policy,
2648        network_allow_rules,
2649        artifact_grant,
2650    } = env;
2651    let ToolA2aWiring {
2652        sse: a2a_sse,
2653        task_id: a2a_task_id,
2654        chunk_event_id: a2a_chunk_event_id,
2655        chunks_emitted: a2a_chunks_emitted,
2656        task_registry: a2a_task_registry,
2657        input_timeout_secs,
2658    } = a2a;
2659
2660    let mut linker = Linker::new(engine);
2661    wasmtime_wasi::p2::add_to_linker_async(&mut linker)
2662        .map_err(|err| format!("failed to add WASI linker for tool '{name}': {err}"))?;
2663    wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
2664        .map_err(|err| format!("failed to add HTTP linker for tool '{name}': {err}"))?;
2665
2666    // Register murmur:text/chunks host functions (synchronous).
2667    // Components that do not import this interface ignore the registrations.
2668    // Both functions must be defined in a single .instance() call — Wasmtime
2669    // rejects a second .instance() for the same interface name. Registered
2670    // under the versioned name only (see WIT_TEXT_CHUNKS_IFACE / wit/VERSIONING.md).
2671    {
2672        let chunks_iface = WIT_TEXT_CHUNKS_IFACE;
2673        let sse_for_chunk = a2a_sse.clone();
2674        let task_id_for_chunk = a2a_task_id.clone();
2675        let chunk_event_id = Arc::clone(&a2a_chunk_event_id);
2676        let chunks_emitted_flag = Arc::clone(&a2a_chunks_emitted);
2677        let sse_for_thinking = a2a_sse.clone();
2678        let task_id_for_thinking = a2a_task_id.clone();
2679        let thinking_event_id = Arc::clone(&a2a_chunk_event_id);
2680
2681        let mut inst = linker.instance(chunks_iface).map_err(|err| {
2682            format!("failed to define {chunks_iface} instance for '{name}': {err}")
2683        })?;
2684
2685        inst.func_wrap(
2686            "emit-chunk",
2687            move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>, (chunk,): (String,)| {
2688                chunks_emitted_flag.store(true, Ordering::Relaxed);
2689                if let (Some((ref tx, ref buf)), Some(ref tid)) =
2690                    (&sse_for_chunk, &task_id_for_chunk)
2691                {
2692                    emit_chunk_sse(tx, buf, &chunk_event_id, tid, &chunk);
2693                }
2694                Ok(())
2695            },
2696        )
2697        .map_err(|err| format!("failed to register emit-chunk for tool '{name}': {err}"))?;
2698
2699        inst.func_wrap(
2700            "emit-thinking-chunk",
2701            move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>, (chunk,): (String,)| {
2702                if let (Some((ref tx, ref buf)), Some(ref tid)) =
2703                    (&sse_for_thinking, &task_id_for_thinking)
2704                {
2705                    emit_thinking_chunk_sse(tx, buf, &thinking_event_id, tid, &chunk);
2706                }
2707                Ok(())
2708            },
2709        )
2710        .map_err(|err| {
2711            format!("failed to register emit-thinking-chunk for tool '{name}': {err}")
2712        })?;
2713    }
2714
2715    // Register the murmur:task/task#request-input host function under the
2716    // versioned name only (see WIT_TASK_IFACE).
2717    // Components that do not import this interface ignore the registration.
2718    {
2719        let task_iface = WIT_TASK_IFACE;
2720        let ri_task_registry = a2a_task_registry.clone();
2721        let ri_sse = a2a_sse.clone();
2722        let ri_task_id = a2a_task_id.clone();
2723        let ri_timeout = input_timeout_secs;
2724        linker
2725            .instance(task_iface)
2726            .map_err(|err| format!("failed to define {task_iface} instance for '{name}': {err}"))?
2727            .func_wrap_async(
2728                "request-input",
2729                move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>,
2730                      (prompt,): (String,)| {
2731                    let reg = ri_task_registry.clone();
2732                    let sse = ri_sse.clone();
2733                    let tid = ri_task_id.clone();
2734                    let fut: std::pin::Pin<
2735                        Box<dyn std::future::Future<Output = wasmtime::Result<(String,)>> + Send>,
2736                    > = Box::pin(async move {
2737                        let result = match (reg, tid) {
2738                            (Some(reg), Some(tid)) => {
2739                                request_input_impl(tid, prompt, reg, sse, ri_timeout).await
2740                            }
2741                            _ => Err(wasmtime::Error::msg(
2742                                "request-input is not available outside an A2A task context",
2743                            )),
2744                        };
2745                        result.map(|s| (s,))
2746                    });
2747                    Box::new(fut)
2748                        as Box<
2749                            dyn std::future::Future<Output = wasmtime::Result<(String,)>>
2750                                + Send
2751                                + '_,
2752                        >
2753                },
2754            )
2755            .map_err(|err| format!("failed to register request-input for tool '{name}': {err}"))?;
2756    }
2757
2758    let tool_limits = capability_policy.limits;
2759    // Both halves of the grant are resolved here, on the one path every WASM tool and the
2760    // inference driver share, so a driver needs no enforcement code of its own.
2761    let effective_network_rules = effective_tool_network_rules(artifact_grant, network_allow_rules);
2762    let filesystem_scope = artifact_grant.and_then(|grant| grant.filesystem_scope.as_deref());
2763    let state = ToolStoreState {
2764        limits: tool_limits.limiter(),
2765        table: ResourceTable::new(),
2766        wasi: build_wasi_ctx(
2767            accessible_workdir,
2768            filesystem_scope,
2769            inference_env,
2770            capability_policy,
2771        )
2772        .map_err(|err| format!("failed to build WASI context for tool '{name}': {err}"))?,
2773        http: WasiHttpCtx::new(),
2774        http_hooks: NetworkPolicyHooks {
2775            network_allow_rules: effective_network_rules.to_vec(),
2776        },
2777    };
2778
2779    let mut store = Store::new(engine, state);
2780    // Registered before instantiation — see the capsule store for why.
2781    store.limiter(|state| &mut state.limits);
2782
2783    store.set_epoch_deadline(tool_limits.deadline_ticks());
2784    let instance = linker
2785        .instantiate_async(&mut store, component)
2786        .await
2787        .map_err(|err| format!("failed to instantiate tool '{name}': {err}"))?;
2788
2789    let tool_iface = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED)
2790        .ok_or_else(|| {
2791            RuntimeError::ToolExportMissing {
2792                name: name.to_string(),
2793            }
2794            .to_string()
2795        })?;
2796    let tool_run = instance
2797        .get_export_index(&mut store, Some(&tool_iface), "run")
2798        .and_then(|idx| instance.get_func(&mut store, idx))
2799        .ok_or_else(|| {
2800            RuntimeError::ToolExportMissing {
2801                name: name.to_string(),
2802            }
2803            .to_string()
2804        })?;
2805
2806    let run = tool_run
2807        .typed::<(murmur::tool::run::ToolInput,), (murmur::tool::run::ToolResult,)>(&store)
2808        .map_err(|err| format!("failed to type-check tool '{name}' run export: {err}"))?;
2809
2810    // Fresh budget for `run` itself, so instantiation cost cannot eat into it. This is
2811    // also the driver path (`agent.rs` dispatches the inference driver through here).
2812    store.set_epoch_deadline(tool_limits.deadline_ticks());
2813    let called = run.call_async(&mut store, (input,)).await;
2814    let (result,) = match called {
2815        Ok(result) => result,
2816        Err(err) => {
2817            // Classified rather than folded into the generic "trapped" string, so a
2818            // deadline or limit trap is distinguishable on a path that reports failures
2819            // as plain text. `Other` reproduces the pre-slice wording verbatim.
2820            let failure = classify_guest_failure(&err, &store.data().limits);
2821            return Err(failure.message(&format!("tool '{name}'"), &err));
2822        }
2823    };
2824    Ok(result)
2825}
2826
2827impl CapsuleStoreState {
2828    /// Async WASM tool dispatch — used by the agent loop for drivers and WASM tools.
2829    pub(crate) async fn dispatch_tool_async(
2830        &self,
2831        name: &str,
2832        input: murmur::tool::run::ToolInput,
2833    ) -> Result<murmur::tool::run::ToolResult, String> {
2834        let Some(component) = self.tool_components.get(name) else {
2835            return Err(format!("tool '{name}' is not available in this session"));
2836        };
2837        invoke_tool_component(
2838            ToolInvokeEnv {
2839                engine: &self.engine,
2840                accessible_workdir: &self.accessible_workdir,
2841                inference_env: &self.inference_env,
2842                capability_policy: &self.capability_policy,
2843                network_allow_rules: &self.network_allow_rules,
2844                // Absent for every artifact that declared no `capabilities:` block, and for
2845                // anything pulled in at runtime via `manage.pull()` (which has no operator
2846                // manifest entry to narrow from) — both keep the full ceiling.
2847                artifact_grant: self.artifact_grants.get(name),
2848            },
2849            ToolA2aWiring {
2850                sse: self.a2a_sse.clone(),
2851                task_id: self.a2a_task_id.clone(),
2852                chunk_event_id: Arc::clone(&self.a2a_chunk_event_id),
2853                chunks_emitted: Arc::clone(&self.a2a_chunks_emitted),
2854                task_registry: self.a2a_task_registry.clone(),
2855                input_timeout_secs: self.input_timeout_secs,
2856            },
2857            name,
2858            component,
2859            input,
2860        )
2861        .await
2862    }
2863
2864    /// Dispatch a tool call from the agent loop: native binary, shell, or WASM.
2865    pub(crate) async fn dispatch_agent_tool_async(
2866        &self,
2867        name: &str,
2868        input: murmur::tool::run::ToolInput,
2869    ) -> Result<DispatchOutcome, String> {
2870        // Native artifact: packaged binary in workdir/tools/<name>/<name>
2871        let native_bin = self.workdir.join("tools").join(name).join(name);
2872        if native_bin.exists() && !self.tool_components.contains_key(name) {
2873            return enforce_allowlist(&self.allowlisted_tools, name, || {
2874                dispatch_native_tool(
2875                    name,
2876                    input,
2877                    &native_bin,
2878                    &self.accessible_workdir,
2879                    &self.capability_policy,
2880                    &self.shell_enforcement,
2881                )
2882            })
2883            .map(DispatchOutcome::tool);
2884        }
2885
2886        // Shell tool — run on a blocking thread so the LocalSet stays free to handle
2887        // incoming HTTP requests (e.g. curl POSTing back to the same capsule's server).
2888        if self
2889            .capability_policy
2890            .shell_allow
2891            .iter()
2892            .any(|allowed| allowed == name)
2893        {
2894            let name = name.to_string();
2895            let workdir = self.accessible_workdir.clone();
2896            let env_overrides = self.inference_env.clone();
2897            let policy = self.capability_policy.clone();
2898            let enforcement = self.shell_enforcement.clone();
2899            return tokio::task::spawn_blocking(move || {
2900                dispatch_shell_tool(
2901                    &name,
2902                    input,
2903                    &workdir,
2904                    &env_overrides,
2905                    &policy,
2906                    &enforcement,
2907                )
2908            })
2909            .await
2910            .map_err(|e| format!("shell tool panicked: {e}"));
2911        }
2912
2913        // Skill artifact: return skill.md content as the tool result (no WASM dispatch).
2914        // Works in capsules with no shell/file capabilities — the runtime reads the file.
2915        let skill_md_path = self.workdir.join("tools").join(name).join("skill.md");
2916        if skill_md_path.exists() {
2917            let content = fs::read_to_string(&skill_md_path)
2918                .map_err(|e| format!("failed to read skill '{name}': {e}"))?;
2919            return Ok(DispatchOutcome::skill(murmur::tool::run::ToolResult {
2920                status: murmur::tool::run::Status::Passed,
2921                summary: Some(format!("Skill {name} guidance")),
2922                data: Some(content),
2923                data_path: None,
2924                truncated: false,
2925                metadata: Vec::new(),
2926            }));
2927        }
2928
2929        // WASM tool
2930        if !self.allowlisted_tools.contains(name) {
2931            return Err(format!(
2932                "tool '{name}' is not declared in manifest allowlist"
2933            ));
2934        }
2935        self.dispatch_tool_async(name, input)
2936            .await
2937            .map(DispatchOutcome::tool)
2938    }
2939}
2940
2941fn read_artifact_info_from_manifest(
2942    manifest_path: &Path,
2943    fallback: &InstalledArtifactSummary,
2944) -> Result<manage::ArtifactInfo, String> {
2945    let manifest_content = fs::read_to_string(manifest_path)
2946        .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?;
2947
2948    let value: Value = serde_yaml::from_str(&manifest_content)
2949        .map_err(|err| format!("failed to parse {}: {err}", manifest_path.display()))?;
2950
2951    let root = value.as_mapping();
2952
2953    let description = root
2954        .and_then(|mapping| mapping.get(Value::String("description".to_string())))
2955        .and_then(Value::as_str)
2956        .unwrap_or_default()
2957        .to_string();
2958
2959    let tags = root
2960        .and_then(|mapping| mapping.get(Value::String("tags".to_string())))
2961        .and_then(Value::as_sequence)
2962        .map(|items| {
2963            items
2964                .iter()
2965                .filter_map(Value::as_str)
2966                .map(ToString::to_string)
2967                .collect::<Vec<_>>()
2968        })
2969        .unwrap_or_default();
2970
2971    let input_schema = root
2972        .and_then(|mapping| mapping.get(Value::String("input_schema".to_string())))
2973        .map(yaml_to_json_string)
2974        .transpose()?;
2975    let output_schema = root
2976        .and_then(|mapping| mapping.get(Value::String("output_schema".to_string())))
2977        .map(yaml_to_json_string)
2978        .transpose()?;
2979
2980    Ok(manage::ArtifactInfo {
2981        name: fallback.name.clone(),
2982        version: fallback.version.clone(),
2983        description,
2984        tags,
2985        runtime: runtime_type_to_wit(&fallback.runtime, fallback.implementation.as_ref()),
2986        input_schema,
2987        output_schema,
2988    })
2989}
2990
2991fn yaml_to_json_string(value: &Value) -> Result<String, String> {
2992    if let Value::String(s) = value {
2993        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
2994            return serde_json::to_string(&parsed)
2995                .map_err(|err| format!("failed to convert schema to JSON: {err}"));
2996        }
2997    }
2998    serde_json::to_string(value).map_err(|err| format!("failed to convert schema to JSON: {err}"))
2999}
3000
3001fn runtime_type_to_wit(
3002    runtime: &ArtifactRuntime,
3003    implementation: Option<&ArtifactImplementation>,
3004) -> manage::RuntimeType {
3005    match (runtime, implementation) {
3006        (ArtifactRuntime::Tool, Some(ArtifactImplementation::Native)) => {
3007            manage::RuntimeType::Native
3008        }
3009        _ => manage::RuntimeType::Wasm,
3010    }
3011}
3012
3013struct ToolStoreState {
3014    /// Resource limiter for this store — see [`CapsuleStoreState::limits`].
3015    limits: ExecutionLimiter,
3016    table: ResourceTable,
3017    wasi: WasiCtx,
3018    http: WasiHttpCtx,
3019    http_hooks: NetworkPolicyHooks,
3020}
3021
3022impl WasiView for ToolStoreState {
3023    fn ctx(&mut self) -> WasiCtxView<'_> {
3024        WasiCtxView {
3025            ctx: &mut self.wasi,
3026            table: &mut self.table,
3027        }
3028    }
3029}
3030
3031impl WasiHttpView for ToolStoreState {
3032    fn http(&mut self) -> WasiHttpCtxView<'_> {
3033        WasiHttpCtxView {
3034            ctx: &mut self.http,
3035            table: &mut self.table,
3036            hooks: &mut self.http_hooks,
3037        }
3038    }
3039}
3040
3041/// Write a single artifact's `murmur.yaml` under `<workdir>/tools/<name>/`.
3042///
3043/// Used both by `stage_session` (for every artifact declared in the manifest) and by
3044/// `manage.pull()` (for the single artifact it just fetched at runtime).
3045fn write_tool_manifest(
3046    workdir: &Path,
3047    name: &str,
3048    manifest_yaml: &str,
3049) -> Result<(), RuntimeError> {
3050    let manifest_path = workdir.join("tools").join(name).join(PACKED_MANIFEST_ENTRY);
3051    let Some(parent) = manifest_path.parent() else {
3052        return Err(RuntimeError::Runtime(format!(
3053            "failed to derive parent for tool manifest path {}",
3054            manifest_path.display()
3055        )));
3056    };
3057
3058    fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
3059        path: manifest_path.display().to_string(),
3060        source,
3061    })?;
3062    fs::write(&manifest_path, manifest_yaml).map_err(|source| RuntimeError::WriteToolManifest {
3063        path: manifest_path.display().to_string(),
3064        source,
3065    })
3066}
3067
3068fn install_native_binaries(
3069    workdir: &Path,
3070    native_binaries: Vec<(String, Vec<u8>)>,
3071) -> Result<(), RuntimeError> {
3072    for (name, bytes) in native_binaries {
3073        let binary_path = workdir.join("tools").join(&name).join(&name);
3074        let Some(parent) = binary_path.parent() else {
3075            return Err(RuntimeError::Runtime(format!(
3076                "failed to derive parent for native binary path {}",
3077                binary_path.display()
3078            )));
3079        };
3080        fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
3081            path: binary_path.display().to_string(),
3082            source,
3083        })?;
3084        fs::write(&binary_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
3085            path: binary_path.display().to_string(),
3086            source,
3087        })?;
3088        #[cfg(unix)]
3089        {
3090            use std::os::unix::fs::PermissionsExt;
3091            let mut perms = fs::metadata(&binary_path)
3092                .map_err(|source| RuntimeError::WriteToolManifest {
3093                    path: binary_path.display().to_string(),
3094                    source,
3095                })?
3096                .permissions();
3097            perms.set_mode(0o755);
3098            fs::set_permissions(&binary_path, perms).map_err(|source| {
3099                RuntimeError::WriteToolManifest {
3100                    path: binary_path.display().to_string(),
3101                    source,
3102                }
3103            })?;
3104        }
3105    }
3106    Ok(())
3107}
3108
3109/// Resolve and read a local-source skill's `skill.md` bytes.
3110///
3111/// `source` may be a relative or absolute path. Relative paths resolve against `manifest_dir`
3112/// (the directory containing `murmur.yaml`). The resolved path may be:
3113///   - a file (assumed to be `skill.md` itself), read directly, or
3114///   - a directory, in which case `skill.md` is located case-insensitively within it.
3115///
3116/// Errors before the workdir is created so that failures exit non-zero with no side effects.
3117fn load_local_skill_md(manifest_dir: &Path, source: &str) -> Result<Vec<u8>, RuntimeError> {
3118    let raw = Path::new(source);
3119    let path = if raw.is_absolute() {
3120        raw.to_path_buf()
3121    } else {
3122        manifest_dir.join(raw)
3123    };
3124
3125    if !path.exists() {
3126        return Err(RuntimeError::SkillSourceNotFound {
3127            path: path.display().to_string(),
3128        });
3129    }
3130
3131    let skill_md_path = if path.is_dir() {
3132        find_skill_md_in_dir(&path)?.ok_or_else(|| RuntimeError::SkillSourceMissingSkillMd {
3133            path: path.display().to_string(),
3134        })?
3135    } else {
3136        path.clone()
3137    };
3138
3139    fs::read(&skill_md_path).map_err(|source| RuntimeError::SkillSourceRead {
3140        path: skill_md_path.display().to_string(),
3141        source,
3142    })
3143}
3144
3145/// Locate `skill.md` in a directory, matching the filename case-insensitively. First match wins.
3146fn find_skill_md_in_dir(dir: &Path) -> Result<Option<std::path::PathBuf>, RuntimeError> {
3147    let entries = fs::read_dir(dir).map_err(|source| RuntimeError::SkillSourceRead {
3148        path: dir.display().to_string(),
3149        source,
3150    })?;
3151    for entry in entries {
3152        let entry = entry.map_err(|source| RuntimeError::SkillSourceRead {
3153            path: dir.display().to_string(),
3154            source,
3155        })?;
3156        if entry.file_name().to_string_lossy().to_lowercase() == "skill.md" {
3157            return Ok(Some(entry.path()));
3158        }
3159    }
3160    Ok(None)
3161}
3162
3163fn install_skill_files(
3164    workdir: &Path,
3165    skill_files: Vec<(String, Vec<u8>)>,
3166) -> Result<(), RuntimeError> {
3167    for (name, bytes) in skill_files {
3168        let skill_path = workdir.join("tools").join(&name).join("skill.md");
3169        let Some(parent) = skill_path.parent() else {
3170            return Err(RuntimeError::Runtime(format!(
3171                "failed to derive parent for skill.md path {}",
3172                skill_path.display()
3173            )));
3174        };
3175        fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
3176            path: skill_path.display().to_string(),
3177            source,
3178        })?;
3179        fs::write(&skill_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
3180            path: skill_path.display().to_string(),
3181            source,
3182        })?;
3183    }
3184    Ok(())
3185}
3186
3187fn write_shell_tool_manifests(workdir: &Path, shell_allow: &[String]) -> Result<(), RuntimeError> {
3188    for binary in shell_allow {
3189        let manifest_path = workdir
3190            .join("tools")
3191            .join(binary)
3192            .join(PACKED_MANIFEST_ENTRY);
3193
3194        if manifest_path.exists() {
3195            continue;
3196        }
3197
3198        let Some(parent) = manifest_path.parent() else {
3199            return Err(RuntimeError::Runtime(format!(
3200                "failed to derive parent for shell manifest path {}",
3201                manifest_path.display()
3202            )));
3203        };
3204
3205        fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
3206            path: manifest_path.display().to_string(),
3207            source,
3208        })?;
3209
3210        fs::write(&manifest_path, shell_tool_manifest_yaml(binary)).map_err(|source| {
3211            RuntimeError::WriteToolManifest {
3212                path: manifest_path.display().to_string(),
3213                source,
3214            }
3215        })?;
3216    }
3217
3218    Ok(())
3219}
3220
3221/// Execute a native artifact binary.
3222///
3223/// The binary receives the serialized ToolInput JSON on stdin and must write a valid
3224/// ToolResult JSON object to stdout. The binary's working directory is the capsule workdir.
3225fn dispatch_native_tool(
3226    name: &str,
3227    input: murmur::tool::run::ToolInput,
3228    binary_path: &Path,
3229    workdir: &Path,
3230    policy: &CapabilityPolicy,
3231    enforcement: &sandbox::ShellEnforcement,
3232) -> Result<murmur::tool::run::ToolResult, String> {
3233    use std::{
3234        io::Write,
3235        process::{Command, Stdio},
3236    };
3237
3238    let input_json = serde_json::to_string(&serde_json::json!({
3239        "data": input.data,
3240        "log_path": input.log_path,
3241    }))
3242    .map_err(|e| format!("failed to serialize input for native tool '{name}': {e}"))?;
3243
3244    enforcement.check_workdir_budget()?;
3245
3246    let env = build_shell_env(policy, &[], workdir)?;
3247
3248    // Bound to a local before spawning (rather than chained straight into `.spawn()`) so a
3249    // `pre_exec` step can be attached to it, mirroring `execute_shell`'s shape.
3250    let mut command = Command::new(binary_path);
3251    command
3252        .current_dir(workdir)
3253        .env_clear()
3254        .envs(env)
3255        .stdin(Stdio::piped())
3256        .stdout(Stdio::piped())
3257        .stderr(Stdio::piped());
3258
3259    // This path carries the same hard rlimits and the same cgroup membership as `execute_shell`,
3260    // but installs no seccomp filter and no Landlock scope. That asymmetry is deliberate and
3261    // open: a native-implementation artifact is bounded but not confined.
3262    sandbox::attach_process_limits(&mut command, enforcement);
3263    // Mark every fd >= 3 close-on-exec in the forked child, so this subprocess inherits only the
3264    // stdio pipes configured just above and nothing that merely happened to be open in the
3265    // runtime process. Fds 0/1/2 are deliberately excluded: this function writes the tool's
3266    // `ToolInput` JSON to the child's stdin and parses its `ToolResult` JSON back off stdout, so
3267    // stdio inheritance across `execve` is the point, not a leak — see
3268    // `sandbox::FD_HYGIENE_FIRST_FD` for the full recorded decision.
3269    //
3270    // This is the shared helper `execute_shell`'s path also runs (as the first step of
3271    // `sandbox::linux_enforce::child_install_enforcement`), so the two spawn paths cannot drift
3272    // apart on this dimension. It takes no policy input — fd hygiene is unconditional — and it
3273    // deliberately does not bring seccomp/Landlock enforcement with it; kernel sandboxing of
3274    // native tool subprocesses remains the documented gap it was.
3275    crate::sandbox::apply_fd_hygiene(&mut command);
3276
3277    let mut child = command
3278        .spawn()
3279        .map_err(|e| format!("failed to spawn native tool '{name}': {e}"))?;
3280
3281    if let Some(mut stdin) = child.stdin.take() {
3282        let _ = stdin.write_all(input_json.as_bytes());
3283        // stdin closes when dropped, signalling EOF to the child
3284    }
3285
3286    let output = child
3287        .wait_with_output()
3288        .map_err(|e| format!("native tool '{name}' failed to complete: {e}"))?;
3289
3290    let stdout = String::from_utf8_lossy(&output.stdout);
3291    let stderr = String::from_utf8_lossy(&output.stderr);
3292
3293    if stdout.trim().is_empty() {
3294        return Ok(murmur::tool::run::ToolResult {
3295            status: if output.status.success() {
3296                murmur::tool::run::Status::Passed
3297            } else {
3298                murmur::tool::run::Status::Error
3299            },
3300            summary: Some(format!(
3301                "native tool '{}' exited with {}",
3302                name, output.status
3303            )),
3304            data: if stderr.is_empty() {
3305                None
3306            } else {
3307                Some(stderr.to_string())
3308            },
3309            data_path: None,
3310            truncated: false,
3311            metadata: Vec::new(),
3312        });
3313    }
3314
3315    match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
3316        Ok(json) => {
3317            let status = match json.get("status").and_then(|s| s.as_str()) {
3318                Some("passed") => murmur::tool::run::Status::Passed,
3319                Some("failed") => murmur::tool::run::Status::Failed,
3320                _ => murmur::tool::run::Status::Error,
3321            };
3322            let summary = json
3323                .get("summary")
3324                .and_then(|s| s.as_str())
3325                .map(|s| s.to_string());
3326            let data = json
3327                .get("data")
3328                .and_then(|d| d.as_str())
3329                .map(|s| s.to_string())
3330                .or_else(|| {
3331                    json.get("data")
3332                        .filter(|d| !d.is_null())
3333                        .map(|d| d.to_string())
3334                });
3335            Ok(murmur::tool::run::ToolResult {
3336                status,
3337                summary,
3338                data,
3339                data_path: None,
3340                truncated: false,
3341                metadata: Vec::new(),
3342            })
3343        }
3344        Err(_) => Ok(murmur::tool::run::ToolResult {
3345            status: if output.status.success() {
3346                murmur::tool::run::Status::Passed
3347            } else {
3348                murmur::tool::run::Status::Error
3349            },
3350            summary: Some(format!("native tool '{}' completed", name)),
3351            data: Some(stdout.to_string()),
3352            data_path: None,
3353            truncated: false,
3354            metadata: Vec::new(),
3355        }),
3356    }
3357}
3358
3359fn dispatch_shell_tool(
3360    name: &str,
3361    input: murmur::tool::run::ToolInput,
3362    workdir: &Path,
3363    env_overrides: &[(String, String)],
3364    policy: &CapabilityPolicy,
3365    enforcement: &sandbox::ShellEnforcement,
3366) -> DispatchOutcome {
3367    let command = match extract_shell_command(&input) {
3368        Ok(command) => command,
3369        Err(error) => {
3370            return DispatchOutcome::tool(murmur::tool::run::ToolResult {
3371                status: murmur::tool::run::Status::Error,
3372                summary: Some("shell command parsing failed".to_string()),
3373                data: Some(error),
3374                data_path: None,
3375                truncated: false,
3376                metadata: Vec::new(),
3377            });
3378        }
3379    };
3380
3381    let split_args: Vec<String>;
3382    let args: Vec<&str> = if is_shell_interpreter(name) {
3383        vec!["-c", command.as_str()]
3384    } else {
3385        split_args = split_shell_words(&command);
3386        split_args.iter().map(String::as_str).collect()
3387    };
3388
3389    match execute_shell(name, &args, env_overrides, workdir, policy, enforcement) {
3390        Ok(result) => {
3391            let shell = ShellDispatchInfo {
3392                // `name` is the invoked binary (each `capabilities.shell.allow` entry is
3393                // exposed as its own tool), resolved to a path by `execute_shell`.
3394                binary: result.binary.clone(),
3395                command: command.clone(),
3396                exit_code: result.exit_code,
3397                stdout: result.stdout.clone(),
3398                stderr: result.stderr.clone(),
3399                stdout_bytes: result.stdout.len() as u64,
3400                stderr_bytes: result.stderr.len() as u64,
3401                duration_ms: result.duration_ms,
3402                resource_limit: result.resource_limit_hit.clone(),
3403            };
3404            DispatchOutcome {
3405                result: shell_result_to_tool_result(&command, result),
3406                shell: Some(shell),
3407                is_skill: false,
3408                fatal: None,
3409            }
3410        }
3411        Err(error) => DispatchOutcome {
3412            // The tool result is filled in either way, so the trace records the call that was
3413            // attempted; `fatal` is what tells the agent turn loop that this particular failure
3414            // is not one the capsule gets another turn to react to.
3415            result: murmur::tool::run::ToolResult {
3416                status: murmur::tool::run::Status::Error,
3417                summary: Some("shell execution failed".to_string()),
3418                data: Some(error.to_string()),
3419                data_path: None,
3420                truncated: false,
3421                metadata: Vec::new(),
3422            },
3423            shell: None,
3424            is_skill: false,
3425            fatal: error.session_fatal(),
3426        },
3427    }
3428}
3429
3430fn extract_shell_command(input: &murmur::tool::run::ToolInput) -> Result<String, String> {
3431    let data = input
3432        .data
3433        .as_deref()
3434        .ok_or_else(|| "shell tool input.data is required".to_string())?;
3435
3436    let json: serde_json::Value = serde_json::from_str(data)
3437        .map_err(|error| format!("shell tool input must be valid JSON: {error}"))?;
3438
3439    let command = json
3440        .get("command")
3441        .and_then(serde_json::Value::as_str)
3442        .map(str::trim)
3443        .filter(|value| !value.is_empty())
3444        .ok_or_else(|| "shell tool input must include a non-empty 'command' string".to_string())?;
3445
3446    Ok(command.to_string())
3447}
3448
3449fn shell_result_to_tool_result(
3450    command: &str,
3451    result: ShellResult,
3452) -> murmur::tool::run::ToolResult {
3453    let mut data = format!(
3454        "$ {}\nExit code: {}\nStdout:\n{}\nStderr:\n{}",
3455        command, result.exit_code, result.stdout, result.stderr
3456    );
3457
3458    let mut metadata = Vec::new();
3459    if result.truncated {
3460        if let Some(path) = result.full_output_path.as_ref() {
3461            data.push_str(&format!(
3462                "\n\nOutput truncated. Full output written to {path}"
3463            ));
3464            metadata.push(("full_output_path".to_string(), path.clone()));
3465        } else {
3466            data.push_str("\n\nOutput truncated.");
3467        }
3468    }
3469
3470    murmur::tool::run::ToolResult {
3471        status: murmur::tool::run::Status::Passed,
3472        summary: Some(format!(
3473            "Shell command exited with code {}",
3474            result.exit_code
3475        )),
3476        data: Some(data),
3477        data_path: None,
3478        truncated: result.truncated,
3479        metadata,
3480    }
3481}
3482
3483fn resolve_context_window(context: Option<&ContextConfig>) -> u32 {
3484    context.and_then(|c| c.max_tokens).unwrap_or(0)
3485}
3486
3487fn validate_capability_policy(policy: &CapabilityPolicy) -> Result<(), RuntimeError> {
3488    parse_network_allow_rules(&policy.network_allow)?;
3489    if let Some(scope) = policy.filesystem_scope.as_deref() {
3490        validate_filesystem_scope(scope)?;
3491    }
3492
3493    Ok(())
3494}
3495
3496fn enforce_allowlist<T, F>(
3497    allowlisted_tools: &HashSet<String>,
3498    name: &str,
3499    dispatch: F,
3500) -> Result<T, String>
3501where
3502    F: FnOnce() -> Result<T, String>,
3503{
3504    if !allowlisted_tools.contains(name) {
3505        return Err(format!(
3506            "tool '{name}' is not declared in manifest allowlist"
3507        ));
3508    }
3509
3510    dispatch()
3511}
3512
3513fn generate_session_id() -> String {
3514    format!("ses_{}", uuid::Uuid::now_v7().simple())
3515}
3516
3517fn resolve_lifecycle(
3518    base: Option<LifecycleConfig>,
3519    override_: Option<&murmur_artifact::LifecycleOverride>,
3520) -> LifecycleConfig {
3521    let mut config = base.unwrap_or_default();
3522    if let Some(ov) = override_ {
3523        if let Some(ta) = &ov.task_acceptance {
3524            config.task_acceptance = ta.clone();
3525        }
3526        if let Some(at) = &ov.after_task {
3527            config.after_task = at.clone();
3528        }
3529    }
3530    config
3531}
3532
3533#[cfg(test)]
3534mod tests {
3535    use murmur_artifact::{ArtifactMeta, ArtifactRuntime, Registry, ResolvedArtifact, RuntimeType};
3536    use tempfile::TempDir;
3537
3538    use super::*;
3539
3540    fn bootstrap_log_contents(workdir: &Path) -> String {
3541        fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
3542    }
3543
3544    /// An `InferenceConfig` with every system-prompt field empty, for the prompt-resolution
3545    /// tests below to fill in one at a time.
3546    fn inference_without_prompt() -> murmur_artifact::InferenceConfig {
3547        murmur_artifact::InferenceConfig {
3548            transport: "http".to_string(),
3549            endpoint: Some("http://127.0.0.1:1".to_string()),
3550            model: "test-model".to_string(),
3551            api_key: None,
3552            driver: None,
3553            command: None,
3554            compaction: None,
3555            system_prompt: None,
3556            system_prompt_file: None,
3557            system_prompt_artifact: None,
3558            max_turns: 10,
3559            max_task_reopens: 1,
3560            max_tokens: None,
3561        }
3562    }
3563
3564    #[test]
3565    fn resolve_system_prompt_returns_none_when_nothing_is_declared() {
3566        let tmp = TempDir::new().unwrap();
3567        let resolved =
3568            resolve_system_prompt(tmp.path(), tmp.path(), &inference_without_prompt()).unwrap();
3569        assert_eq!(resolved, None);
3570    }
3571
3572    #[test]
3573    fn resolve_system_prompt_returns_the_inline_prompt_verbatim() {
3574        let tmp = TempDir::new().unwrap();
3575        let inference = murmur_artifact::InferenceConfig {
3576            system_prompt: Some("Be terse.".to_string()),
3577            ..inference_without_prompt()
3578        };
3579        let resolved = resolve_system_prompt(tmp.path(), tmp.path(), &inference).unwrap();
3580        assert_eq!(resolved.as_deref(), Some("Be terse."));
3581    }
3582
3583    /// File contents are used exactly as they sit on disk — trailing newline included. The
3584    /// trimming that applies to the inline form happens at manifest parse time and has no
3585    /// counterpart here.
3586    #[test]
3587    fn resolve_system_prompt_reads_the_prompt_file_verbatim_relative_to_the_manifest_dir() {
3588        let manifest_dir = TempDir::new().unwrap();
3589        let workdir = TempDir::new().unwrap();
3590        fs::write(manifest_dir.path().join("conventions.md"), "  Be terse.\n").unwrap();
3591
3592        let inference = murmur_artifact::InferenceConfig {
3593            system_prompt_file: Some("conventions.md".to_string()),
3594            ..inference_without_prompt()
3595        };
3596        let resolved =
3597            resolve_system_prompt(manifest_dir.path(), workdir.path(), &inference).unwrap();
3598        assert_eq!(resolved.as_deref(), Some("  Be terse.\n"));
3599    }
3600
3601    #[test]
3602    fn resolve_system_prompt_errors_when_the_prompt_file_is_missing() {
3603        let tmp = TempDir::new().unwrap();
3604        let inference = murmur_artifact::InferenceConfig {
3605            system_prompt_file: Some("missing-conventions.md".to_string()),
3606            ..inference_without_prompt()
3607        };
3608        match resolve_system_prompt(tmp.path(), tmp.path(), &inference) {
3609            Err(RuntimeError::SystemPromptFileRead { path, .. }) => {
3610                assert!(path.ends_with("missing-conventions.md"), "got {path}");
3611            }
3612            other => panic!("expected SystemPromptFileRead, got {other:?}"),
3613        }
3614    }
3615
3616    /// The artifact branch reads the staged skill's `skill.md` out of the *workdir*, not the
3617    /// manifest directory — it is a resolved artifact, not a file the author wrote beside
3618    /// murmur.yaml.
3619    #[test]
3620    fn resolve_system_prompt_reads_skill_md_from_the_staged_artifact() {
3621        let manifest_dir = TempDir::new().unwrap();
3622        let workdir = TempDir::new().unwrap();
3623        let skill_dir = workdir.path().join("tools").join("house-style");
3624        fs::create_dir_all(&skill_dir).unwrap();
3625        fs::write(skill_dir.join("skill.md"), "# House style\nBe terse.").unwrap();
3626
3627        let inference = murmur_artifact::InferenceConfig {
3628            system_prompt_artifact: Some("house-style".to_string()),
3629            ..inference_without_prompt()
3630        };
3631        let resolved =
3632            resolve_system_prompt(manifest_dir.path(), workdir.path(), &inference).unwrap();
3633        assert_eq!(resolved.as_deref(), Some("# House style\nBe terse."));
3634    }
3635
3636    #[test]
3637    fn resolve_system_prompt_errors_when_the_prompt_artifact_has_no_skill_md() {
3638        let tmp = TempDir::new().unwrap();
3639        let inference = murmur_artifact::InferenceConfig {
3640            system_prompt_artifact: Some("house-style".to_string()),
3641            ..inference_without_prompt()
3642        };
3643        match resolve_system_prompt(tmp.path(), tmp.path(), &inference) {
3644            Err(RuntimeError::SystemPromptArtifactRead { name, .. }) => {
3645                assert_eq!(name, "house-style");
3646            }
3647            other => panic!("expected SystemPromptArtifactRead, got {other:?}"),
3648        }
3649    }
3650
3651    /// `mur run --system-prompt` overrides by clearing the other two fields and setting the
3652    /// inline one, so resolution never reaches a declaration the operator replaced — including
3653    /// a `system_prompt_file` pointing at a file that does not exist.
3654    #[test]
3655    fn resolve_system_prompt_ignores_cleared_declarations() {
3656        let tmp = TempDir::new().unwrap();
3657        let inference = murmur_artifact::InferenceConfig {
3658            system_prompt: Some("CLI prompt".to_string()),
3659            system_prompt_file: None,
3660            system_prompt_artifact: None,
3661            ..inference_without_prompt()
3662        };
3663        let resolved = resolve_system_prompt(tmp.path(), tmp.path(), &inference).unwrap();
3664        assert_eq!(resolved.as_deref(), Some("CLI prompt"));
3665    }
3666
3667    #[test]
3668    fn bash_and_network_together_trigger_warning() {
3669        let tmp = TempDir::new().unwrap();
3670        let policy = CapabilityPolicy {
3671            shell_allow: vec!["bash".to_string()],
3672            network_allow: vec!["https://api.example.com".to_string()],
3673            ..Default::default()
3674        };
3675        warn_if_bash_network_bypass(tmp.path(), &policy);
3676        let log = bootstrap_log_contents(tmp.path());
3677        assert!(log.contains("bash"), "log should mention bash: {log}");
3678        assert!(log.contains("network"), "log should mention network: {log}");
3679        assert!(
3680            log.contains(W_SEC_003),
3681            "log should carry its warning code: {log}"
3682        );
3683        assert!(
3684            log.contains(&security_warning_link(W_SEC_003)),
3685            "log should link to the diagnostics doc page: {log}"
3686        );
3687    }
3688
3689    fn compaction_config(
3690        system_prompt: Option<&str>,
3691        system_prompt_file: Option<&str>,
3692    ) -> murmur_artifact::CompactionConfig {
3693        murmur_artifact::CompactionConfig {
3694            threshold: None,
3695            model: Some("compaction-model".to_string()),
3696            system_prompt: system_prompt.map(str::to_string),
3697            system_prompt_file: system_prompt_file.map(str::to_string),
3698            dump_summaries: None,
3699        }
3700    }
3701
3702    /// `system_prompt_file` is read relative to the manifest directory — not the process
3703    /// cwd — and its contents reach the hook verbatim, newlines and all.
3704    #[test]
3705    fn compaction_system_prompt_file_resolves_relative_to_manifest_dir() {
3706        let manifest_dir = TempDir::new().unwrap();
3707        let body = "Summarize aggressively.\nKeep file paths.\n";
3708        fs::write(manifest_dir.path().join("compaction-instructions.md"), body).unwrap();
3709
3710        let resolved = resolve_compaction_system_prompt(
3711            manifest_dir.path(),
3712            Some(&compaction_config(None, Some("compaction-instructions.md"))),
3713        )
3714        .expect("file resolves");
3715
3716        assert_eq!(resolved, Some(body.to_string()));
3717    }
3718
3719    /// The inline field keeps its pre-existing behavior: returned as-is, no file touched.
3720    #[test]
3721    fn compaction_inline_system_prompt_resolves_without_reading_a_file() {
3722        let manifest_dir = TempDir::new().unwrap();
3723
3724        let resolved = resolve_compaction_system_prompt(
3725            manifest_dir.path(),
3726            Some(&compaction_config(Some("inline prompt"), None)),
3727        )
3728        .expect("inline prompt resolves");
3729
3730        assert_eq!(resolved, Some("inline prompt".to_string()));
3731    }
3732
3733    /// Neither prompt source set — and no `compaction:` block at all — both stay `None`;
3734    /// nothing on this path substitutes a default prompt.
3735    #[test]
3736    fn compaction_system_prompt_absent_resolves_to_none() {
3737        let manifest_dir = TempDir::new().unwrap();
3738
3739        assert_eq!(
3740            resolve_compaction_system_prompt(
3741                manifest_dir.path(),
3742                Some(&compaction_config(None, None))
3743            )
3744            .unwrap(),
3745            None
3746        );
3747        assert_eq!(
3748            resolve_compaction_system_prompt(manifest_dir.path(), None).unwrap(),
3749            None
3750        );
3751    }
3752
3753    /// A missing file fails with the compaction-specific variant — distinguishable by
3754    /// variant, not just message text, from the primary prompt's `SystemPromptFileRead` —
3755    /// and names the resolved path.
3756    #[test]
3757    fn compaction_system_prompt_file_missing_reports_compaction_variant() {
3758        let manifest_dir = TempDir::new().unwrap();
3759
3760        let err = resolve_compaction_system_prompt(
3761            manifest_dir.path(),
3762            Some(&compaction_config(None, Some("nope.md"))),
3763        )
3764        .expect_err("missing file must fail");
3765
3766        match &err {
3767            RuntimeError::CompactionSystemPromptFileRead { path, .. } => {
3768                assert!(
3769                    path.ends_with("nope.md"),
3770                    "error should name the resolved path, got {path}"
3771                );
3772                assert!(
3773                    path.starts_with(&manifest_dir.path().display().to_string()),
3774                    "path should be manifest-dir relative, got {path}"
3775                );
3776            }
3777            other => panic!("expected CompactionSystemPromptFileRead, got {other:?}"),
3778        }
3779        assert!(err
3780            .to_string()
3781            .contains("inference.compaction.system_prompt_file"));
3782    }
3783
3784    #[test]
3785    fn network_without_bash_does_not_warn() {
3786        let tmp = TempDir::new().unwrap();
3787        let policy = CapabilityPolicy {
3788            shell_allow: vec!["cargo".to_string(), "git".to_string()],
3789            network_allow: vec!["https://api.example.com".to_string()],
3790            ..Default::default()
3791        };
3792        warn_if_bash_network_bypass(tmp.path(), &policy);
3793        assert!(bootstrap_log_contents(tmp.path()).is_empty());
3794    }
3795
3796    #[test]
3797    fn bash_without_network_does_not_warn() {
3798        let tmp = TempDir::new().unwrap();
3799        let policy = CapabilityPolicy {
3800            shell_allow: vec!["bash".to_string()],
3801            network_allow: Vec::new(),
3802            ..Default::default()
3803        };
3804        warn_if_bash_network_bypass(tmp.path(), &policy);
3805        assert!(bootstrap_log_contents(tmp.path()).is_empty());
3806    }
3807
3808    #[test]
3809    fn neither_declared_does_not_warn() {
3810        let tmp = TempDir::new().unwrap();
3811        warn_if_bash_network_bypass(tmp.path(), &CapabilityPolicy::default());
3812        assert!(bootstrap_log_contents(tmp.path()).is_empty());
3813    }
3814
3815    #[test]
3816    fn non_bash_shell_interpreter_does_not_warn() {
3817        let tmp = TempDir::new().unwrap();
3818        let policy = CapabilityPolicy {
3819            shell_allow: vec!["sh".to_string()],
3820            network_allow: vec!["https://api.example.com".to_string()],
3821            ..Default::default()
3822        };
3823        warn_if_bash_network_bypass(tmp.path(), &policy);
3824        assert!(
3825            bootstrap_log_contents(tmp.path()).is_empty(),
3826            "exact match on \"bash\" literal must not fire for other shell interpreters like sh"
3827        );
3828    }
3829
3830    #[test]
3831    fn allowlist_blocks_unlisted_before_dispatch() {
3832        let allowlist = HashSet::from(["echo-tool".to_string()]);
3833        let mut dispatched = false;
3834
3835        let result = enforce_allowlist(&allowlist, "missing-tool", || {
3836            dispatched = true;
3837            Ok::<_, String>(())
3838        });
3839
3840        assert!(!dispatched);
3841        assert!(result.is_err());
3842        assert!(result
3843            .unwrap_err()
3844            .contains("not declared in manifest allowlist"));
3845    }
3846
3847    #[test]
3848    fn allowlist_dispatches_listed_tool() {
3849        let allowlist = HashSet::from(["echo-tool".to_string()]);
3850        let result = enforce_allowlist(&allowlist, "echo-tool", || Ok::<_, String>("ok")).unwrap();
3851        assert_eq!(result, "ok");
3852    }
3853
3854    /// Builds a component that exports a single empty instance under `iface`.
3855    /// `resolve_versioned_iface` only looks up the export index by instance
3856    /// name — it never inspects the instance's contents — so an empty instance
3857    /// is sufficient to exercise its versioned-name probe.
3858    fn iface_double(engine: &wasmtime::Engine, iface: &str) -> wasmtime::component::Component {
3859        let wat = format!(
3860            "(component\n\
3861             (instance $i)\n\
3862             (export \"{iface}\" (instance $i))\n\
3863             )"
3864        );
3865        let bytes = wat::parse_str(&wat).expect("component WAT parses");
3866        wasmtime::component::Component::new(engine, bytes).expect("component compiles")
3867    }
3868
3869    fn iface_test_engine() -> wasmtime::Engine {
3870        let mut config = wasmtime::Config::new();
3871        config.wasm_component_model(true);
3872        wasmtime::Engine::new(&config).expect("engine builds")
3873    }
3874
3875    fn instantiate_iface_double(
3876        engine: &wasmtime::Engine,
3877        iface: &str,
3878    ) -> (wasmtime::component::Instance, Store<()>) {
3879        let component = iface_double(engine, iface);
3880        let linker = wasmtime::component::Linker::new(engine);
3881        let mut store = Store::new(engine, ());
3882        let instance = linker
3883            .instantiate(&mut store, &component)
3884            .expect("component with no imports instantiates");
3885        (instance, store)
3886    }
3887
3888    /// A component exporting the versioned instance name (the shape a guest
3889    /// built against the semver'd WIT carries) resolves via the versioned probe.
3890    #[test]
3891    fn resolve_versioned_iface_finds_versioned_name() {
3892        let engine = iface_test_engine();
3893        let (instance, mut store) = instantiate_iface_double(&engine, WIT_CAPSULE_IFACE_VERSIONED);
3894        let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
3895        assert!(
3896            found.is_some(),
3897            "a component exporting the versioned name must resolve"
3898        );
3899    }
3900
3901    /// A component exporting only the legacy unversioned instance name no longer
3902    /// resolves — the fallback probe was removed, so the versioned-only lookup
3903    /// returns `None` and the call site surfaces a hard missing-export error.
3904    #[test]
3905    fn resolve_versioned_iface_rejects_unversioned_only_name() {
3906        let engine = iface_test_engine();
3907        let (instance, mut store) = instantiate_iface_double(&engine, "murmur:tool/run");
3908        let found = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED);
3909        assert!(
3910            found.is_none(),
3911            "a component exporting only the legacy unversioned name must no longer resolve"
3912        );
3913    }
3914
3915    /// A component exporting neither the versioned name nor any recognizable
3916    /// name resolves to `None` — the probe must not silently swallow a genuinely
3917    /// absent interface.
3918    #[test]
3919    fn resolve_versioned_iface_returns_none_when_neither_name_matches() {
3920        let engine = iface_test_engine();
3921        let (instance, mut store) = instantiate_iface_double(&engine, "murmur:capsule/nonexistent");
3922        let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
3923        assert!(
3924            found.is_none(),
3925            "a component exporting neither probed name must not resolve"
3926        );
3927    }
3928
3929    #[test]
3930    fn generate_session_id_is_unique() {
3931        let first = generate_session_id();
3932        let second = generate_session_id();
3933
3934        assert_ne!(first, second);
3935        assert!(first.starts_with("ses_"), "session id must start with ses_");
3936        assert_eq!(
3937            first.len(),
3938            36,
3939            "session id must be 36 chars (ses_ + 32 hex)"
3940        );
3941        uuid::Uuid::parse_str(&first[4..]).expect("session id suffix should be a valid UUID");
3942    }
3943
3944    #[test]
3945    fn sha_verification_happens_before_component_compile() {
3946        struct FakeRegistry;
3947
3948        impl Registry for FakeRegistry {
3949            fn resolve(
3950                &self,
3951                name: &str,
3952                version: &str,
3953            ) -> Result<ResolvedArtifact, RegistryError> {
3954                Ok(ResolvedArtifact {
3955                    meta: ArtifactMeta {
3956                        name: name.to_string(),
3957                        version: version.to_string(),
3958                        runtime: RuntimeType::Wasm,
3959                        artifact_runtime: "wasm".to_string(),
3960                        platforms: Vec::new(),
3961                        description: None,
3962                        tags: Vec::new(),
3963                    },
3964                    bytes: b"not-a-real-zip".to_vec().into(),
3965                    sha256: "definitely-wrong".to_string(),
3966                })
3967            }
3968
3969            fn publish(
3970                &self,
3971                _meta: ArtifactMeta,
3972                _bytes: &[u8],
3973            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
3974                unreachable!()
3975            }
3976
3977            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
3978                unreachable!()
3979            }
3980        }
3981
3982        let tempdir = tempfile::tempdir().unwrap();
3983        let request = StageRequest {
3984            manifest_dir: tempdir.path().to_path_buf(),
3985            capsule_name: "test".to_string(),
3986            capsule_version: String::new(),
3987            capsule_component_bytes: b"not-a-component".to_vec(),
3988            artifacts: vec![crate::types::ArtifactRequest {
3989                name: "echo-tool".to_string(),
3990                version: "0.0.1".to_string(),
3991                runtime: ArtifactRuntime::Tool,
3992                source: None,
3993                on_overflow: Default::default(),
3994                capabilities: None,
3995            }],
3996            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
3997            lock_expectations: None,
3998            capability_policy: CapabilityPolicy::default(),
3999            inference: None,
4000            system_prompt_overridden: false,
4001            context: None,
4002            otel_endpoint: None,
4003            eval_config_json: None,
4004            case_id: None,
4005            dataset_id: None,
4006            lifecycle: None,
4007            lifecycle_override: None,
4008            trace: None,
4009            workdir: None,
4010            bind_addr: "127.0.0.1".to_string(),
4011            internal_port: None,
4012            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4013        };
4014
4015        let err = match stage_session(Arc::new(FakeRegistry), request) {
4016            Ok(_) => panic!("expected stage_session to fail"),
4017            Err(err) => err,
4018        };
4019        assert!(matches!(
4020            err,
4021            RuntimeError::ArtifactIntegrityFailed { name, version }
4022                if name == "echo-tool" && version == "0.0.1"
4023        ));
4024    }
4025
4026    #[test]
4027    fn lock_expectation_mismatch_returns_integrity_failure() {
4028        struct FakeRegistry;
4029
4030        impl Registry for FakeRegistry {
4031            fn resolve(
4032                &self,
4033                name: &str,
4034                version: &str,
4035            ) -> Result<ResolvedArtifact, RegistryError> {
4036                Ok(ResolvedArtifact {
4037                    meta: ArtifactMeta {
4038                        name: name.to_string(),
4039                        version: version.to_string(),
4040                        runtime: RuntimeType::Wasm,
4041                        artifact_runtime: "wasm".to_string(),
4042                        platforms: Vec::new(),
4043                        description: None,
4044                        tags: Vec::new(),
4045                    },
4046                    bytes: b"not-a-real-zip".to_vec().into(),
4047                    sha256: murmur_artifact::sha256_hex(b"not-a-real-zip"),
4048                })
4049            }
4050
4051            fn publish(
4052                &self,
4053                _meta: ArtifactMeta,
4054                _bytes: &[u8],
4055            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4056                unreachable!()
4057            }
4058
4059            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4060                unreachable!()
4061            }
4062        }
4063
4064        let tempdir = tempfile::tempdir().unwrap();
4065        let request = StageRequest {
4066            manifest_dir: tempdir.path().to_path_buf(),
4067            capsule_name: "test".to_string(),
4068            capsule_version: String::new(),
4069            capsule_component_bytes: Vec::new(),
4070            artifacts: vec![crate::types::ArtifactRequest {
4071                name: "echo-tool".to_string(),
4072                version: "0.0.1".to_string(),
4073                runtime: ArtifactRuntime::Tool,
4074                source: None,
4075                on_overflow: Default::default(),
4076                capabilities: None,
4077            }],
4078            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
4079            lock_expectations: Some(vec![crate::types::LockExpectation {
4080                name: "echo-tool".to_string(),
4081                resolved_version: "0.0.1".to_string(),
4082                sha256_wasm: "different".to_string(),
4083            }]),
4084            capability_policy: CapabilityPolicy::default(),
4085            inference: None,
4086            system_prompt_overridden: false,
4087            context: None,
4088            otel_endpoint: None,
4089            eval_config_json: None,
4090            case_id: None,
4091            dataset_id: None,
4092            lifecycle: None,
4093            lifecycle_override: None,
4094            trace: None,
4095            workdir: None,
4096            bind_addr: "127.0.0.1".to_string(),
4097            internal_port: None,
4098            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4099        };
4100
4101        let err = match stage_session(Arc::new(FakeRegistry), request) {
4102            Ok(_) => panic!("expected stage_session to fail"),
4103            Err(err) => err,
4104        };
4105        assert!(matches!(
4106            err,
4107            RuntimeError::ArtifactIntegrityFailed { name, version }
4108                if name == "echo-tool" && version == "0.0.1"
4109        ));
4110    }
4111
4112    #[test]
4113    fn lock_expectations_require_entry_for_each_manifest_artifact() {
4114        struct FakeRegistry;
4115
4116        impl Registry for FakeRegistry {
4117            fn resolve(
4118                &self,
4119                _name: &str,
4120                _version: &str,
4121            ) -> Result<ResolvedArtifact, RegistryError> {
4122                unreachable!("lock validation should fail before any registry resolve call")
4123            }
4124
4125            fn publish(
4126                &self,
4127                _meta: ArtifactMeta,
4128                _bytes: &[u8],
4129            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4130                unreachable!()
4131            }
4132
4133            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4134                unreachable!()
4135            }
4136        }
4137
4138        let tempdir = tempfile::tempdir().unwrap();
4139        let request = StageRequest {
4140            manifest_dir: tempdir.path().to_path_buf(),
4141            capsule_name: "test".to_string(),
4142            capsule_version: String::new(),
4143            capsule_component_bytes: Vec::new(),
4144            artifacts: vec![crate::types::ArtifactRequest {
4145                name: "echo-tool".to_string(),
4146                version: "0.0.1".to_string(),
4147                runtime: ArtifactRuntime::Tool,
4148                source: None,
4149                on_overflow: Default::default(),
4150                capabilities: None,
4151            }],
4152            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
4153            lock_expectations: Some(vec![crate::types::LockExpectation {
4154                name: "different-tool".to_string(),
4155                resolved_version: "0.0.1".to_string(),
4156                sha256_wasm: "abc".to_string(),
4157            }]),
4158            capability_policy: CapabilityPolicy::default(),
4159            inference: None,
4160            system_prompt_overridden: false,
4161            context: None,
4162            otel_endpoint: None,
4163            eval_config_json: None,
4164            case_id: None,
4165            dataset_id: None,
4166            lifecycle: None,
4167            lifecycle_override: None,
4168            trace: None,
4169            workdir: None,
4170            bind_addr: "127.0.0.1".to_string(),
4171            internal_port: None,
4172            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4173        };
4174
4175        let err = match stage_session(Arc::new(FakeRegistry), request) {
4176            Ok(_) => panic!("expected stage_session to fail"),
4177            Err(err) => err,
4178        };
4179        assert!(matches!(
4180            err,
4181            RuntimeError::LockMissingEntry { name } if name == "echo-tool"
4182        ));
4183    }
4184
4185    #[test]
4186    fn lock_expectation_version_mismatch_is_reported() {
4187        struct FakeRegistry;
4188
4189        impl Registry for FakeRegistry {
4190            fn resolve(
4191                &self,
4192                _name: &str,
4193                _version: &str,
4194            ) -> Result<ResolvedArtifact, RegistryError> {
4195                unreachable!("version mismatch should fail before registry resolve")
4196            }
4197
4198            fn publish(
4199                &self,
4200                _meta: ArtifactMeta,
4201                _bytes: &[u8],
4202            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4203                unreachable!()
4204            }
4205
4206            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4207                unreachable!()
4208            }
4209        }
4210
4211        let tempdir = tempfile::tempdir().unwrap();
4212        let request = StageRequest {
4213            manifest_dir: tempdir.path().to_path_buf(),
4214            capsule_name: "test".to_string(),
4215            capsule_version: String::new(),
4216            capsule_component_bytes: Vec::new(),
4217            artifacts: vec![crate::types::ArtifactRequest {
4218                name: "echo-tool".to_string(),
4219                version: "0.0.9".to_string(),
4220                runtime: ArtifactRuntime::Tool,
4221                source: None,
4222                on_overflow: Default::default(),
4223                capabilities: None,
4224            }],
4225            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
4226            lock_expectations: Some(vec![crate::types::LockExpectation {
4227                name: "echo-tool".to_string(),
4228                resolved_version: "0.0.1".to_string(),
4229                sha256_wasm: "abc".to_string(),
4230            }]),
4231            capability_policy: CapabilityPolicy::default(),
4232            inference: None,
4233            system_prompt_overridden: false,
4234            context: None,
4235            otel_endpoint: None,
4236            eval_config_json: None,
4237            case_id: None,
4238            dataset_id: None,
4239            lifecycle: None,
4240            lifecycle_override: None,
4241            trace: None,
4242            workdir: None,
4243            bind_addr: "127.0.0.1".to_string(),
4244            internal_port: None,
4245            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4246        };
4247
4248        let err = match stage_session(Arc::new(FakeRegistry), request) {
4249            Ok(_) => panic!("expected stage_session to fail"),
4250            Err(err) => err,
4251        };
4252        assert!(matches!(
4253            err,
4254            RuntimeError::LockVersionMismatch {
4255                name,
4256                requested,
4257                pinned,
4258            } if name == "echo-tool" && requested == "0.0.9" && pinned == "0.0.1"
4259        ));
4260    }
4261
4262    // ── local-source skill resolution ──────────────────────────────────────
4263
4264    #[test]
4265    fn load_local_skill_md_reads_file_path() {
4266        let dir = tempfile::tempdir().unwrap();
4267        let skill = dir.path().join("skill.md");
4268        fs::write(&skill, b"# hi from file").unwrap();
4269        let bytes = load_local_skill_md(dir.path(), "skill.md").unwrap();
4270        assert_eq!(bytes, b"# hi from file");
4271    }
4272
4273    #[test]
4274    fn load_local_skill_md_finds_skill_md_in_directory_case_insensitive() {
4275        let manifest_dir = tempfile::tempdir().unwrap();
4276        let skill_dir = manifest_dir.path().join("skills").join("my-skill");
4277        fs::create_dir_all(&skill_dir).unwrap();
4278        // Uppercase filename — must still be found.
4279        fs::write(skill_dir.join("SKILL.MD"), b"# upper").unwrap();
4280        let bytes = load_local_skill_md(manifest_dir.path(), "skills/my-skill").unwrap();
4281        assert_eq!(bytes, b"# upper");
4282    }
4283
4284    #[test]
4285    fn load_local_skill_md_missing_path_errors_with_name() {
4286        let dir = tempfile::tempdir().unwrap();
4287        let err = load_local_skill_md(dir.path(), "does/not/exist").unwrap_err();
4288        match err {
4289            RuntimeError::SkillSourceNotFound { path } => {
4290                assert!(path.contains("does/not/exist"), "path was: {path}");
4291            }
4292            other => panic!("expected SkillSourceNotFound, got {other:?}"),
4293        }
4294    }
4295
4296    #[test]
4297    fn load_local_skill_md_directory_without_skill_md_errors() {
4298        let dir = tempfile::tempdir().unwrap();
4299        let empty = dir.path().join("empty");
4300        fs::create_dir_all(&empty).unwrap();
4301        let err = load_local_skill_md(dir.path(), "empty").unwrap_err();
4302        match err {
4303            RuntimeError::SkillSourceMissingSkillMd { path } => {
4304                assert!(path.contains("empty"), "path was: {path}");
4305            }
4306            other => panic!("expected SkillSourceMissingSkillMd, got {other:?}"),
4307        }
4308    }
4309
4310    #[test]
4311    fn load_local_skill_md_absolute_path_ignores_manifest_dir() {
4312        let manifest_dir = tempfile::tempdir().unwrap();
4313        let elsewhere = tempfile::tempdir().unwrap();
4314        let skill = elsewhere.path().join("skill.md");
4315        fs::write(&skill, b"# absolute").unwrap();
4316        let bytes = load_local_skill_md(manifest_dir.path(), &skill.to_string_lossy()).unwrap();
4317        assert_eq!(bytes, b"# absolute");
4318    }
4319
4320    #[test]
4321    fn stage_session_installs_local_source_skill_without_registry() {
4322        use murmur_artifact::{InferenceConfig, InferenceDriver};
4323
4324        // Registry must never be consulted for a local-source skill.
4325        struct PanicRegistry;
4326        impl Registry for PanicRegistry {
4327            fn resolve(&self, _: &str, _: &str) -> Result<ResolvedArtifact, RegistryError> {
4328                panic!("registry must not be called for a local-source skill");
4329            }
4330            fn publish(
4331                &self,
4332                _: ArtifactMeta,
4333                _: &[u8],
4334            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4335                unreachable!()
4336            }
4337            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4338                unreachable!()
4339            }
4340        }
4341
4342        let project = tempfile::tempdir().unwrap();
4343        let skill_dir = project.path().join("skills").join("my-skill");
4344        fs::create_dir_all(&skill_dir).unwrap();
4345        fs::write(skill_dir.join("skill.md"), b"# my local skill").unwrap();
4346
4347        let inference = InferenceConfig {
4348            transport: "http".into(),
4349            endpoint: Some("http://localhost".into()),
4350            model: "claude-3-haiku".into(),
4351            api_key: None,
4352            driver: Some(InferenceDriver {
4353                artifact: "test-driver".into(),
4354                config: None,
4355            }),
4356            command: None,
4357            compaction: None,
4358            system_prompt: None,
4359            system_prompt_file: None,
4360            system_prompt_artifact: None,
4361            max_turns: 10,
4362            max_task_reopens: 1,
4363            max_tokens: None,
4364        };
4365
4366        let request = StageRequest {
4367            manifest_dir: project.path().to_path_buf(),
4368            capsule_name: "test".to_string(),
4369            capsule_version: "0.0.1".to_string(),
4370            capsule_component_bytes: Vec::new(),
4371            artifacts: vec![crate::types::ArtifactRequest {
4372                name: "my-skill".to_string(),
4373                version: "local".to_string(),
4374                runtime: ArtifactRuntime::Skill,
4375                source: Some("skills/my-skill".to_string()),
4376                on_overflow: Default::default(),
4377                capabilities: None,
4378            }],
4379            allowlisted_tools: HashSet::new(),
4380            lock_expectations: None,
4381            capability_policy: CapabilityPolicy::default(),
4382            inference: Some(inference),
4383            system_prompt_overridden: false,
4384            context: None,
4385            otel_endpoint: None,
4386            eval_config_json: None,
4387            case_id: None,
4388            dataset_id: None,
4389            lifecycle: None,
4390            lifecycle_override: None,
4391            trace: None,
4392            workdir: None,
4393            bind_addr: "127.0.0.1".to_string(),
4394            internal_port: None,
4395            declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4396        };
4397
4398        let staged = stage_session(Arc::new(PanicRegistry), request).unwrap();
4399        let installed = staged
4400            .workdir
4401            .join("tools")
4402            .join("my-skill")
4403            .join("skill.md");
4404        assert!(
4405            installed.exists(),
4406            "skill.md not installed at {}",
4407            installed.display()
4408        );
4409        assert_eq!(fs::read(&installed).unwrap(), b"# my local skill");
4410        // No lock artifact recorded for a local-source skill.
4411        assert!(staged.resolved_lock_artifacts.is_empty());
4412        assert_eq!(staged.installed_artifacts.len(), 1);
4413        assert_eq!(staged.installed_artifacts[0].version, "local");
4414        // MURMUR.md (written during staging) lists the skill as callable.
4415        let murmur_md = fs::read_to_string(staged.workdir.join("MURMUR.md")).unwrap();
4416        assert!(
4417            murmur_md.contains("**my-skill**"),
4418            "MURMUR.md missing skill listing:\n{murmur_md}"
4419        );
4420        assert!(
4421            murmur_md.contains("call by name to load guidance"),
4422            "MURMUR.md missing callable skill hint:\n{murmur_md}"
4423        );
4424    }
4425
4426    // ── manage.pull() ──────────────────────────────────────────────────────────
4427
4428    fn zip_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
4429        use std::io::Write as _;
4430        let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
4431        {
4432            let mut zip = zip::ZipWriter::new(&mut cursor);
4433            let options = zip::write::SimpleFileOptions::default();
4434            for (name, bytes) in files {
4435                zip.start_file(*name, options).unwrap();
4436                zip.write_all(bytes).unwrap();
4437            }
4438            zip.finish().unwrap();
4439        }
4440        cursor.into_inner()
4441    }
4442
4443    fn build_test_state(
4444        registry: Arc<dyn Registry>,
4445        workdir: PathBuf,
4446        lock_path: PathBuf,
4447    ) -> CapsuleStoreState {
4448        let engine = build_engine().unwrap();
4449        let wasi = build_wasi_ctx(&workdir, None, &[], &CapabilityPolicy::default()).unwrap();
4450        CapsuleStoreState {
4451            limits: crate::limits::ExecutionLimits::default().limiter(),
4452            table: ResourceTable::new(),
4453            wasi,
4454            http: WasiHttpCtx::new(),
4455            http_hooks: NetworkPolicyHooks {
4456                network_allow_rules: Vec::new(),
4457            },
4458            network_allow_rules: Vec::new(),
4459            inference_env: Vec::new(),
4460            engine,
4461            workdir: workdir.clone(),
4462            accessible_workdir: workdir,
4463            tool_components: HashMap::new(),
4464            artifact_grants: HashMap::new(),
4465            allowlisted_tools: HashSet::new(),
4466            installed_artifacts: Vec::new(),
4467            session_id: "ses_test".to_string(),
4468            pending_a2a_events: Vec::new(),
4469            capability_policy: CapabilityPolicy::default(),
4470            shell_enforcement: sandbox::ShellEnforcement::environment_only(),
4471            current_traceparent: None,
4472            a2a_task_registry: None,
4473            a2a_sse: None,
4474            a2a_task_id: None,
4475            input_timeout_secs: None,
4476            a2a_chunk_event_id: Arc::new(AtomicU64::new(0)),
4477            a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
4478            registry,
4479            lock_path,
4480            driver_continuation_id: None,
4481            driver_continuation_context_id: None,
4482            driver_continuation_acked_len: 0,
4483        }
4484    }
4485
4486    // ── Driver continuation bookkeeping on CapsuleStoreState ─────────────
4487
4488    fn continuation_test_state() -> CapsuleStoreState {
4489        let dir = tempfile::tempdir().unwrap();
4490        let workdir = dir.path().to_path_buf();
4491        let lock_path = workdir.join("murmur.lock");
4492        // Registry/workdir are irrelevant to continuation bookkeeping; reuse the helper.
4493        build_test_state(
4494            Arc::new(FakeSkillRegistry::new(Vec::new())),
4495            workdir,
4496            lock_path,
4497        )
4498    }
4499
4500    #[test]
4501    fn continuation_default_is_none_and_active_query_returns_none() {
4502        let state = continuation_test_state();
4503        assert!(state.driver_continuation_id.is_none());
4504        assert!(state.active_continuation(Some("ctx-a")).is_none());
4505        assert!(state.active_continuation(None).is_none());
4506    }
4507
4508    #[test]
4509    fn continuation_record_then_active_requires_matching_context() {
4510        // Scenario 6: a held continuation is only reused under the same context_id.
4511        let mut state = continuation_test_state();
4512        state.record_continuation("cont-1".into(), Some("ctx-a".into()), 2);
4513
4514        assert_eq!(
4515            state.active_continuation(Some("ctx-a")),
4516            Some(("cont-1", 2)),
4517            "same context → continuation is active"
4518        );
4519        assert!(
4520            state.active_continuation(Some("ctx-b")).is_none(),
4521            "different context → continuation must not be reused"
4522        );
4523        assert!(
4524            state.active_continuation(None).is_none(),
4525            "absent context → continuation must not be reused"
4526        );
4527    }
4528
4529    #[test]
4530    fn continuation_clear_drops_all_bookkeeping() {
4531        // Scenarios 3 & 5: driver silence / replace-context commit drops the held id.
4532        let mut state = continuation_test_state();
4533        state.record_continuation("cont-1".into(), Some("ctx-a".into()), 5);
4534        state.clear_continuation();
4535        assert!(state.driver_continuation_id.is_none());
4536        assert_eq!(state.driver_continuation_acked_len, 0);
4537        assert!(state.active_continuation(Some("ctx-a")).is_none());
4538    }
4539
4540    #[test]
4541    fn advance_acked_len_only_affects_same_context_held_continuation() {
4542        // Scenario 7: end_turn persists an assistant message the driver already knows; the
4543        // acked length advances so the next same-context Task wires only its new user message.
4544        let mut state = continuation_test_state();
4545        state.record_continuation("cont-1".into(), Some("ctx-a".into()), 1);
4546
4547        state.advance_continuation_acked_len(Some("ctx-b"), 9);
4548        assert_eq!(
4549            state.driver_continuation_acked_len, 1,
4550            "advancing under a different context must be a no-op"
4551        );
4552
4553        state.advance_continuation_acked_len(Some("ctx-a"), 2);
4554        assert_eq!(
4555            state.active_continuation(Some("ctx-a")),
4556            Some(("cont-1", 2))
4557        );
4558
4559        state.clear_continuation();
4560        state.advance_continuation_acked_len(Some("ctx-a"), 7);
4561        assert_eq!(
4562            state.driver_continuation_acked_len, 0,
4563            "advancing with no held continuation must be a no-op"
4564        );
4565    }
4566
4567    struct FakeSkillRegistry {
4568        bytes: Vec<u8>,
4569        sha256: String,
4570    }
4571
4572    impl FakeSkillRegistry {
4573        fn new(bytes: Vec<u8>) -> Self {
4574            let sha256 = murmur_artifact::sha256_hex(&bytes);
4575            Self { bytes, sha256 }
4576        }
4577    }
4578
4579    impl Registry for FakeSkillRegistry {
4580        fn resolve(&self, name: &str, version: &str) -> Result<ResolvedArtifact, RegistryError> {
4581            Ok(ResolvedArtifact {
4582                meta: ArtifactMeta {
4583                    name: name.to_string(),
4584                    version: version.to_string(),
4585                    runtime: RuntimeType::Static,
4586                    artifact_runtime: "skill".to_string(),
4587                    platforms: Vec::new(),
4588                    description: None,
4589                    tags: Vec::new(),
4590                },
4591                bytes: self.bytes.clone().into(),
4592                sha256: self.sha256.clone(),
4593            })
4594        }
4595
4596        fn publish(
4597            &self,
4598            _meta: ArtifactMeta,
4599            _bytes: &[u8],
4600        ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4601            unreachable!()
4602        }
4603
4604        fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4605            unreachable!()
4606        }
4607    }
4608
4609    #[test]
4610    fn pull_happy_path_installs_artifact_and_updates_lock() {
4611        let artifact_bytes = zip_with_files(&[
4612            (
4613                PACKED_MANIFEST_ENTRY,
4614                b"name: my-skill\nversion: 1.0.0\nruntime: skill\n",
4615            ),
4616            ("skill.md", b"# guidance"),
4617        ]);
4618        let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
4619        let expected_sha256 = registry.sha256.clone();
4620
4621        let project = tempfile::tempdir().unwrap();
4622        let workdir = project.path().join("workdir");
4623        fs::create_dir_all(&workdir).unwrap();
4624        let lock_path = project.path().join("murmur.lock");
4625
4626        let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
4627
4628        let summary = manage::Host::pull(&mut state, "my-skill".to_string(), "1.0.0".to_string())
4629            .expect("pull should succeed");
4630        assert_eq!(summary.name, "my-skill");
4631        assert_eq!(summary.version, "1.0.0");
4632
4633        let installed_skill_md = workdir.join("tools").join("my-skill").join("skill.md");
4634        assert!(installed_skill_md.exists());
4635        assert_eq!(fs::read(&installed_skill_md).unwrap(), b"# guidance");
4636
4637        assert!(state
4638            .installed_artifacts
4639            .iter()
4640            .any(|a| a.name == "my-skill" && a.version == "1.0.0"));
4641
4642        let lock = read_lockfile(&lock_path).expect("murmur.lock should have been written");
4643        let entry = lock
4644            .artifact_for("my-skill")
4645            .expect("lock entry for my-skill");
4646        assert_eq!(entry.resolved_version, "1.0.0");
4647        assert_eq!(entry.sha256.wasm, expected_sha256);
4648    }
4649
4650    #[test]
4651    fn pull_rejects_tampered_bytes_and_writes_nothing() {
4652        struct TamperedRegistry;
4653        impl Registry for TamperedRegistry {
4654            fn resolve(
4655                &self,
4656                name: &str,
4657                version: &str,
4658            ) -> Result<ResolvedArtifact, RegistryError> {
4659                Ok(ResolvedArtifact {
4660                    meta: ArtifactMeta {
4661                        name: name.to_string(),
4662                        version: version.to_string(),
4663                        runtime: RuntimeType::Static,
4664                        artifact_runtime: "skill".to_string(),
4665                        platforms: Vec::new(),
4666                        description: None,
4667                        tags: Vec::new(),
4668                    },
4669                    bytes: b"tampered-bytes".to_vec().into(),
4670                    sha256: "not-the-real-hash".to_string(),
4671                })
4672            }
4673
4674            fn publish(
4675                &self,
4676                _meta: ArtifactMeta,
4677                _bytes: &[u8],
4678            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4679                unreachable!()
4680            }
4681
4682            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4683                unreachable!()
4684            }
4685        }
4686
4687        let project = tempfile::tempdir().unwrap();
4688        let workdir = project.path().join("workdir");
4689        fs::create_dir_all(&workdir).unwrap();
4690        let lock_path = project.path().join("murmur.lock");
4691
4692        let mut state = build_test_state(
4693            Arc::new(TamperedRegistry),
4694            workdir.clone(),
4695            lock_path.clone(),
4696        );
4697
4698        let err = manage::Host::pull(&mut state, "evil-tool".to_string(), "1.0.0".to_string())
4699            .expect_err("tampered bytes must be rejected");
4700        assert!(err.contains("integrity"), "unexpected error message: {err}");
4701
4702        assert!(!workdir.join("tools").join("evil-tool").exists());
4703        assert!(!lock_path.exists());
4704        assert!(state.installed_artifacts.is_empty());
4705    }
4706
4707    #[test]
4708    fn pull_rejects_lock_conflict_and_writes_nothing() {
4709        let artifact_bytes = zip_with_files(&[
4710            (
4711                PACKED_MANIFEST_ENTRY,
4712                b"name: my-skill\nversion: 2.0.0\nruntime: skill\n",
4713            ),
4714            ("skill.md", b"# guidance v2"),
4715        ]);
4716        let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
4717
4718        let project = tempfile::tempdir().unwrap();
4719        let workdir = project.path().join("workdir");
4720        fs::create_dir_all(&workdir).unwrap();
4721        let lock_path = project.path().join("murmur.lock");
4722
4723        // Pin a different version/hash for this artifact ahead of time.
4724        write_lockfile_atomic(
4725            &lock_path,
4726            &MurmurLock {
4727                lock_version: LOCK_VERSION,
4728                artifacts: vec![LockedArtifact {
4729                    name: "my-skill".to_string(),
4730                    resolved_version: "1.0.0".to_string(),
4731                    sha256: LockedSha256 {
4732                        wasm: "pinned-hash-from-earlier-pull".to_string(),
4733                    },
4734                }],
4735            },
4736        )
4737        .unwrap();
4738
4739        let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
4740
4741        let err = manage::Host::pull(&mut state, "my-skill".to_string(), "2.0.0".to_string())
4742            .expect_err("lock conflict must be rejected");
4743        assert!(
4744            err.contains("murmur.lock conflict"),
4745            "unexpected error message: {err}"
4746        );
4747
4748        assert!(!workdir
4749            .join("tools")
4750            .join("my-skill")
4751            .join("skill.md")
4752            .exists());
4753        assert!(state.installed_artifacts.is_empty());
4754
4755        // Lock must be left exactly as it was.
4756        let lock = read_lockfile(&lock_path).unwrap();
4757        let entry = lock.artifact_for("my-skill").unwrap();
4758        assert_eq!(entry.resolved_version, "1.0.0");
4759        assert_eq!(entry.sha256.wasm, "pinned-hash-from-earlier-pull");
4760    }
4761
4762    /// Writes an executable shell script native-tool fixture that echoes the given env
4763    /// var names (space-separated `NAME=value`, empty string for an unset var) into the
4764    /// `data` field of a passing ToolResult JSON payload on stdout.
4765    fn write_env_echo_native_tool(dir: &Path, name: &str, env_vars: &[&str]) -> std::path::PathBuf {
4766        use std::os::unix::fs::PermissionsExt;
4767
4768        let script_path = dir.join(name);
4769        let echoes: String = env_vars
4770            .iter()
4771            .map(|var| format!(r#"echo -n "{var}=${var} ""#))
4772            .collect::<Vec<_>>()
4773            .join("\n");
4774        let script = format!(
4775            "#!/bin/sh\ncat >/dev/null\nprintf '{{\"status\":\"passed\",\"data\":\"'\n{echoes}\nprintf '\"}}'\n"
4776        );
4777        fs::write(&script_path, script).unwrap();
4778        fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
4779        script_path
4780    }
4781
4782    /// The wiring the shell-event fix depends on: `dispatch_shell_tool` carries the resolved
4783    /// binary out of `execute_shell` and into `ShellDispatchInfo`, which is the only channel
4784    /// by which `agent.rs` can put it on the hook event and the trace record. `command` keeps
4785    /// its pre-existing meaning — the argument list alone, never the binary name.
4786    #[test]
4787    fn dispatch_shell_tool_reports_the_invoked_binary_separately_from_the_command() {
4788        let tmp = TempDir::new().unwrap();
4789        let policy = CapabilityPolicy {
4790            shell_allow: vec!["bash".to_string()],
4791            ..CapabilityPolicy::default()
4792        };
4793
4794        let outcome = dispatch_shell_tool(
4795            "bash",
4796            murmur::tool::run::ToolInput {
4797                data: Some(r#"{"command":"echo hi"}"#.to_string()),
4798                log_path: None,
4799            },
4800            tmp.path(),
4801            &[],
4802            &policy,
4803            &sandbox::ShellEnforcement::environment_only(),
4804        );
4805
4806        let shell = outcome
4807            .shell
4808            .expect("a successful shell call reports itself");
4809        assert!(
4810            Path::new(&shell.binary).is_absolute() && shell.binary.ends_with("bash"),
4811            "binary must be the resolved path of what ran, got {:?}",
4812            shell.binary
4813        );
4814        assert_eq!(
4815            shell.command, "echo hi",
4816            "command must still carry only the argument list"
4817        );
4818        assert_eq!(shell.exit_code, 0);
4819    }
4820
4821    /// The dispatch-layer half of the composed-root failure path: a sealed session whose root
4822    /// could not be built does not come back as an ordinary failed tool call the capsule gets
4823    /// another turn to react to. The tool result is still filled in (so the trace records the
4824    /// attempt), but `fatal` carries the typed `RuntimeError` the agent turn loop returns and
4825    /// the CLI renders as `E-RUN-014`.
4826    ///
4827    /// Linux-only because the forced-failure seam lives in the Linux `pre_exec` path; the
4828    /// mechanism it stands in for is Linux-only too.
4829    #[cfg(target_os = "linux")]
4830    #[test]
4831    fn a_sealed_composed_root_failure_ends_the_session_not_just_the_tool_call() {
4832        if crate::network_namespace::skip_without_egress_namespace(
4833            "a_sealed_composed_root_failure_ends_the_session_not_just_the_tool_call",
4834        ) {
4835            return;
4836        }
4837        let tmp = TempDir::new().unwrap();
4838        let policy = CapabilityPolicy {
4839            shell_allow: vec!["bash".to_string()],
4840            ..CapabilityPolicy::default()
4841        };
4842
4843        let _guard = sandbox::ForceSealedRootFailureGuard::new();
4844        let outcome = dispatch_shell_tool(
4845            "bash",
4846            murmur::tool::run::ToolInput {
4847                data: Some(r#"{"command":"echo hi"}"#.to_string()),
4848                log_path: None,
4849            },
4850            tmp.path(),
4851            &[],
4852            &policy,
4853            &sandbox::sealed_test_enforcement(),
4854        );
4855
4856        assert!(
4857            matches!(outcome.result.status, murmur::tool::run::Status::Error),
4858            "the failed call must still read as a failed call"
4859        );
4860        let data = outcome.result.data.as_deref().unwrap_or_default();
4861        assert!(
4862            data.contains("composed root"),
4863            "the tool result must still say what happened: {data}"
4864        );
4865        let fatal = outcome
4866            .fatal
4867            .expect("a composed-root failure must be carried out as session-fatal");
4868        assert!(
4869            matches!(fatal, RuntimeError::SealedRootConstructionFailed { .. }),
4870            "must be the variant murmur-cli maps to E-RUN-014: {fatal}"
4871        );
4872    }
4873
4874    /// The contrast: an ordinary shell failure leaves `fatal` unset, so the capsule keeps its
4875    /// turn. Without this, "always fatal" would pass the test above.
4876    #[test]
4877    fn an_ordinary_shell_failure_leaves_the_session_running() {
4878        let tmp = TempDir::new().unwrap();
4879        let outcome = dispatch_shell_tool(
4880            "bash",
4881            murmur::tool::run::ToolInput {
4882                data: Some(r#"{"command":"echo hi"}"#.to_string()),
4883                log_path: None,
4884            },
4885            tmp.path(),
4886            &[],
4887            // Empty allowlist: `execute_shell` refuses before spawning anything.
4888            &CapabilityPolicy::default(),
4889            &sandbox::ShellEnforcement::environment_only(),
4890        );
4891
4892        assert!(matches!(
4893            outcome.result.status,
4894            murmur::tool::run::Status::Error
4895        ));
4896        assert!(
4897            outcome.fatal.is_none(),
4898            "a disallowed binary is the capsule's problem, not the session's"
4899        );
4900    }
4901
4902    /// The composed path, on real inputs and a real file: dispatch a real shell tool, then
4903    /// hand its `ShellDispatchInfo` to a real `TraceWriter` exactly as `agent.rs` does, and
4904    /// read the resulting `workdir/trace.jsonl` back. This is the automated stand-in for
4905    /// eyeballing a session's trace after `mur run` — the two lines it mirrors in `agent.rs`
4906    /// are a straight field pass-through, but the JSONL key and its value are pinned here.
4907    #[test]
4908    fn shell_dispatch_writes_the_resolved_binary_into_trace_jsonl() {
4909        let tmp = TempDir::new().unwrap();
4910        let workdir = tmp.path().to_path_buf();
4911        let policy = CapabilityPolicy {
4912            shell_allow: vec!["bash".to_string()],
4913            ..CapabilityPolicy::default()
4914        };
4915
4916        let rt = tokio::runtime::Runtime::new().unwrap();
4917        let events: Vec<serde_json::Value> = rt.block_on(async {
4918            let outcome = dispatch_shell_tool(
4919                "bash",
4920                murmur::tool::run::ToolInput {
4921                    data: Some(r#"{"command":"echo hi"}"#.to_string()),
4922                    log_path: None,
4923                },
4924                &workdir,
4925                &[],
4926                &policy,
4927                &sandbox::ShellEnforcement::environment_only(),
4928            );
4929            let shell = outcome.shell.expect("the shell call reports itself");
4930
4931            let mut trace = TraceWriter::open(
4932                &workdir,
4933                "ses_test".to_string(),
4934                "cap".to_string(),
4935                "0.1.0".to_string(),
4936                "test-model".to_string(),
4937                Vec::new(),
4938                crate::containment::scope_report_for_tier(
4939                    &policy,
4940                    murmur_artifact::ContainmentClass::Advisory,
4941                    sandbox::EnforcementTier::EnvironmentOnly,
4942                    None,
4943                ),
4944                false,
4945                None,
4946                false,
4947            )
4948            .await
4949            .unwrap();
4950            trace
4951                .write_shell(
4952                    1,
4953                    shell.binary.clone(),
4954                    shell.command.clone(),
4955                    shell.exit_code,
4956                    shell.stdout_bytes,
4957                    shell.stderr_bytes,
4958                    shell.duration_ms,
4959                    shell.resource_limit.clone(),
4960                )
4961                .await
4962                .unwrap();
4963            trace.flush().await.unwrap();
4964
4965            fs::read_to_string(workdir.join("trace.jsonl"))
4966                .unwrap()
4967                .lines()
4968                .filter(|l| !l.is_empty())
4969                .map(|l| serde_json::from_str(l).unwrap())
4970                .collect()
4971        });
4972
4973        let shell_event = events
4974            .iter()
4975            .find(|e| e["event_type"] == "shell")
4976            .expect("trace.jsonl must carry a shell event");
4977        let binary = shell_event["binary"]
4978            .as_str()
4979            .expect("the shell event must carry a `binary` string");
4980        assert!(
4981            Path::new(binary).is_absolute() && binary.ends_with("bash"),
4982            "trace.jsonl's binary must be the resolved absolute path of what ran, got {binary:?}"
4983        );
4984        assert_eq!(shell_event["command"], "echo hi");
4985    }
4986
4987    #[test]
4988    fn dispatch_native_tool_gets_synthetic_home_matching_execute_shell() {
4989        let tmp = TempDir::new().unwrap();
4990        let binary = write_env_echo_native_tool(tmp.path(), "echo-home", &["HOME"]);
4991        let policy = CapabilityPolicy::default();
4992
4993        let result = dispatch_native_tool(
4994            "echo-home",
4995            murmur::tool::run::ToolInput {
4996                data: None,
4997                log_path: None,
4998            },
4999            &binary,
5000            tmp.path(),
5001            &policy,
5002            &sandbox::ShellEnforcement::environment_only(),
5003        )
5004        .unwrap();
5005
5006        let expected_home = tmp.path().join(".capsule-home");
5007        let data = result.data.unwrap_or_default();
5008        assert!(
5009            data.contains(&format!("HOME={}", expected_home.display())),
5010            "expected synthetic HOME in native tool output, got: {data}"
5011        );
5012        assert!(expected_home.is_dir());
5013    }
5014
5015    #[test]
5016    fn dispatch_native_tool_strips_credential_shaped_var() {
5017        let tmp = TempDir::new().unwrap();
5018        let binary = write_env_echo_native_tool(tmp.path(), "echo-token", &["GITHUB_TOKEN"]);
5019        let policy = CapabilityPolicy::default();
5020
5021        std::env::set_var("GITHUB_TOKEN", "leaked-token");
5022        let result = dispatch_native_tool(
5023            "echo-token",
5024            murmur::tool::run::ToolInput {
5025                data: None,
5026                log_path: None,
5027            },
5028            &binary,
5029            tmp.path(),
5030            &policy,
5031            &sandbox::ShellEnforcement::environment_only(),
5032        )
5033        .unwrap();
5034        std::env::remove_var("GITHUB_TOKEN");
5035
5036        let data = result.data.unwrap_or_default();
5037        assert!(
5038            !data.contains("leaked-token"),
5039            "GITHUB_TOKEN must not reach a native tool subprocess, got: {data}"
5040        );
5041    }
5042
5043    #[test]
5044    fn dispatch_native_tool_strips_wildcard_credential_pattern() {
5045        let tmp = TempDir::new().unwrap();
5046        let binary = write_env_echo_native_tool(tmp.path(), "echo-stripe", &["STRIPE_API_KEY"]);
5047        let policy = CapabilityPolicy::default();
5048
5049        std::env::set_var("STRIPE_API_KEY", "leaked-key");
5050        let result = dispatch_native_tool(
5051            "echo-stripe",
5052            murmur::tool::run::ToolInput {
5053                data: None,
5054                log_path: None,
5055            },
5056            &binary,
5057            tmp.path(),
5058            &policy,
5059            &sandbox::ShellEnforcement::environment_only(),
5060        )
5061        .unwrap();
5062        std::env::remove_var("STRIPE_API_KEY");
5063
5064        let data = result.data.unwrap_or_default();
5065        assert!(
5066            !data.contains("leaked-key"),
5067            "*_API_KEY wildcard pattern must strip STRIPE_API_KEY from native tool subprocess, got: {data}"
5068        );
5069    }
5070
5071    #[test]
5072    fn dispatch_native_tool_keeps_safe_baseline_var() {
5073        let tmp = TempDir::new().unwrap();
5074        let binary = write_env_echo_native_tool(tmp.path(), "echo-cargo-home", &["CARGO_HOME"]);
5075        let policy = CapabilityPolicy::default();
5076
5077        std::env::set_var("CARGO_HOME", "/fake/cargo/home");
5078        let result = dispatch_native_tool(
5079            "echo-cargo-home",
5080            murmur::tool::run::ToolInput {
5081                data: None,
5082                log_path: None,
5083            },
5084            &binary,
5085            tmp.path(),
5086            &policy,
5087            &sandbox::ShellEnforcement::environment_only(),
5088        )
5089        .unwrap();
5090        std::env::remove_var("CARGO_HOME");
5091
5092        let data = result.data.unwrap_or_default();
5093        assert!(
5094            data.contains("CARGO_HOME=/fake/cargo/home"),
5095            "safe baseline var CARGO_HOME must pass through to native tool subprocess, got: {data}"
5096        );
5097    }
5098
5099    #[test]
5100    fn dispatch_native_tool_composes_policy_strip_env() {
5101        let tmp = TempDir::new().unwrap();
5102        let binary =
5103            write_env_echo_native_tool(tmp.path(), "echo-mycompany", &["MYCOMPANY_SECRET"]);
5104        let policy = CapabilityPolicy {
5105            shell_strip_env: vec!["MYCOMPANY_*".to_string()],
5106            ..CapabilityPolicy::default()
5107        };
5108
5109        std::env::set_var("MYCOMPANY_SECRET", "leaked-secret");
5110        let result = dispatch_native_tool(
5111            "echo-mycompany",
5112            murmur::tool::run::ToolInput {
5113                data: None,
5114                log_path: None,
5115            },
5116            &binary,
5117            tmp.path(),
5118            &policy,
5119            &sandbox::ShellEnforcement::environment_only(),
5120        )
5121        .unwrap();
5122        std::env::remove_var("MYCOMPANY_SECRET");
5123
5124        let data = result.data.unwrap_or_default();
5125        assert!(
5126            !data.contains("leaked-secret"),
5127            "policy.shell_strip_env pattern must compose for native tool subprocess, got: {data}"
5128        );
5129    }
5130
5131    // ── Per-artifact grant narrowing for tools and drivers ───────────────────────────
5132
5133    /// The capsule ceiling the narrowing tests clamp against. Loopback ports nothing listens
5134    /// on, so a policy decision is observable without any connection ever completing.
5135    fn narrowing_ceiling() -> Vec<NetworkAllowRule> {
5136        parse_network_allow_rules(&[
5137            "http://127.0.0.1:1".to_string(),
5138            "http://127.0.0.1:2".to_string(),
5139        ])
5140        .unwrap()
5141    }
5142
5143    fn grant_of(network: Option<Vec<&str>>, scope: Option<&str>) -> ToolCapabilityGrant {
5144        let caps = murmur_artifact::Capabilities {
5145            network: network.map(|allow| murmur_artifact::NetworkCapabilities {
5146                allow: allow.into_iter().map(str::to_string).collect(),
5147                unix_sockets: false,
5148            }),
5149            filesystem: scope.map(|scope| murmur_artifact::FilesystemCapabilities {
5150                scope: Some(scope.to_string()),
5151                workdir_exec: false,
5152            }),
5153            shell: None,
5154            spawn: None,
5155            env: None,
5156            limits: None,
5157            resources: None,
5158            containment: None,
5159        };
5160        ToolCapabilityGrant::derive(Some(&caps), &narrowing_ceiling()).expect("grant is valid")
5161    }
5162
5163    /// Push a request through the very `NetworkPolicyHooks` a tool store is built with, so
5164    /// the assertion is on the real wasi-http gate rather than on the rule list.
5165    fn send_through_tool_hooks(rules: &[NetworkAllowRule], uri: &str, use_tls: bool) -> bool {
5166        use http_body_util::{BodyExt, Empty};
5167
5168        let mut hooks = NetworkPolicyHooks {
5169            network_allow_rules: rules.to_vec(),
5170        };
5171        let body = Empty::<bytes::Bytes>::new()
5172            .map_err(|err| match err {})
5173            .boxed_unsync();
5174        let request = hyper::Request::builder()
5175            .uri(uri)
5176            .body(body)
5177            .expect("request builds");
5178        let config = wasmtime_wasi_http::p2::types::OutgoingRequestConfig {
5179            use_tls,
5180            connect_timeout: std::time::Duration::from_millis(1),
5181            first_byte_timeout: std::time::Duration::from_millis(1),
5182            between_bytes_timeout: std::time::Duration::from_millis(1),
5183        };
5184
5185        let rt = tokio::runtime::Runtime::new().unwrap();
5186        rt.block_on(async { hooks.send_request(request, config).is_ok() })
5187    }
5188
5189    /// The no-op invariant, network half: a tool with no per-artifact entry dispatches on the
5190    /// full ceiling, reaching everything the capsule may reach.
5191    #[test]
5192    fn ungranted_tool_keeps_the_whole_ceiling() {
5193        let ceiling = narrowing_ceiling();
5194        let rules = effective_tool_network_rules(None, &ceiling);
5195
5196        assert!(send_through_tool_hooks(
5197            rules,
5198            "http://127.0.0.1:1/x",
5199            false
5200        ));
5201        assert!(send_through_tool_hooks(
5202            rules,
5203            "http://127.0.0.1:2/x",
5204            false
5205        ));
5206    }
5207
5208    /// A narrowed tool reaches only its declared host; the ceiling's other host is gone for
5209    /// that artifact even though a sibling tool still reaches it.
5210    #[test]
5211    fn narrowed_tool_reaches_only_its_declared_host() {
5212        let ceiling = narrowing_ceiling();
5213        let grant = grant_of(Some(vec!["http://127.0.0.1:1"]), None);
5214        let narrowed = effective_tool_network_rules(Some(&grant), &ceiling);
5215
5216        assert!(
5217            send_through_tool_hooks(narrowed, "http://127.0.0.1:1/x", false),
5218            "the declared host stays reachable"
5219        );
5220        assert!(
5221            !send_through_tool_hooks(narrowed, "http://127.0.0.1:2/x", false),
5222            "the rest of the ceiling is dropped for this artifact"
5223        );
5224        // The sibling with no entry is unaffected by its neighbour's narrowing.
5225        let sibling = effective_tool_network_rules(None, &ceiling);
5226        assert!(send_through_tool_hooks(
5227            sibling,
5228            "http://127.0.0.1:2/x",
5229            false
5230        ));
5231    }
5232
5233    /// An entry outside the ceiling is dropped rather than granted, and reported so staging
5234    /// can raise `W-SEC-007`.
5235    #[test]
5236    fn out_of_ceiling_entry_is_dropped_and_reported() {
5237        let ceiling = narrowing_ceiling();
5238        let grant = grant_of(
5239            Some(vec!["http://127.0.0.1:1", "https://evil.example.com"]),
5240            None,
5241        );
5242        let narrowed = effective_tool_network_rules(Some(&grant), &ceiling);
5243
5244        assert!(!send_through_tool_hooks(
5245            narrowed,
5246            "https://evil.example.com/x",
5247            true
5248        ));
5249        assert_eq!(
5250            grant.dropped_network_entries,
5251            vec!["https://evil.example.com".to_string()]
5252        );
5253    }
5254
5255    /// Without a scope the preopened root is the workdir itself — observable because
5256    /// `preopened_dir` requires the directory to exist, so a missing workdir is an error.
5257    #[test]
5258    fn tool_without_filesystem_scope_preopens_the_workdir_itself() {
5259        let root = TempDir::new().unwrap();
5260        let missing = root.path().join("does-not-exist");
5261
5262        assert!(
5263            build_wasi_ctx(&missing, None, &[], &CapabilityPolicy::default()).is_err(),
5264            "an unscoped tool preopens the workdir, which must exist"
5265        );
5266        build_wasi_ctx(root.path(), None, &[], &CapabilityPolicy::default())
5267            .expect("an existing workdir preopens as before");
5268        assert!(
5269            !missing.exists(),
5270            "the unscoped path must not create anything"
5271        );
5272    }
5273
5274    /// With a scope the preopened root is `<workdir>/<scope>`, created if absent. Sibling
5275    /// paths under the workdir are never mounted, so a guest has no descriptor for them.
5276    #[test]
5277    fn tool_with_filesystem_scope_preopens_only_the_scoped_subtree() {
5278        let root = TempDir::new().unwrap();
5279        std::fs::write(root.path().join("secret.txt"), b"capsule state").unwrap();
5280
5281        build_wasi_ctx(
5282            root.path(),
5283            Some("cache"),
5284            &[],
5285            &CapabilityPolicy::default(),
5286        )
5287        .expect("a granted scope is created and preopened");
5288
5289        let scoped = root.path().join("cache");
5290        assert!(scoped.is_dir(), "the granted scope is created if missing");
5291        std::fs::write(scoped.join("entry.json"), b"{}").unwrap();
5292        assert!(scoped.join("entry.json").exists());
5293        assert!(
5294            root.path().join("secret.txt").exists(),
5295            "nothing outside the scope was touched"
5296        );
5297    }
5298
5299    /// A scope that cannot be created is a hard error naming the scope, never a silent
5300    /// widening back to the whole workdir.
5301    #[test]
5302    fn unusable_filesystem_scope_is_a_hard_error() {
5303        let root = TempDir::new().unwrap();
5304        // A regular file where the scope directory would go: `create_dir_all` cannot proceed.
5305        std::fs::write(root.path().join("cache"), b"not a directory").unwrap();
5306
5307        let policy = CapabilityPolicy::default();
5308        let err = match build_wasi_ctx(root.path(), Some("cache"), &[], &policy) {
5309            Ok(_) => panic!("an uncreatable scope must fail loudly"),
5310            Err(err) => err,
5311        };
5312        assert!(
5313            err.to_string().contains("cache"),
5314            "the error must name the scope, got: {err}"
5315        );
5316    }
5317
5318    /// End-to-end through the one dispatch body every WASM tool and the inference driver
5319    /// share: a real component is instantiated and called under a grant, and the scoped
5320    /// directory it was granted appears on the host filesystem.
5321    #[test]
5322    fn real_dispatch_applies_the_filesystem_scope() {
5323        let engine = build_engine().unwrap();
5324        let workdir = TempDir::new().unwrap();
5325        let component = crate::inference_import::test_support::driver_double(
5326            &engine,
5327            0,
5328            r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"ok"}]}"#,
5329        );
5330        let ceiling = narrowing_ceiling();
5331        let grant = grant_of(None, Some("tool-cache"));
5332
5333        let rt = tokio::runtime::Runtime::new().unwrap();
5334        let result = rt.block_on(async {
5335            invoke_tool_component(
5336                ToolInvokeEnv {
5337                    engine: &engine,
5338                    accessible_workdir: workdir.path(),
5339                    inference_env: &[],
5340                    capability_policy: &CapabilityPolicy::default(),
5341                    network_allow_rules: &ceiling,
5342                    artifact_grant: Some(&grant),
5343                },
5344                ToolA2aWiring::silent(),
5345                "scoped-tool",
5346                &component,
5347                murmur::tool::run::ToolInput {
5348                    data: Some("{}".to_string()),
5349                    log_path: None,
5350                },
5351            )
5352            .await
5353        });
5354
5355        assert!(result.is_ok(), "the scoped tool still runs: {result:?}");
5356        assert!(
5357            workdir.path().join("tool-cache").is_dir(),
5358            "the real dispatch path preopened the granted scope"
5359        );
5360    }
5361
5362    /// The same dispatch with no grant is byte-for-byte the pre-narrowing behavior: the whole
5363    /// accessible workdir is the preopen root and no subtree is created.
5364    #[test]
5365    fn real_dispatch_without_a_grant_is_unchanged() {
5366        let engine = build_engine().unwrap();
5367        let workdir = TempDir::new().unwrap();
5368        let component = crate::inference_import::test_support::driver_double(
5369            &engine,
5370            0,
5371            r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"ok"}]}"#,
5372        );
5373        let ceiling = narrowing_ceiling();
5374
5375        let rt = tokio::runtime::Runtime::new().unwrap();
5376        let result = rt.block_on(async {
5377            invoke_tool_component(
5378                ToolInvokeEnv {
5379                    engine: &engine,
5380                    accessible_workdir: workdir.path(),
5381                    inference_env: &[],
5382                    capability_policy: &CapabilityPolicy::default(),
5383                    network_allow_rules: &ceiling,
5384                    artifact_grant: None,
5385                },
5386                ToolA2aWiring::silent(),
5387                "plain-tool",
5388                &component,
5389                murmur::tool::run::ToolInput {
5390                    data: Some("{}".to_string()),
5391                    log_path: None,
5392                },
5393            )
5394            .await
5395        });
5396
5397        assert!(
5398            result.is_ok(),
5399            "an ungranted tool runs as before: {result:?}"
5400        );
5401        assert_eq!(
5402            std::fs::read_dir(workdir.path()).unwrap().count(),
5403            0,
5404            "no per-artifact subtree is created when nothing was granted"
5405        );
5406    }
5407
5408    /// Staging lowers a grant only for artifacts that declared one, and only from the
5409    /// operator's own entry — the map is the single source dispatch consults.
5410    #[test]
5411    fn staging_records_grants_only_for_declaring_artifacts() {
5412        let ceiling = narrowing_ceiling();
5413        let mut grants = HashMap::new();
5414
5415        let declared = ArtifactRequest {
5416            name: "scoped-tool".to_string(),
5417            version: "1.0.0".to_string(),
5418            runtime: ArtifactRuntime::Tool,
5419            source: None,
5420            on_overflow: Default::default(),
5421            capabilities: Some(murmur_artifact::Capabilities {
5422                network: Some(murmur_artifact::NetworkCapabilities {
5423                    allow: vec!["http://127.0.0.1:1".to_string()],
5424                    unix_sockets: false,
5425                }),
5426                filesystem: None,
5427                shell: None,
5428                spawn: None,
5429                env: None,
5430                limits: None,
5431                resources: None,
5432                containment: None,
5433            }),
5434        };
5435        let silent = ArtifactRequest {
5436            name: "plain-tool".to_string(),
5437            version: "1.0.0".to_string(),
5438            runtime: ArtifactRuntime::Tool,
5439            source: None,
5440            on_overflow: Default::default(),
5441            capabilities: None,
5442        };
5443
5444        stage_artifact_grant(&declared, &ceiling, &mut grants).unwrap();
5445        stage_artifact_grant(&silent, &ceiling, &mut grants).unwrap();
5446
5447        assert!(grants.contains_key("scoped-tool"));
5448        assert!(
5449            !grants.contains_key("plain-tool"),
5450            "an artifact with no capabilities block must stay absent so dispatch falls back \
5451             to the ceiling"
5452        );
5453    }
5454
5455    /// A malformed grant fails staging rather than surfacing as a confusing denial once the
5456    /// tool is already running.
5457    #[test]
5458    fn staging_rejects_an_escaping_filesystem_scope() {
5459        let mut grants = HashMap::new();
5460        let artifact = ArtifactRequest {
5461            name: "escaping-tool".to_string(),
5462            version: "1.0.0".to_string(),
5463            runtime: ArtifactRuntime::Tool,
5464            source: None,
5465            on_overflow: Default::default(),
5466            capabilities: Some(murmur_artifact::Capabilities {
5467                network: None,
5468                filesystem: Some(murmur_artifact::FilesystemCapabilities {
5469                    scope: Some("../escape".to_string()),
5470                    workdir_exec: false,
5471                }),
5472                shell: None,
5473                spawn: None,
5474                env: None,
5475                limits: None,
5476                resources: None,
5477                containment: None,
5478            }),
5479        };
5480
5481        let err = stage_artifact_grant(&artifact, &narrowing_ceiling(), &mut grants)
5482            .expect_err("an escaping scope must fail staging");
5483        assert!(matches!(err, RuntimeError::InvalidFilesystemScope { .. }));
5484        assert!(grants.is_empty());
5485    }
5486
5487    /// The inert-sub-block set is shared with the hook warning: `network`/`filesystem` are
5488    /// consumed, everything else is reported.
5489    #[test]
5490    fn inert_sub_blocks_are_exactly_the_unconsumed_ones() {
5491        let caps = murmur_artifact::Capabilities {
5492            network: Some(murmur_artifact::NetworkCapabilities {
5493                allow: Vec::new(),
5494                unix_sockets: false,
5495            }),
5496            filesystem: Some(murmur_artifact::FilesystemCapabilities {
5497                scope: None,
5498                workdir_exec: false,
5499            }),
5500            shell: Some(murmur_artifact::ShellCapabilities {
5501                allow: vec!["bash".to_string()],
5502                strip_env: None,
5503                baseline_env: None,
5504                interpreter_runtime: Vec::new(),
5505                staged_runtime: Vec::new(),
5506            }),
5507            spawn: None,
5508            env: Some(murmur_artifact::EnvCapabilities { allow: Vec::new() }),
5509            limits: None,
5510            resources: None,
5511            containment: Some(murmur_artifact::ContainmentClass::Sealed),
5512        };
5513
5514        assert_eq!(
5515            inert_capability_sub_blocks(Some(&caps)),
5516            vec!["shell", "env", "containment"]
5517        );
5518        assert!(inert_capability_sub_blocks(None).is_empty());
5519    }
5520
5521    // ── End-to-end reopen loop (real Wasmtime hook + real process transport) ──────
5522
5523    /// Write an executable fake `claude`-dialect CLI that, on each spawn, consumes its
5524    /// stdin then emits exactly one assistant text turn and a success result — so every
5525    /// agent-loop attempt burns exactly one turn and returns `Ok`.
5526    fn write_fake_claude_cli(dir: &Path) -> PathBuf {
5527        let script = dir.join("claude");
5528        fs::write(
5529            &script,
5530            "#!/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",
5531        )
5532        .unwrap();
5533        #[cfg(unix)]
5534        {
5535            use std::os::unix::fs::PermissionsExt;
5536            let mut perms = fs::metadata(&script).unwrap().permissions();
5537            perms.set_mode(0o755);
5538            fs::set_permissions(&script, perms).unwrap();
5539        }
5540        script
5541    }
5542
5543    /// A current-version (`@0.5.0`, 5-case `hook-output`) `on-task-end` hook double that
5544    /// returns `reopen-task(reason)` on its first `reopen_limit` invocations (tracked by a
5545    /// mutable core global that persists across a blocking hook's reused store) and `none`
5546    /// thereafter. `reopen_limit` large ⇒ "always reopen".
5547    fn on_task_end_reopen_double(
5548        engine: &wasmtime::Engine,
5549        reopen_limit: u32,
5550        reason: &str,
5551    ) -> wasmtime::component::Component {
5552        let reason_len = reason.len();
5553        let stubs = [
5554            "on-session-start",
5555            "on-inference",
5556            "on-tool-call",
5557            "on-shell",
5558            "on-compaction",
5559            "on-session-end",
5560        ]
5561        .iter()
5562        .map(|n| format!("    (export \"{n}\" (func $noop))"))
5563        .collect::<Vec<_>>()
5564        .join("\n");
5565        let wat = format!(
5566            r#"(component
5567  (core module $m
5568    (memory (export "memory") 1)
5569    (global $count (mut i32) (i32.const 0))
5570    (data (i32.const 300) "{reason}")
5571    (func (export "realloc") (param i32 i32 i32 i32) (result i32) i32.const 512)
5572    (func (export "ontaskend") (param i32 i32 i32 i32) (result i32)
5573      (i32.store (i32.const 128) (i32.const 0))
5574      (if (result i32) (i32.lt_u (global.get $count) (i32.const {reopen_limit}))
5575        (then
5576          (global.set $count (i32.add (global.get $count) (i32.const 1)))
5577          (i32.store (i32.const 132) (i32.const 4))
5578          (i32.store (i32.const 136) (i32.const 300))
5579          (i32.store (i32.const 140) (i32.const {reason_len}))
5580          (i32.const 128))
5581        (else
5582          (i32.store (i32.const 132) (i32.const 0))
5583          (i32.const 128))))
5584    (func (export "noop"))
5585  )
5586  (core instance $i (instantiate $m))
5587  (alias core export $i "memory" (core memory $mem))
5588  (alias core export $i "realloc" (core func $realloc))
5589
5590  (type $message (record (field "role" string) (field "content" string)))
5591  (type $tool-manifest (record (field "binary-name" string) (field "content" string)))
5592  (type $hook-output (variant
5593    (case "none")
5594    (case "replace-context" (list $message))
5595    (case "write-manifests" (list $tool-manifest))
5596    (case "artifact" string)
5597    (case "reopen-task" string)))
5598  (type $task-end-event (record
5599    (field "task-id" string)
5600    (field "exit-status" string)))
5601  (type $ft (func (param "event" $task-end-event) (result (result $hook-output (error string)))))
5602
5603  (func $te (type $ft)
5604    (canon lift (core func $i "ontaskend") (memory $mem) (realloc $realloc) string-encoding=utf8))
5605  (func $noop (canon lift (core func $i "noop")))
5606
5607  (instance $lc
5608    (export "message" (type $message))
5609    (export "tool-manifest" (type $tool-manifest))
5610    (export "hook-output" (type $hook-output))
5611    (export "task-end-event" (type $task-end-event))
5612    (export "on-task-end" (func $te))
5613{stubs}
5614  )
5615  (export "murmur:hook/lifecycle@0.5.0" (instance $lc))
5616)"#
5617        );
5618        let bytes = wat::parse_str(&wat).expect("on-task-end reopen double WAT parses");
5619        wasmtime::component::Component::new(engine, &bytes)
5620            .expect("on-task-end reopen double compiles")
5621    }
5622
5623    /// Drive `run_task_with_reopens` once, with a real process-transport agent loop (fake
5624    /// `claude` CLI, one turn per attempt) and a real Wasmtime `on-task-end` hook that
5625    /// reopens `reopen_limit` times. Returns the task result and the parsed `trace.jsonl`.
5626    async fn run_reopen_scenario(
5627        reopen_limit: u32,
5628        max_task_reopens: u32,
5629        max_turns: u32,
5630    ) -> (Result<(), RuntimeError>, Vec<serde_json::Value>) {
5631        let dir = tempfile::tempdir().unwrap();
5632        let workdir = dir.path().to_path_buf();
5633        fs::create_dir_all(workdir.join("tools")).unwrap();
5634        fs::write(workdir.join("task.md"), "Original task: build the thing.").unwrap();
5635        let cli = write_fake_claude_cli(dir.path());
5636
5637        let inference = InferenceConfig {
5638            transport: "process".into(),
5639            endpoint: None,
5640            model: "test-model".into(),
5641            api_key: None,
5642            driver: None,
5643            command: Some(cli.to_string_lossy().to_string()),
5644            compaction: None,
5645            system_prompt: None,
5646            system_prompt_file: None,
5647            system_prompt_artifact: None,
5648            max_turns,
5649            max_task_reopens,
5650            max_tokens: None,
5651        };
5652
5653        let mut state = build_test_state(
5654            Arc::new(FakeSkillRegistry::new(Vec::new())),
5655            workdir.clone(),
5656            workdir.join("murmur.lock"),
5657        );
5658
5659        let mut trace = TraceWriter::open(
5660            &workdir,
5661            "ses_test".to_string(),
5662            "cap".to_string(),
5663            "0.1.0".to_string(),
5664            "test-model".to_string(),
5665            Vec::new(),
5666            crate::containment::scope_report_for_tier(
5667                &CapabilityPolicy::default(),
5668                murmur_artifact::ContainmentClass::Advisory,
5669                sandbox::EnforcementTier::EnvironmentOnly,
5670                None,
5671            ),
5672            false,
5673            None,
5674            false,
5675        )
5676        .await
5677        .unwrap();
5678
5679        let mut otel = OtelEmitter::new(None, &workdir, "cap".to_string(), "0.1.0".to_string());
5680
5681        let staged_hook = StagedHookArtifact {
5682            name: "gatekeeper".to_string(),
5683            version: "0.0.1".to_string(),
5684            component: on_task_end_reopen_double(&state.engine, reopen_limit, "tests still fail"),
5685            config: murmur_artifact::HookConfig {
5686                binding: HookBinding::OnTaskEnd,
5687                execution_mode: murmur_artifact::HookExecutionMode::Blocking,
5688                commit_policy: murmur_artifact::HookCommitPolicy::ReopenTask,
5689            },
5690            grant: HookCapabilityGrant::default(),
5691            on_overflow: Default::default(),
5692        };
5693
5694        let mut hooks = HookRuntime::new(
5695            &state.engine,
5696            &workdir,
5697            &workdir,
5698            vec![staged_hook],
5699            SessionContextData {
5700                capsule_name: "cap".to_string(),
5701                capsule_version: "0.1.0".to_string(),
5702                session_id: "ses_test".to_string(),
5703                model: "test-model".to_string(),
5704                capabilities: Vec::new(),
5705            },
5706            HookEnvVars::default(),
5707            crate::limits::ExecutionLimits::default(),
5708            None,
5709        )
5710        .await
5711        .unwrap();
5712
5713        let run_config = agent::AgentRunConfig {
5714            context_window: 0,
5715            compaction_threshold: 0.98,
5716            compaction_model: None,
5717            compaction_system_prompt: None,
5718            compaction_dump_summaries: false,
5719            max_output_tokens: 1024,
5720        };
5721
5722        // Caller resets per-task counters via write_task_start before the reopen loop.
5723        trace
5724            .write_task_start("tsk_1", "ctx_1", "task_md", 8)
5725            .await
5726            .unwrap();
5727
5728        let result = run_task_with_reopens(
5729            &mut state,
5730            &workdir,
5731            &inference,
5732            None,
5733            run_config,
5734            &mut hooks,
5735            &mut trace,
5736            &mut otel,
5737            None,
5738            None,
5739            &workdir,
5740            "cap",
5741            "0.1.0",
5742            ConversationMode::Stateless,
5743            Some("ctx_1".to_string()),
5744            "tsk_1",
5745        )
5746        .await;
5747
5748        trace.flush().await.unwrap();
5749        let content = fs::read_to_string(workdir.join("trace.jsonl")).unwrap();
5750        let events: Vec<serde_json::Value> = content
5751            .lines()
5752            .filter(|l| !l.is_empty())
5753            .map(|l| serde_json::from_str(l).unwrap())
5754            .collect();
5755        (result, events)
5756    }
5757
5758    fn count_type(events: &[serde_json::Value], ty: &str) -> usize {
5759        events.iter().filter(|e| e["event_type"] == ty).count()
5760    }
5761
5762    /// Happy path: one reopen, then the hook is satisfied. The agent loop runs twice, one
5763    /// `task_reopened` sits between the attempts naming the hook and its feedback, and the
5764    /// terminal `task_end` shows `reopen_count: 1` with the second attempt's real outcome.
5765    #[tokio::test(flavor = "multi_thread")]
5766    async fn reopen_once_then_satisfied_end_to_end() {
5767        let (result, events) = run_reopen_scenario(1, 5, 10).await;
5768        assert!(
5769            result.is_ok(),
5770            "a satisfied hook ends the task Ok: {result:?}"
5771        );
5772        assert_eq!(
5773            count_type(&events, "inference"),
5774            2,
5775            "agent loop ran exactly twice"
5776        );
5777        assert_eq!(count_type(&events, "task_reopened"), 1);
5778        let re = events
5779            .iter()
5780            .find(|e| e["event_type"] == "task_reopened")
5781            .unwrap();
5782        assert_eq!(re["hook_name"], "gatekeeper");
5783        assert_eq!(re["reason"], "tests still fail");
5784        assert_eq!(re["reopen_number"], 1);
5785        let end = events
5786            .iter()
5787            .find(|e| e["event_type"] == "task_end")
5788            .unwrap();
5789        assert_eq!(end["reopen_count"], 1);
5790        assert_eq!(end["exit_status"], "ok");
5791    }
5792
5793    /// Budget exhausted: a hook that always reopens with `max_task_reopens: 1` runs the
5794    /// loop exactly twice, then ends `reopen_budget_exhausted` (an `Err`, so downstream
5795    /// task-failure branches fire) with `reopen_count: 1`.
5796    #[tokio::test(flavor = "multi_thread")]
5797    async fn reopen_budget_exhausted_end_to_end() {
5798        let (result, events) = run_reopen_scenario(99, 1, 10).await;
5799        assert!(
5800            result.is_err(),
5801            "an exhausted reopen budget is a task failure"
5802        );
5803        assert_eq!(count_type(&events, "inference"), 2, "1 original + 1 reopen");
5804        assert_eq!(count_type(&events, "task_reopened"), 1);
5805        let end = events
5806            .iter()
5807            .find(|e| e["event_type"] == "task_end")
5808            .unwrap();
5809        assert_eq!(end["reopen_count"], 1);
5810        assert_eq!(end["exit_status"], "reopen_budget_exhausted");
5811    }
5812
5813    /// Turn ceiling respected: `max_turns: 3`, `max_task_reopens: 5`, a hook that always
5814    /// reopens, one turn per attempt. Cumulative `inference` records never exceed 3, and
5815    /// the task ends `reopen_budget_exhausted` once turns run out even though reopens
5816    /// remain in the budget.
5817    #[tokio::test(flavor = "multi_thread")]
5818    async fn reopen_never_exceeds_max_turns_end_to_end() {
5819        let (result, events) = run_reopen_scenario(99, 5, 3).await;
5820        assert!(result.is_err());
5821        assert_eq!(
5822            count_type(&events, "inference"),
5823            3,
5824            "cumulative turns must never exceed max_turns"
5825        );
5826        assert_eq!(
5827            count_type(&events, "task_reopened"),
5828            2,
5829            "3 attempts ⇒ 2 reopens"
5830        );
5831        let end = events
5832            .iter()
5833            .find(|e| e["event_type"] == "task_end")
5834            .unwrap();
5835        assert_eq!(end["reopen_count"], 2);
5836        assert_eq!(end["exit_status"], "reopen_budget_exhausted");
5837    }
5838}