Skip to main content

warble_cli/
lib.rs

1//! `warble_cli` — the native host library behind the `warble` binary.
2//!
3//! Holds the pieces the binary and the golden integration tests share. The headline is
4//! [`compile_project_to_ir`]: the real front-end host path — read a Warble project's files, resolve
5//! the declared context kind into a [`ContextLoader`] via [`BuiltinContextResolver`], and run the
6//! sans-IO core compiler with it injected. Resolving a kind is all this host does; it reads no
7//! semantic format, so a layer reaches it as a prepared-context document the producer wrote.
8//! (The binary's `dispatch`/`render`/`manifest`/`eval` subcommands stay in `main.rs`.)
9//!
10//! [`blast_radius_for_project`] reuses the same project-resolution path to answer a `blast_radius`
11//! query without running a full compile — the host side of the `warble blast-radius` subcommand.
12//!
13//! Component *resolution* — turning a profile's `use: <id>` mount into a directory to read
14//! `component.yml`/steps from — is entirely a host concern (see [`ComponentSource`] /
15//! `resolve_component_dir`): the core compiler never touches a filesystem, it only ever sees the
16//! already-resolved `HashMap<String, ComponentFile>` this module builds.
17//!
18//! This is the crate that becomes the `warble` binary itself — end users install just this one.
19//! It links in both dispatcher back-ends (`warble-claude-code`, `warble-vercel`) and nothing that
20//! speaks a semantic format; neither of those two crates is a standalone tool, and
21//! `warble dispatch --target ...` simply selects which linked-in back-end handles the compiled
22//! IR.
23
24mod authoring;
25pub mod gate;
26pub mod hub_fetch;
27pub mod overlay;
28pub mod provenance;
29
30use std::collections::HashMap;
31use std::fmt;
32use std::path::{Path, PathBuf};
33
34use sha2::{Digest, Sha256};
35use warble::{
36    read_raw_dir, BindingFile, ComponentFile, ContextLoader, PreparedContext, RawSourceContext,
37};
38use warble_claude_code::ir::SUPPORTED_IR_VERSION;
39
40/// The precedence class a [`ComponentSource`] belongs to. Precedence is a fixed rule *between*
41/// kinds — `Local` always outranks `Hub` — not derived from the order sources happen to be listed
42/// in. There is deliberately no rule *within* a kind: if the same component id is found in two
43/// sources of the same kind, `resolve_component_dir` treats that as ambiguous rather than
44/// guessing (e.g. "first in the list wins"), because nothing declares which of two equally-ranked
45/// sources should win.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum SourceKind {
48    /// A host- or product-specific component directory — e.g. a profile's own `components/`
49    /// override, or an external component library a consumer mounts alongside the Hub. Wins over
50    /// `Hub` whenever both define the same id.
51    Local,
52    /// The shared, generic Warble component library (this checkout's `hub/components`, or an
53    /// externally-supplied equivalent). The fallback tier: consulted only for ids no `Local`
54    /// source defines.
55    Hub,
56}
57
58impl fmt::Display for SourceKind {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(match self {
61            SourceKind::Local => "local",
62            SourceKind::Hub => "hub",
63        })
64    }
65}
66
67/// One explicitly-configured place a mounted component's files may live: `components_dir` is a
68/// directory whose immediate children are `<id>/component.yml` (+ its `steps/*`). The host builds
69/// the full ordered/kinded list up front (see [`compile_project_to_ir_with_sources`]) — resolution
70/// never discovers sources on its own (no ancestor/filesystem-root walk).
71#[derive(Debug, Clone)]
72pub struct ComponentSource {
73    pub kind: SourceKind,
74    pub components_dir: PathBuf,
75}
76
77impl ComponentSource {
78    pub fn local(components_dir: impl Into<PathBuf>) -> Self {
79        ComponentSource {
80            kind: SourceKind::Local,
81            components_dir: components_dir.into(),
82        }
83    }
84
85    pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
86        ComponentSource {
87            kind: SourceKind::Hub,
88            components_dir: components_dir.into(),
89        }
90    }
91
92    fn candidate(&self, id: &str) -> PathBuf {
93        self.components_dir.join(id)
94    }
95}
96
97/// This checkout's own Hub component library — a fixed sibling of the `cli` crate's manifest dir,
98/// known at compile time (`CARGO_MANIFEST_DIR`), never discovered by walking the filesystem at
99/// runtime. This is what backs [`compile_project_to_ir`]'s default source list, so every in-repo
100/// example/eval profile keeps resolving its Hub-mounted components exactly as before.
101fn in_repo_hub_dir() -> PathBuf {
102    Path::new(env!("CARGO_MANIFEST_DIR"))
103        .join("..")
104        .join("hub")
105        .join("components")
106}
107
108/// The Hub source used when no `--hub-dir` override is given: this checkout's own
109/// `hub/components/` if it exists on disk, otherwise the per-user cache of the Hub release
110/// matching `hub_version` (or this binary's own version, when `hub_version` is `None`), fetching
111/// it over the network first if the cache doesn't already hold a verified copy.
112///
113/// The in-repo check comes first and is unconditional: an in-repo Hub directory always wins and
114/// never triggers a fetch, even when `hub_version` names a different version than this binary's
115/// own — "there is a Hub checked out right here" is decisive, not one input among several. This is also what keeps every in-repo example/eval profile (and the CLI's own
116/// integration tests) resolving offline, with no network access, when run from within this
117/// checkout.
118fn default_hub_source(hub_version: Option<&str>) -> Result<ComponentSource, String> {
119    let in_repo = in_repo_hub_dir();
120    if in_repo.is_dir() {
121        return Ok(ComponentSource::hub(in_repo));
122    }
123    let version = hub_version.unwrap_or(env!("CARGO_PKG_VERSION"));
124    let cached_dir = hub_fetch::ensure_cached_hub(version)?;
125    Ok(ComponentSource::hub(cached_dir))
126}
127
128/// The default two-source list used by [`compile_project_to_ir`]: the project's own `components/`
129/// (`Local`, highest precedence — lets a profile deliberately diverge from the Hub, e.g. an
130/// eval/demo substrate with intentionally different anatomy) plus the Hub (`Hub`, fallback) — see
131/// the crate's private `default_hub_source` helper for how the Hub source itself is resolved. A
132/// host mounting an *additional* local component library (e.g. a product-specific one, alongside
133/// the Hub) extends this list — see [`compile_project_to_ir_with_sources`].
134///
135/// Fallible since v0.7.0: resolving the Hub source can now require a network fetch (via that same
136/// `default_hub_source` helper), which can fail. Callers that previously relied on this being
137/// infallible need a `?` or `.expect(...)`.
138pub fn default_component_sources(project_dir: &Path) -> Result<Vec<ComponentSource>, String> {
139    default_component_sources_with_hub_version(project_dir, None)
140}
141
142/// Same as [`default_component_sources`], but lets the caller pick which Hub version to fetch
143/// when default resolution needs the network (i.e. no in-repo `hub/components` on disk). This is
144/// the seam the CLI's `--hub-version` flag threads through; `None` means "this binary's own
145/// version", the ordinary default.
146pub fn default_component_sources_with_hub_version(
147    project_dir: &Path,
148    hub_version: Option<&str>,
149) -> Result<Vec<ComponentSource>, String> {
150    Ok(vec![
151        ComponentSource::local(project_dir.join("components")),
152        default_hub_source(hub_version)?,
153    ])
154}
155
156/// Whether any mount still needs library resolution after inline and explicit local sources.
157/// This inspects the final overlaid profile before a CLI decides to fetch a Hub archive.
158pub fn project_needs_hub(
159    project_dir: &Path,
160    local_sources: &[ComponentSource],
161    overlay_path: Option<&Path>,
162) -> Result<bool, String> {
163    let profile_path = project_dir.join("profile.yml");
164    let (mut profile, inline) = authoring::parse_profile(&read_file(&profile_path)?)
165        .map_err(|error| format!("failed to parse {}: {error}", profile_path.display()))?;
166    if let Some(path) = overlay_path {
167        overlay::apply_overlay(&mut profile, &overlay::read_overlay(path)?)?;
168    }
169    Ok(profile.components.iter().any(|mount| {
170        !inline.contains_key(&mount.use_id)
171            && !local_sources.iter().any(|source| {
172                source
173                    .candidate(&mount.use_id)
174                    .join("component.yml")
175                    .is_file()
176            })
177    }))
178}
179
180/// Resolve where a mounted component's directory lives, against an explicit, ordered set of
181/// sources — never by walking the filesystem. Within the highest-precedence [`SourceKind`] that
182/// contains a match, exactly one source must define `id`; more than one is **ambiguous** (loud
183/// fail — no rule picks between equally-ranked sources) and zero across every source is
184/// **unresolved** (loud fail, listing everywhere it looked).
185fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
186    for kind in [SourceKind::Local, SourceKind::Hub] {
187        let matches: Vec<&ComponentSource> = sources
188            .iter()
189            .filter(|source| source.kind == kind)
190            .filter(|source| source.candidate(id).join("component.yml").is_file())
191            .collect();
192
193        match matches.as_slice() {
194            [] => continue,
195            [only] => return Ok(only.candidate(id)),
196            many => {
197                let dirs: Vec<String> = many
198                    .iter()
199                    .map(|s| s.candidate(id).display().to_string())
200                    .collect();
201                return Err(format!(
202                    "component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
203                     precedence rule distinguishes sources of the same kind",
204                    many.len(),
205                    dirs.join(", ")
206                ));
207            }
208        }
209    }
210
211    let searched: Vec<String> = sources
212        .iter()
213        .map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
214        .collect();
215    Err(format!(
216        "component '{id}' not found in any configured source: {}",
217        searched.join("; ")
218    ))
219}
220
221/// Turns a parsed `context/binding.yml` into the [`ContextLoader`] the compiler probes.
222///
223/// The counterpart of [`ComponentSource`] for context: components could already be supplied by a
224/// host, context could not. A host implements this to bind a semantic layer this checkout cannot
225/// read itself — one held by a service, for instance — without warble learning anything about it.
226///
227/// **A resolver is free to do no I/O at all, and `warble compile` must stay runnable offline and
228/// without credentials.** A host binding a remote layer is expected to resolve from a snapshot it
229/// pulled earlier, or to return a loader that declines the schema probes
230/// (`ContextLoader::can_answer`) rather than reaching for the network mid-compile.
231pub trait ContextResolver {
232    /// Build the loader for `binding`. `project_dir` is the Warble project directory, so a resolver
233    /// reading from disk can resolve a relative `binding.project` against it.
234    fn resolve(
235        &self,
236        binding: &BindingFile,
237        project_dir: &Path,
238    ) -> Result<Box<dyn ContextLoader>, String>;
239}
240
241/// The context kinds this checkout resolves without help: `raw_source` (read natively), `external`
242/// (read nothing) and `prepared` (read a projection the host already resolved). A host that needs
243/// another kind wraps this — delegating the ones it knows and handling its own.
244///
245/// `wren_project` is recognized but no longer resolved: it is matched only to say what to write
246/// instead, because a binding authored against an older warble names a kind this build cannot read
247/// rather than one it has never heard of.
248///
249/// `prepared` is the kind that does not require warble to speak the semantic format at all, so a
250/// host whose format has no adapter here binds through it rather than through a linked resolver —
251/// which is the only option open to a host that drives `warble` as a subprocess.
252pub struct BuiltinContextResolver;
253
254/// The retired kind name, kept in the reference host rather than in core: core no longer carries
255/// any semantic format's vocabulary, but the host that used to resolve this kind is the right
256/// place to recognize it and say what replaced it.
257const WREN_PROJECT_RETIRED: &str = "wren_project";
258
259impl ContextResolver for BuiltinContextResolver {
260    fn resolve(
261        &self,
262        binding: &BindingFile,
263        project_dir: &Path,
264    ) -> Result<Box<dyn ContextLoader>, String> {
265        // `external` names a layer that is not on this machine, so it must be resolved before any
266        // path is built — joining a locator like `remote-service://analytics` onto a directory would produce
267        // nonsense, and reading anything at all would break the offline guarantee.
268        if binding.kind == BindingFile::EXTERNAL {
269            return Ok(Box::new(warble::ExternalContext::new()));
270        }
271        // The remaining built-in kinds read a directory, so `project` is a path for them. A host
272        // kind's `project` may be anything at all, which is why this resolution lives per-kind
273        // rather than in the caller.
274        let path = project_dir.join(&binding.project);
275        match binding.kind.as_str() {
276            // Reading a wren project means linking a semantic-format library, which core no
277            // longer does. The kind is still matched by name so an older binding gets the
278            // migration, not the generic unknown-kind error that would leave the author guessing
279            // which of the remaining kinds replaced it.
280            WREN_PROJECT_RETIRED => Err(format!(
281                "`kind: {WREN_PROJECT_RETIRED}` is no longer resolved: warble reads no semantic \
282                 format itself. Have the host read {} and write a prepared-context document, then \
283                 bind it with `kind: {}` and a `document:` field naming that document.",
284                path.display(),
285                BindingFile::PREPARED
286            )),
287            BindingFile::RAW_SOURCE => {
288                let raw = read_raw_dir(&path)
289                    .map_err(|e| format!("failed to read {}: {e}", path.display()))?
290                    .ok_or_else(|| {
291                        format!(
292                            "binding declares `kind: {}` but {} has no schema.json",
293                            BindingFile::RAW_SOURCE,
294                            path.display()
295                        )
296                    })?;
297                Ok(Box::new(RawSourceContext::from_sources(&raw)))
298            }
299            BindingFile::PREPARED => {
300                // `project` stays what it is for every other kind: the bound layer's identity,
301                // echoed into the IR and the `{{project}}` placeholder. The document is a separate
302                // field, because pointing `project` at the file would put the file's name into
303                // every prompt — telling the agent it works on a project called "context.json".
304                let document_ref = binding
305                    .extra
306                    .get("document")
307                    .and_then(|v| v.as_str())
308                    .ok_or_else(|| {
309                        format!(
310                            "binding declares `kind: {}` but no `document:` — add the path to the \
311                             prepared-context document the host wrote, and keep `project:` as the \
312                             bound layer's name",
313                            BindingFile::PREPARED
314                        )
315                    })?;
316                let document_path = project_dir.join(document_ref);
317                // A missing or malformed document is an error rather than an unparseable context:
318                // the binding named a file the host was supposed to write, so its absence is a
319                // broken pipeline, not a project without a semantic layer.
320                let document = std::fs::read_to_string(&document_path)
321                    .map_err(|e| format!("failed to read {}: {e}", document_path.display()))?;
322                let context = PreparedContext::from_json(&document)
323                    .map_err(|e| format!("{}: {e}", document_path.display()))?;
324                Ok(Box::new(context))
325            }
326            other => Err(format!(
327                "unknown context kind '{other}' (this build resolves '{}', '{}' and '{}'). A \
328                 host that defines '{other}' must supply a ContextResolver for it — see \
329                 `compile_project_to_ir_with`.",
330                BindingFile::RAW_SOURCE,
331                BindingFile::EXTERNAL,
332                BindingFile::PREPARED
333            )),
334        }
335    }
336}
337
338/// Compile a Warble project directory into its IR JSON, resolving the context binding through
339/// [`BuiltinContextResolver`] and using the default component source list (project-local `components/` + this
340/// checkout's Hub — see [`default_component_sources`]). This is what every in-repo example/eval
341/// profile and integration test compiles through.
342pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
343    let local = vec![ComponentSource::local(project_dir.join("components"))];
344    let sources = if project_needs_hub(project_dir, &local, None)? {
345        default_component_sources(project_dir)?
346    } else {
347        local
348    };
349    compile_project_to_ir_with_sources(project_dir, &sources)
350}
351
352/// Compile a Warble project directory into its IR JSON, resolving mounted components against an
353/// explicit, caller-supplied source list instead of the in-repo default (see [`ComponentSource`]).
354/// This is the seam a host outside this checkout uses to mount its own local component library
355/// alongside (or instead of) the Hub — e.g. `[ComponentSource::local(my_components),
356/// ComponentSource::hub(path_to_warble_hub)]`. All filesystem reads happen here; the core compiler
357/// stays sans-IO — it only ever receives the resolved `HashMap<String, ComponentFile>` built below.
358pub fn compile_project_to_ir_with_sources(
359    project_dir: &Path,
360    sources: &[ComponentSource],
361) -> Result<serde_json::Value, String> {
362    compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
363}
364
365/// As [`compile_project_to_ir_with_sources`], resolving the context binding through a
366/// caller-supplied [`ContextResolver`] instead of [`BuiltinContextResolver`]. This is the seam a
367/// host uses to bind a context kind this checkout cannot read — the context-side counterpart of
368/// passing your own [`ComponentSource`] list.
369pub fn compile_project_to_ir_with(
370    project_dir: &Path,
371    sources: &[ComponentSource],
372    resolver: &dyn ContextResolver,
373) -> Result<serde_json::Value, String> {
374    compile_project_to_ir_with_overlay(project_dir, sources, resolver, None)
375}
376
377/// As [`compile_project_to_ir_with`], additionally applying an overlay document to the parsed
378/// profile before compiling — see [`crate::overlay`] for what a patch may touch and why.
379///
380/// The overlay is applied here, between parsing `profile.yml` and resolving the mounted
381/// components, for two reasons that are easy to get wrong: a patch may add a mount, whose
382/// `component.yml` must therefore still be read; and every compile-time check then runs against
383/// the patched profile without any of them having to know a patch happened. The capability
384/// ceiling is the case that matters — it is enforced per mount, so a patch mounting a behavior
385/// the profile does not permit is refused with no ordering rule arranged for it.
386///
387/// `None` is exactly the previous behavior, which is why the three entry points above delegate
388/// here rather than duplicating the body.
389pub fn compile_project_to_ir_with_overlay(
390    project_dir: &Path,
391    sources: &[ComponentSource],
392    resolver: &dyn ContextResolver,
393    overlay_path: Option<&Path>,
394) -> Result<serde_json::Value, String> {
395    compile_project_to_ir_with_assets(project_dir, sources, resolver, overlay_path)
396        .map(|(ir, _)| ir)
397}
398
399/// A component's asset content, keyed by component id, each entry the authored relative path and
400/// the file's bytes.
401///
402/// Exists because compile is the only place that holds this: the compiler is sans-IO, and a
403/// dispatch has no component directory to re-read. See [`write_assets`].
404pub type CompiledAssets = std::collections::BTreeMap<String, Vec<(String, Vec<u8>)>>;
405
406/// Compile, and also hand back the asset content the manifest names.
407///
408/// The extra return value is what makes an IR's assets transportable. Callers that do not need it
409/// use [`compile_project_to_ir_with_overlay`], which drops it.
410pub fn compile_project_to_ir_with_assets(
411    project_dir: &Path,
412    sources: &[ComponentSource],
413    resolver: &dyn ContextResolver,
414    overlay_path: Option<&Path>,
415) -> Result<(serde_json::Value, CompiledAssets), String> {
416    let result =
417        compile_project_with_provenance(project_dir, sources, resolver, overlay_path, false)?;
418    Ok((result.ir, result.assets))
419}
420
421/// One compilation with local author origins, kept outside the portable IR.
422pub struct AuthorCompilation {
423    pub ir: serde_json::Value,
424    pub assets: CompiledAssets,
425    pub sources: Vec<provenance::AuthorSource>,
426}
427
428/// Compile an author project with exact local origins and strict profile/config field checks.
429/// Overlay and runtime host inputs remain on the lower-level integration path.
430pub fn compile_project_for_authoring(
431    project_dir: &Path,
432    sources: &[ComponentSource],
433    resolver: &dyn ContextResolver,
434) -> Result<AuthorCompilation, String> {
435    compile_project_with_provenance(project_dir, sources, resolver, None, true)
436}
437
438fn compile_project_with_provenance(
439    project_dir: &Path,
440    sources: &[ComponentSource],
441    resolver: &dyn ContextResolver,
442    overlay_path: Option<&Path>,
443    strict_profile: bool,
444) -> Result<AuthorCompilation, String> {
445    let mut collected_assets: CompiledAssets = CompiledAssets::new();
446    let mut origins = Vec::new();
447    let profile_path = project_dir.join("profile.yml");
448    let profile_text = read_file(&profile_path)?;
449    if strict_profile {
450        provenance::validate_profile_fields(&profile_text, &profile_path)?;
451    }
452    let (mut profile, inline_components) = authoring::parse_profile(&profile_text)
453        .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
454
455    if let Some(path) = overlay_path {
456        let overlay = overlay::read_overlay(path)?;
457        overlay::apply_overlay(&mut profile, &overlay)?;
458    }
459
460    provenance::profile_sources(&profile, &profile_path, &mut origins);
461    let (project, context): (String, Box<dyn ContextLoader>) = match &profile.context {
462        Some(authored) => {
463            let binding_path = project_dir.join(&authored.project);
464            let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
465                .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
466            let context = resolver.resolve(&binding, project_dir)?;
467            (binding.project, context)
468        }
469        None => (String::new(), Box::new(warble::NoContext::default())),
470    };
471
472    let mut components: HashMap<String, ComponentFile> = HashMap::new();
473    let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
474    // A profile-level slot's variants resolve against the profile's own directory, which is the
475    // project dir — the same shared rule component-level references follow, with the base dir
476    // being whatever owns the declaration.
477    let mut profile_slots: HashMap<String, HashMap<String, String>> = HashMap::new();
478    for slot in &profile.slots {
479        let mut variants: HashMap<String, String> = HashMap::new();
480        for (key, reference) in &slot.variants {
481            let label = format!("profile slot '{}' variant '{}'", slot.name, key);
482            let variant_path = resolve_file_ref(project_dir, reference, &label)?;
483            variants.insert(key.clone(), read_file(&variant_path)?);
484        }
485        profile_slots.insert(slot.name.clone(), variants);
486    }
487    let mut slot_contents = warble::SlotContents {
488        profile: profile_slots,
489        ..Default::default()
490    };
491
492    for (mount_index, mount) in profile.components.iter().enumerate() {
493        let (component_dir, mut component, shorthand) =
494            if let Some(component) = inline_components.get(&mount.use_id) {
495                let raw: serde_yaml::Value =
496                    serde_yaml::from_str(&profile_text).expect("profile parsed");
497                let shorthand = raw["components"][mount_index].get("prompt").is_some();
498                (project_dir.to_path_buf(), component.clone(), shorthand)
499            } else {
500                let dir = resolve_component_dir(sources, &mount.use_id)?;
501                let path = dir.join("component.yml");
502                let value: serde_yaml::Value = serde_yaml::from_str(&read_file(&path)?)
503                    .map_err(|e| format!("failed to parse {}: {e}", path.display()))?;
504                let shorthand = value.get("prompt").is_some();
505                let component = authoring::parse_component(value)
506                    .map_err(|e| format!("failed to parse {}: {e}", path.display()))?;
507                (dir, component, shorthand)
508            };
509        provenance::component_sources(
510            &profile,
511            mount,
512            &component,
513            &profile_path,
514            &component_dir,
515            inline_components.contains_key(&mount.use_id),
516            shorthand,
517            &mut origins,
518        );
519        if component.id != mount.use_id {
520            return Err(format!(
521                "mount '{}' resolves to component '{}'",
522                mount.use_id, component.id
523            ));
524        }
525        let mut steps: HashMap<String, String> = HashMap::new();
526        for step in &component.llm_steps {
527            let text = match (&step.prompt, &step.prompt_ref) {
528                (Some(text), None) if !text.trim().is_empty() => text.clone(),
529                (None, Some(reference)) if !reference.trim().is_empty() => {
530                    let path = resolve_file_ref(&component_dir, reference, "prompt_ref")?;
531                    read_file(&path)?
532                }
533                _ => {
534                    return Err(format!(
535                    "component '{}' step '{}': supply exactly one non-empty prompt or prompt_ref",
536                    component.id, step.name
537                ))
538                }
539            };
540            steps.insert(step.name.clone(), text);
541        }
542        step_contents.insert(component.id.clone(), steps);
543
544        // Slot variants are prompt text, so they are read here and travel into the IR — the same
545        // treatment `prompt_ref` gets, through the same resolution rule, because core never opens
546        // a file itself.
547        let mut slots: HashMap<String, HashMap<String, String>> = HashMap::new();
548        for slot in &component.slots {
549            let mut variants: HashMap<String, String> = HashMap::new();
550            for (key, reference) in &slot.variants {
551                let label = format!("slot '{}' variant '{}'", slot.name, key);
552                let variant_path = resolve_file_ref(&component_dir, reference, &label)?;
553                variants.insert(key.clone(), read_file(&variant_path)?);
554            }
555            slots.insert(slot.name.clone(), variants);
556        }
557        if !slots.is_empty() {
558            slot_contents.components.insert(component.id.clone(), slots);
559        }
560
561        // Assets are never read into the IR — only their identity (hash + size) is, and core never
562        // opens a file itself, so both are computed here before the declaration reaches `compile`.
563        //
564        // The bytes are kept rather than dropped. This is the only place that has them: `core` is
565        // sans-IO, and by dispatch there is no component directory at all — a Hub component was
566        // resolved over the network here, possibly on another machine. So the content has to leave
567        // compile alongside the IR or it cannot reach a runtime. See decision-101.
568        for asset in &mut component.assets {
569            let asset_path = resolve_file_ref(&component_dir, &asset.path, "asset")?;
570            let data = read_file_bytes(&asset_path)?;
571            asset.bytes = Some(data.len() as u64);
572            asset.hash = Some(format!("sha256:{:x}", Sha256::digest(&data)));
573            collected_assets
574                .entry(component.id.clone())
575                .or_default()
576                .push((asset.path.clone(), data));
577        }
578
579        components.insert(component.id.clone(), component);
580    }
581
582    let ir = warble::compile(
583        &profile,
584        &components,
585        &project,
586        context.as_ref(),
587        &step_contents,
588        &slot_contents,
589    )
590    .map_err(|e| {
591        if strict_profile {
592            format!(
593                "{e}\nAuthor sources:\n{}",
594                origins
595                    .iter()
596                    .filter(|s| s.role == "component declaration")
597                    .map(|s| format!(
598                        "  {}#{} ({})",
599                        s.file,
600                        s.field,
601                        s.component.as_deref().unwrap_or("profile")
602                    ))
603                    .collect::<Vec<_>>()
604                    .join("\n")
605            )
606        } else {
607            e.to_string()
608        }
609    })?;
610    Ok(AuthorCompilation {
611        ir,
612        assets: collected_assets,
613        sources: origins,
614    })
615}
616
617/// The directory an IR's assets live in, derived from the IR's own path.
618///
619/// A sibling rather than a path recorded inside the IR: the IR is a portable document and should not
620/// carry a filesystem location that stops being true the moment it is copied. Both dispatch surfaces
621/// already receive the IR path, so both can derive this.
622pub fn asset_dir_for_ir(ir_path: &Path) -> PathBuf {
623    let name = ir_path
624        .file_stem()
625        .map(|stem| format!("{}.assets", stem.to_string_lossy()))
626        .unwrap_or_else(|| "ir.assets".to_string());
627    ir_path
628        .parent()
629        .unwrap_or_else(|| Path::new("."))
630        .join(name)
631}
632
633/// Write compiled asset content under `root`, as `<root>/<component-id>/<authored path>`.
634///
635/// Nothing is created when no component declares an asset, so a project without assets emits exactly
636/// what it emitted before this existed.
637pub fn write_assets(root: &Path, assets: &CompiledAssets) -> Result<(), String> {
638    if assets.is_empty() {
639        return Ok(());
640    }
641    // The root is this function's own output directory, created here rather than incidentally by
642    // the first file's parent. It has to exist before any containment check, since resolving a
643    // location is relative to a real root — and creating the directory it was told to write into is
644    // not the same as following a symlink out of it.
645    //
646    // `root` itself is a **caller-trusted anchor**, not something the containment check can validate:
647    // if it is already a symlink to somewhere else, `create_dir_all` is a no-op, canonicalization
648    // resolves through it, and every per-file check then measures containment against that resolved
649    // location. `out_dir`/`cwd` in `land_assets` have the identical property. Reaching it needs write
650    // access to the exact path before this runs, which is a weaker threat model than the "an IR
651    // arrives from anywhere" one governing the manifest — stated here so the question is not
652    // rediscovered as a surprise.
653    std::fs::create_dir_all(root)
654        .map_err(|e| format!("failed to create {}: {e}", root.display()))?;
655    for (component_id, files) in assets {
656        for (relative, data) in files {
657            assert_contained_relative_path(relative, component_id)?;
658            let target = root.join(component_id).join(relative);
659            // Checked before ANY filesystem effect. An earlier version created the parent first so
660            // canonicalization had something to resolve, which meant a symlinked component
661            // directory got a directory created through it before the check refused the write —
662            // "refused rather than followed" has to include not creating anything either.
663            // `assert_resolves_inside` walks to the nearest existing ancestor, so it needs nothing
664            // to have been created.
665            assert_resolves_inside(root, &target, "asset target")?;
666            if let Some(parent) = target.parent() {
667                std::fs::create_dir_all(parent)
668                    .map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
669            }
670            std::fs::write(&target, data)
671                .map_err(|e| format!("failed to write {}: {e}", target.display()))?;
672        }
673    }
674    Ok(())
675}
676
677fn read_file(path: &Path) -> Result<String, String> {
678    std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
679}
680
681/// Reads a file as raw bytes, for content that is fingerprinted rather than rendered. An asset may
682/// be a binary (an image, a font, an archive), so it cannot go through [`read_file`]'s UTF-8
683/// decode — and it never needs to, since only its hash and size reach the IR.
684fn read_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
685    std::fs::read(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
686}
687
688/// Resolves a file reference the same way `prompt_ref` does today (and, by convention,
689/// `eval.template_ref`): relative to the directory that owns it — a component directory for
690/// `prompt_ref`, and, for a future profile-level slot, the profile directory. This is the one
691/// shared rule every resolved-file-reference field should follow, loudly, at compile time:
692///
693/// - the reference must stay inside `base_dir` — an absolute path, or one containing a `..`
694///   segment, is rejected rather than silently escaping `base_dir` (`PathBuf::join` alone allows
695///   both: joining an absolute path replaces the base entirely, and `..` segments are never
696///   normalized away);
697/// - the resolved path must name a file that exists on disk — a missing file is a compile-time
698///   error, never a silent skip.
699///
700/// `label` names the kind of reference in the error message (e.g. `"prompt_ref"`), so this
701/// function stays generic: it never needs to know about `LlmStep`, `EvalSpec`, or any future
702/// slot/asset field.
703///
704/// Two different kinds of future callers are expected to reuse this function unchanged, and they
705/// differ only in what they do with the `Ok(PathBuf)` it returns:
706///
707/// - a **slot variant** reads the resolved file's *content* into the IR, exactly like `prompt_ref`
708///   does today (see the call site in [`compile_project_to_ir_with`]);
709/// - an **asset** only needs the existence check this function already performs — the resolved
710///   path becomes a manifest entry (a file that must exist on disk at render/dispatch time), and
711///   its content is never read into the IR.
712fn resolve_file_ref(base_dir: &Path, reference: &str, label: &str) -> Result<PathBuf, String> {
713    let ref_path = Path::new(reference);
714    if ref_path.is_absolute()
715        || ref_path
716            .components()
717            .any(|c| matches!(c, std::path::Component::ParentDir))
718    {
719        return Err(format!(
720            "{label} '{reference}' must be a relative path inside its own directory (no '..' or absolute paths)"
721        ));
722    }
723    let resolved = base_dir.join(ref_path);
724    if !resolved.is_file() {
725        return Err(format!(
726            "{label} '{reference}' does not exist: {}",
727            resolved.display()
728        ));
729    }
730    Ok(resolved)
731}
732
733#[cfg(test)]
734mod resolve_file_ref_tests {
735    use super::resolve_file_ref;
736
737    #[test]
738    fn accepts_a_relative_path_to_an_existing_file() {
739        let dir = tempfile::tempdir().unwrap();
740        std::fs::write(dir.path().join("ask.md"), "Ask something.\n").unwrap();
741
742        let resolved = resolve_file_ref(dir.path(), "ask.md", "prompt_ref").unwrap();
743
744        assert_eq!(resolved, dir.path().join("ask.md"));
745    }
746
747    #[test]
748    fn accepts_a_relative_path_in_a_subdirectory() {
749        let dir = tempfile::tempdir().unwrap();
750        std::fs::create_dir(dir.path().join("steps")).unwrap();
751        std::fs::write(dir.path().join("steps/ask.md"), "Ask something.\n").unwrap();
752
753        let resolved = resolve_file_ref(dir.path(), "steps/ask.md", "prompt_ref").unwrap();
754
755        assert_eq!(resolved, dir.path().join("steps/ask.md"));
756    }
757
758    #[test]
759    fn rejects_a_dotdot_escape() {
760        let dir = tempfile::tempdir().unwrap();
761
762        let err = resolve_file_ref(dir.path(), "../secrets.md", "prompt_ref").unwrap_err();
763
764        assert!(err.contains("prompt_ref"));
765        assert!(err.contains("../secrets.md"));
766        assert!(err.contains(".."));
767    }
768
769    #[test]
770    fn rejects_a_dotdot_segment_in_the_middle_of_the_path() {
771        let dir = tempfile::tempdir().unwrap();
772
773        let err = resolve_file_ref(dir.path(), "steps/../../escape.md", "prompt_ref").unwrap_err();
774
775        assert!(err.contains("steps/../../escape.md"));
776    }
777
778    #[test]
779    fn rejects_an_absolute_path() {
780        let dir = tempfile::tempdir().unwrap();
781
782        let err = resolve_file_ref(dir.path(), "/etc/passwd", "prompt_ref").unwrap_err();
783
784        assert!(err.contains("prompt_ref"));
785        assert!(err.contains("/etc/passwd"));
786    }
787
788    #[test]
789    fn rejects_a_missing_file() {
790        let dir = tempfile::tempdir().unwrap();
791
792        let err = resolve_file_ref(dir.path(), "steps/missing.md", "prompt_ref").unwrap_err();
793
794        assert!(err.contains("prompt_ref"));
795        assert!(err.contains("steps/missing.md"));
796        assert!(err.contains("does not exist"));
797    }
798}
799
800/// Fetch the bound layer's reported impact for `node`. Resolves the context the same way
801/// [`compile_project_to_ir`] does (profile.yml → context binding → [`ContextLoader`]), but stops
802/// short of a full compile. Nothing is computed here: the answer is whatever the layer supplied, and
803/// a layer that supplied no analysis is an error rather than an empty radius.
804pub fn blast_radius_for_project(
805    project_dir: &Path,
806    node: &str,
807) -> Result<Option<warble::HostImpact>, String> {
808    blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
809}
810
811/// As [`blast_radius_for_project`], resolving the context binding through a caller-supplied
812/// [`ContextResolver`]. Kept in step with [`compile_project_to_ir_with`] so a host kind is bindable
813/// on both paths — a lineage query over a context only the host can load is no less valid than a
814/// compile over it.
815pub fn blast_radius_for_project_with(
816    project_dir: &Path,
817    node: &str,
818    resolver: &dyn ContextResolver,
819) -> Result<Option<warble::HostImpact>, String> {
820    let profile_path = project_dir.join("profile.yml");
821    let (profile, _) = authoring::parse_profile(&read_file(&profile_path)?)?;
822    let authored = profile
823        .context
824        .as_ref()
825        .ok_or("blast-radius requires an explicit context binding")?;
826    let binding_path = project_dir.join(&authored.project);
827    let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
828        .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
829
830    let context = resolver.resolve(&binding, project_dir)?;
831
832    if !context.is_parseable() {
833        return Err(format!(
834            "bound context '{}' is not parseable — cannot compute blast radius",
835            binding.project
836        ));
837    }
838
839    // The answer comes from the layer's own analysis rather than a traversal here. A layer that
840    // supplied none cannot be gated: allowing on the strength of an answer nobody gave is the
841    // false negative a gate exists to prevent, so that is an error rather than an empty radius.
842    let analysis = context.host_analysis().ok_or_else(|| {
843        format!(
844            "bound context '{}' supplied no impact analysis — nothing to gate against",
845            binding.project
846        )
847    })?;
848
849    // A node the analysis does not mention is a different case: it resolves, with nothing
850    // downstream. That is deliberate and long-standing — asking about an id the layer never
851    // declared is answered, not refused.
852    Ok(analysis.impact.get(node).cloned())
853}
854
855#[cfg(test)]
856mod component_source_tests {
857    use super::{resolve_component_dir, ComponentSource};
858    use std::fs;
859    use std::path::Path;
860
861    /// Create `<dir>/<id>/component.yml` (contents are irrelevant — resolution only checks the
862    /// file exists) so a source directory "defines" `id`.
863    fn stub_component(dir: &Path, id: &str) {
864        let component_dir = dir.join(id);
865        fs::create_dir_all(&component_dir).unwrap();
866        fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
867    }
868
869    #[test]
870    fn resolves_from_the_only_source_that_defines_it() {
871        let hub = tempfile::tempdir().unwrap();
872        stub_component(hub.path(), "answer_query");
873
874        let sources = vec![ComponentSource::hub(hub.path())];
875        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
876        assert_eq!(resolved, hub.path().join("answer_query"));
877    }
878
879    #[test]
880    fn local_overrides_hub_when_both_define_the_same_id() {
881        let local = tempfile::tempdir().unwrap();
882        let hub = tempfile::tempdir().unwrap();
883        stub_component(local.path(), "answer_query");
884        stub_component(hub.path(), "answer_query");
885
886        let sources = vec![
887            ComponentSource::local(local.path()),
888            ComponentSource::hub(hub.path()),
889        ];
890        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
891        assert_eq!(
892            resolved,
893            local.path().join("answer_query"),
894            "a Local source must win over a Hub source defining the same id"
895        );
896    }
897
898    #[test]
899    fn falls_back_to_hub_when_local_does_not_define_the_id() {
900        let local = tempfile::tempdir().unwrap();
901        let hub = tempfile::tempdir().unwrap();
902        stub_component(hub.path(), "answer_query");
903
904        let sources = vec![
905            ComponentSource::local(local.path()),
906            ComponentSource::hub(hub.path()),
907        ];
908        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
909        assert_eq!(resolved, hub.path().join("answer_query"));
910    }
911
912    #[test]
913    fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
914        let local = tempfile::tempdir().unwrap();
915        let hub = tempfile::tempdir().unwrap();
916
917        let sources = vec![
918            ComponentSource::local(local.path()),
919            ComponentSource::hub(hub.path()),
920        ];
921        let err = resolve_component_dir(&sources, "missing").unwrap_err();
922        assert!(err.contains("missing"));
923        assert!(err.contains(&local.path().display().to_string()));
924        assert!(err.contains(&hub.path().display().to_string()));
925    }
926
927    #[test]
928    fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
929        let local_a = tempfile::tempdir().unwrap();
930        let local_b = tempfile::tempdir().unwrap();
931        stub_component(local_a.path(), "answer_query");
932        stub_component(local_b.path(), "answer_query");
933
934        let sources = vec![
935            ComponentSource::local(local_a.path()),
936            ComponentSource::local(local_b.path()),
937        ];
938        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
939        assert!(err.contains("ambiguous"));
940        assert!(err.contains(&local_a.path().display().to_string()));
941        assert!(err.contains(&local_b.path().display().to_string()));
942    }
943
944    #[test]
945    fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
946        // Even though `hub` alone would resolve `answer_query` unambiguously, an ambiguous match
947        // at the higher-precedence `Local` kind must fail loud rather than silently falling back.
948        let local_a = tempfile::tempdir().unwrap();
949        let local_b = tempfile::tempdir().unwrap();
950        let hub = tempfile::tempdir().unwrap();
951        stub_component(local_a.path(), "answer_query");
952        stub_component(local_b.path(), "answer_query");
953        stub_component(hub.path(), "answer_query");
954
955        let sources = vec![
956            ComponentSource::local(local_a.path()),
957            ComponentSource::local(local_b.path()),
958            ComponentSource::hub(hub.path()),
959        ];
960        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
961        assert!(err.contains("ambiguous"));
962    }
963}
964
965/// `warble_eval_runner::ComplianceIr` (the type `eval compliance` deserializes into) is
966/// *deliberately* narrower than `WarbleIr` — its own doc comment says so, so a compiled-in
967/// `warble_ir_version` field never belongs on that type. But `eval compliance` is not fed an
968/// arbitrary subset: every real caller hands it the same complete `ir.json` `dispatch`/`manifest`
969/// consume (confirmed by `eval/golden/compliance/ground_truth.yaml`'s own comment — its two golden
970/// IRs, `examples/mutate-agent/ir.golden.json` and `examples/analysis-agent/ir.golden.json`, are "reused
971/// as-is, not new fixtures"). So the version gate belongs here, at the CLI boundary, checked on the
972/// raw JSON before `ComplianceIr` ever sees it — against the same
973/// `warble_claude_code::ir::SUPPORTED_IR_VERSION` that the binary's own `load_ir` already gates
974/// `dispatch`/`manifest` against, so every CLI-level IR consumer rejects an out-of-range version
975/// the same way.
976pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
977    let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
978        format!(
979            "failed to parse IR {} for version check: {e}",
980            path.display()
981        )
982    })?;
983    // Absent and present-but-not-a-string are reported separately on purpose: collapsing them sends
984    // someone whose IR *does* carry the field looking for a missing key that is right there.
985    match parsed.get("warble_ir_version") {
986        None => Err(format!(
987            "IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
988             IR, not a hand-written subset",
989            path.display()
990        )),
991        Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
992        Some(serde_json::Value::String(v)) => Err(format!(
993            "unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
994            path.display()
995        )),
996        Some(other) => Err(format!(
997            "warble_ir_version in {} is {}, not a string — eval compliance understands: \
998             {SUPPORTED_IR_VERSION}",
999            path.display(),
1000            match other {
1001                serde_json::Value::Null => "null",
1002                serde_json::Value::Bool(_) => "a boolean",
1003                serde_json::Value::Number(_) => "a number",
1004                serde_json::Value::Array(_) => "an array",
1005                _ => "an object",
1006            }
1007        )),
1008    }
1009}
1010
1011#[cfg(test)]
1012mod compliance_ir_version_tests {
1013    use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
1014    use std::path::Path;
1015
1016    fn check(raw: &str) -> Result<(), String> {
1017        check_compliance_ir_version(raw, Path::new("ir.json"))
1018    }
1019
1020    #[test]
1021    fn the_supported_version_passes() {
1022        let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
1023        assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
1024    }
1025
1026    #[test]
1027    fn a_missing_field_is_reported_as_missing() {
1028        let err = check(r#"{"components": []}"#).expect_err("must reject");
1029        assert!(err.contains("has no warble_ir_version"), "got: {err}");
1030    }
1031
1032    #[test]
1033    fn an_out_of_range_version_is_reported_as_unsupported() {
1034        let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
1035        assert!(
1036            err.contains("unsupported warble_ir_version '0.2'"),
1037            "got: {err}"
1038        );
1039        assert!(
1040            err.contains(SUPPORTED_IR_VERSION),
1041            "must name what it does accept: {err}"
1042        );
1043    }
1044
1045    #[test]
1046    fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
1047        // Guards against a `starts_with` regression: "0.30" is not "0.3".
1048        let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
1049        let err = check(&raw).expect_err("a superstring is a different version");
1050        assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
1051    }
1052
1053    /// The matrix this module exists for: every non-string JSON type must be reported as a *type*
1054    /// error naming what it actually is — never as "the field is missing", which would send someone
1055    /// looking for a key that is sitting right there. `null` is the one that regressed before.
1056    #[test]
1057    fn every_non_string_type_is_reported_as_a_type_error() {
1058        for (literal, expected) in [
1059            ("null", "is null"),
1060            ("true", "is a boolean"),
1061            ("3", "is a number"),
1062            ("[]", "is an array"),
1063            ("{}", "is an object"),
1064        ] {
1065            let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
1066            let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
1067            assert!(
1068                err.contains(expected),
1069                "for {literal} expected '{expected}', got: {err}"
1070            );
1071            assert!(
1072                !err.contains("has no warble_ir_version"),
1073                "for {literal} the field is present, not missing: {err}"
1074            );
1075        }
1076    }
1077
1078    #[test]
1079    fn unparseable_json_is_reported_as_a_parse_failure() {
1080        let err = check("not json at all").expect_err("must reject");
1081        assert!(err.contains("failed to parse IR"), "got: {err}");
1082    }
1083}
1084
1085/// Land the assets an IR's components declare into `out_dir`, verifying each against its manifest.
1086///
1087/// **Why this exists.** IR 0.7 lets a component declare the files it needs and compile records their
1088/// identity, but nothing consumed that manifest: a component declared its files and the agent then
1089/// ran in a directory that did not contain them, with no error at all. Landing them is what makes
1090/// the declaration mean something.
1091///
1092/// Content comes from the directory compile wrote beside the IR ([`asset_dir_for_ir`]) — compile is
1093/// the only place that has it, since the compiler is sans-IO and a Hub component was resolved over
1094/// the network there. See decision-101.
1095///
1096/// **Both failure modes are loud.** A manifest entry with no file in the asset directory, and one
1097/// whose content does not hash to the recorded value, each stop the dispatch. Silence is the bug
1098/// being fixed, and a half-landed asset set is harder to diagnose than a refusal.
1099pub fn land_assets(
1100    ir: &serde_json::Value,
1101    ir_path: &Path,
1102    out_dir: &Path,
1103) -> Result<Vec<PathBuf>, String> {
1104    let source_root = asset_dir_for_ir(ir_path);
1105    // Read and verify EVERY asset before writing any of them. Writing as each one verifies would
1106    // leave a component's earlier files on disk when a later one fails — a half-landed set, which
1107    // this function's contract and `docs/spec/ir-schema.md` both promise not to produce. That
1108    // promise was prose before it was code: a fixture declaring one asset per component cannot tell
1109    // the two apart.
1110    let mut pending: Vec<(PathBuf, Vec<u8>)> = Vec::new();
1111    let Some(components) = ir.get("components").and_then(|c| c.as_array()) else {
1112        return Ok(Vec::new());
1113    };
1114    for node in components {
1115        let component_id = node
1116            .get("id")
1117            .and_then(|v| v.as_str())
1118            .unwrap_or("<unnamed>");
1119        let Some(assets) = node.get("assets").and_then(|a| a.as_array()) else {
1120            continue;
1121        };
1122        for asset in assets {
1123            let relative = asset.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
1124                format!("component '{component_id}' has an asset with no path in the manifest")
1125            })?;
1126            let expected = asset.get("hash").and_then(|v| v.as_str()).ok_or_else(|| {
1127                format!(
1128                    "asset '{relative}' of component '{component_id}' has no hash in the manifest"
1129                )
1130            })?;
1131            // The manifest is re-validated here, not trusted. Compile checks the *authored*
1132            // reference against the component directory, but an IR is a document that can arrive
1133            // from anywhere, and by this point nothing has looked at the path again. Without this,
1134            // a manifest entry of `../../../etc/whatever` is an arbitrary file write.
1135            assert_contained_relative_path(relative, component_id)?;
1136            let source = source_root.join(component_id).join(relative);
1137            // Everything about the source is decided before it is opened. Ordering here has bitten
1138            // twice: checking containment first masked an absent travelling directory with a
1139            // resolution error, and reading first let a FIFO planted at a declared path hang the
1140            // read forever — before any check could refuse it, and for an in-root path the symlink
1141            // check cannot help with. So: existence, then location, then file kind, then read.
1142            let metadata = std::fs::symlink_metadata(&source).map_err(|e| {
1143                format!(
1144                    "asset '{relative}' of component '{component_id}' is declared in the IR but \
1145                     missing from {}: {e}. An IR's assets travel in that directory; copying the IR \
1146                     without it leaves a component without the files it declared.",
1147                    source_root.display()
1148                )
1149            })?;
1150            assert_resolves_inside(&source_root, &source, "asset source")?;
1151            if !source
1152                .metadata()
1153                .map_err(|e| format!("failed to inspect {}: {e}", source.display()))?
1154                .is_file()
1155            {
1156                let message = [
1157                    format!("asset '{relative}' of component '{component_id}' is not a regular"),
1158                    format!("file (found {:?}).", metadata.file_type()),
1159                    "Reading a pipe or device would block the dispatch indefinitely instead of"
1160                        .to_string(),
1161                    "failing, so anything that is not a plain file is refused.".to_string(),
1162                ]
1163                .join(" ");
1164                return Err(message);
1165            }
1166            let data = std::fs::read(&source)
1167                .map_err(|e| format!("failed to read {}: {e}", source.display()))?;
1168            let actual = format!("sha256:{:x}", Sha256::digest(&data));
1169            if actual != expected {
1170                return Err(format!(
1171                    "asset '{relative}' of component '{component_id}' does not match its manifest \
1172                     (expected {expected}, found {actual}). The file changed after the IR was \
1173                     compiled; recompile rather than dispatching content the manifest does not name."
1174                ));
1175            }
1176            let target = out_dir.join(relative);
1177            assert_resolves_inside(out_dir, &target, "asset target")?;
1178            pending.push((target, data));
1179        }
1180    }
1181
1182    let mut landed = Vec::with_capacity(pending.len());
1183    for (target, data) in pending {
1184        if let Some(parent) = target.parent() {
1185            std::fs::create_dir_all(parent)
1186                .map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
1187        }
1188        std::fs::write(&target, &data)
1189            .map_err(|e| format!("failed to write {}: {e}", target.display()))?;
1190        landed.push(target);
1191    }
1192    Ok(landed)
1193}
1194
1195/// Refuse a manifest path that is absolute or that climbs out of the directory it is joined to.
1196///
1197/// Mirrors the compile-time rule in [`resolve_file_ref`], and for the same reason: `PathBuf::join`
1198/// alone allows both escapes — joining an absolute path replaces the base entirely, and `..`
1199/// segments are never normalized away. The check has to exist on both sides because compile validates
1200/// what an author wrote while this validates what a manifest says, and those are different documents.
1201fn assert_contained_relative_path(relative: &str, component_id: &str) -> Result<(), String> {
1202    let path = Path::new(relative);
1203    if path.is_absolute()
1204        || path
1205            .components()
1206            .any(|c| matches!(c, std::path::Component::ParentDir))
1207    {
1208        let message = [
1209            format!("asset path '{relative}' of component '{component_id}' must be a relative"),
1210            "path with no '..' segments. A manifest naming a path outside the directory it"
1211                .to_string(),
1212            "lands in would be an arbitrary file write, so it is refused rather than".to_string(),
1213            "resolved.".to_string(),
1214        ]
1215        .join(" ");
1216        return Err(message);
1217    }
1218    Ok(())
1219}
1220
1221/// Confirm a path still resolves inside `root` once the filesystem has had its say.
1222///
1223/// The string check above is not enough on its own, and this is the second half of the same lesson:
1224/// it inspects what the manifest *says*, and a syntactically clean relative path — no `..`, not
1225/// absolute — still escapes when a component of it is a symlink pointing elsewhere. That is
1226/// reachable: the Agent SDK back-end's working directory is the bound project, a real directory
1227/// somebody else may have written to, and a CLI `out_dir` may hold whatever a previous step left.
1228///
1229/// `target` need not exist yet: the nearest existing ancestor is canonicalized and checked, and the
1230/// segments below it cannot be a symlink because nothing has created them. `root` must exist.
1231fn assert_resolves_inside(root: &Path, target: &Path, what: &str) -> Result<(), String> {
1232    let canonical_root = root
1233        .canonicalize()
1234        .map_err(|e| format!("failed to resolve {}: {e}", root.display()))?;
1235
1236    let mut existing = target;
1237    let anchor = loop {
1238        if existing.exists() {
1239            break existing
1240                .canonicalize()
1241                .map_err(|e| format!("failed to resolve {}: {e}", existing.display()))?;
1242        }
1243        match existing.parent() {
1244            Some(parent) => existing = parent,
1245            None => {
1246                return Err(format!(
1247                    "{what} '{}' has no resolvable ancestor",
1248                    target.display()
1249                ))
1250            }
1251        }
1252    };
1253
1254    if !anchor.starts_with(&canonical_root) {
1255        let message = [
1256            format!(
1257                "{what} '{}' resolves outside {}",
1258                target.display(),
1259                canonical_root.display()
1260            ),
1261            "once symlinks are followed. A path that looks contained but is not is refused rather"
1262                .to_string(),
1263            "than followed, because the manifest is data and the filesystem is what decides where"
1264                .to_string(),
1265            "a write actually lands.".to_string(),
1266        ]
1267        .join(" ");
1268        return Err(message);
1269    }
1270    Ok(())
1271}