Skip to main content

leviath_cli/daemon/
spawn.rs

1//! The daemon spawner: turns a [`SpawnArgs`] request into a live agent in the
2//! shared world - the CLI-side policy the runtime host calls for a `Spawn`
3//! control op.
4//!
5//! It loads the blueprint, resolves each stage's provider/model (against the
6//! world's registered providers) and effective tool set, spawns the agent via
7//! [`leviath_runtime::pipeline::spawn_agent`], attaches its run metadata /
8//! token totals / compaction settings, and registers its per-agent tool state
9//! with the [`CliToolService`]. The heavy MCP connections are shared (built once
10//! at daemon startup), so this whole path is synchronous - which lets it run
11//! straight from the host's control loop.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::{Arc, Mutex as StdMutex};
15
16use bevy_ecs::entity::Entity;
17use bevy_ecs::world::World;
18use leviath_core::blueprint::Blueprint;
19use leviath_providers::Tool;
20use leviath_runtime::host::{SpawnArgs, SubAgentOp};
21use leviath_runtime::interaction_hub::InteractionHub;
22use leviath_runtime::persistence::{RunMetadata, TokenTotals};
23use leviath_runtime::pipeline::{
24    CompactionSettings, ModelDefaults, PersistWatermark, Providers, resolve_stages,
25    spawn_agent_seeded,
26};
27use tokio::sync::Mutex;
28use tokio::sync::mpsc::UnboundedSender;
29
30use crate::config::Config;
31use crate::daemon::seed_command::SeedCommandPolicy;
32use crate::daemon::subagent::SubAgentHandle;
33use crate::daemon::tool_service::{AgentToolState, CliToolService};
34
35/// Default max sub-agent tree depth when a blueprint doesn't set one.
36const DEFAULT_SUBAGENT_DEPTH: usize = 3;
37
38/// The user's default provider/model from `config.toml`, in the plain form the
39/// runtime's stage resolver takes.
40pub(crate) fn model_defaults(config: &Config) -> ModelDefaults {
41    ModelDefaults {
42        provider: config.default_provider.clone(),
43        model: config.default_model.clone(),
44        fallback_order: parse_fallback_order(&config.providers.fallback_order),
45    }
46}
47
48/// Parse `[providers] fallback_order` entries (`"provider/model"`) into the
49/// runtime's own form.
50///
51/// A malformed entry is dropped with a warning rather than failing the load: a
52/// typo in a *safety net* should not stop the daemon from starting, and the
53/// warning says which entry went nowhere. Splitting on the first `/` keeps
54/// model ids that contain one (`deepseek/deepseek-v4-flash`) intact.
55fn parse_fallback_order(entries: &[String]) -> Vec<leviath_core::blueprint::ModelEntry> {
56    entries
57        .iter()
58        .filter_map(|raw| match raw.split_once('/') {
59            Some((provider, model)) if !provider.is_empty() && !model.is_empty() => Some(
60                leviath_core::blueprint::ModelEntry::new(provider.to_string(), model.to_string()),
61            ),
62            _ => {
63                tracing::warn!(
64                    entry = %raw,
65                    "ignoring [providers] fallback_order entry: expected \"provider/model\""
66                );
67                None
68            }
69        })
70        .collect()
71}
72
73/// The directories scanned for an agent's Rhai script tools, in precedence order
74/// (earlier wins on a name collision): the agent's own `<agent_dir>/tools/`, then
75/// `extra` (the run workdir's `tools/`, only for `dynamic_tools` agents so a
76/// mid-run write is picked up), then the global `~/.leviath/tools/`. `Option`'s
77/// iterator flattens the "no parent" / "no home" cases without a dangling
78/// `if let` else region.
79fn script_scan_dirs(
80    blueprint_path: &str,
81    extra: Option<std::path::PathBuf>,
82) -> Vec<std::path::PathBuf> {
83    std::path::Path::new(blueprint_path)
84        .parent()
85        .map(|d| d.join("tools"))
86        .into_iter()
87        .chain(extra)
88        .chain(leviath_core::tools_dir())
89        .collect()
90}
91
92/// Read and compile every custom region's Rhai script declared by `blueprint`
93/// (global layout plus each stage's per-stage layout), keyed by the script
94/// path as written. Paths resolve relative to the blueprint's directory (the
95/// script-tool convention - the script travels with the agent), with absolute
96/// paths passing through `Path::join` unchanged. Each distinct path is read
97/// and compiled once; regions sharing a script share the compiled AST.
98///
99/// A missing or uncompilable script is a **hard spawn error** (fail fast,
100/// before any tokens are spent): a hook that silently never ran would change
101/// every inference with no signal. Runtime hook *eval* failures, by contrast,
102/// warn and fall back per hook.
103pub(crate) fn resolve_region_scripts(
104    blueprint: &Blueprint,
105    blueprint_path: &str,
106) -> Result<HashMap<String, Arc<leviath_scripting::region_hook::RegionScript>>, String> {
107    let base = std::path::Path::new(blueprint_path)
108        .parent()
109        .map(std::path::Path::to_path_buf)
110        .unwrap_or_default();
111    let mut scripts = HashMap::new();
112
113    let layouts = std::iter::once(&blueprint.context_layout).chain(
114        blueprint
115            .stages
116            .iter()
117            .filter_map(|s| s.context_layout.as_ref()),
118    );
119    for layout in layouts {
120        for region in &layout.regions {
121            let leviath_core::RegionKind::Custom { script, .. } = &region.kind else {
122                continue;
123            };
124            if scripts.contains_key(script) {
125                continue;
126            }
127            let path = base.join(script);
128            let source = std::fs::read_to_string(&path).map_err(|e| {
129                format!(
130                    "region '{}': cannot read custom region script '{}': {e}",
131                    region.name,
132                    path.display()
133                )
134            })?;
135            let compiled =
136                leviath_scripting::region_hook::compile(script, &source).map_err(|e| {
137                    format!(
138                        "region '{}': custom region script failed to compile: {e}",
139                        region.name
140                    )
141                })?;
142            scripts.insert(script.clone(), Arc::new(compiled));
143        }
144    }
145    Ok(scripts)
146}
147
148/// Names already claimed by a built-in, sub-agent, or MCP tool - a discovered
149/// script tool colliding with one of these is dropped (never shadows a core tool).
150fn reserved_tool_names(builtin_names: &HashSet<String>, mcp_tool_defs: &[Tool]) -> HashSet<String> {
151    let mut reserved: HashSet<String> = builtin_names.clone();
152    reserved.extend(leviath_tools::BuiltinTools::subagent_tool_names());
153    reserved.extend(mcp_tool_defs.iter().map(|t| t.name.clone()));
154    reserved
155}
156
157/// Map a script's self-declared `@requires` capability name to the platform
158/// [`ToolCapability`] it corresponds to. An unrecognized name returns `None`,
159/// which the discovery pass treats as unsatisfiable (the tool is dropped) so a
160/// typo can't silently slip a tool through the platform gate.
161fn script_cap(name: &str) -> Option<leviath_tools::ToolCapability> {
162    match name {
163        "network" | "net" | "http" => Some(leviath_tools::ToolCapability::Network),
164        "shell" | "process" | "process_spawn" => Some(leviath_tools::ToolCapability::ProcessSpawn),
165        "filesystem" | "file" | "fs" => Some(leviath_tools::ToolCapability::FileSystem),
166        _ => None,
167    }
168}
169
170/// Whether `platform` can satisfy every capability a script `@requires`. An
171/// unknown capability name is never satisfiable.
172fn platform_satisfies_caps(
173    platform: &leviath_tools::PlatformCapabilities,
174    required_caps: &[String],
175) -> bool {
176    required_caps
177        .iter()
178        .all(|c| script_cap(c).is_some_and(|cap| platform.supports(cap)))
179}
180
181/// Whether the *current* platform can satisfy a script's `@requires` - the same
182/// gate `discover_script_tools_in` applies at spawn. Exposed so the read-only CLI
183/// surfaces (`lev tools`, `lev validate`, `lev mcp list`) report a tool's real
184/// availability (and flag an unknown/typo'd capability) instead of listing a tool
185/// the daemon would silently drop.
186pub(crate) fn current_platform_satisfies(required_caps: &[String]) -> bool {
187    platform_satisfies_caps(
188        &leviath_tools::PlatformCapabilities::current(),
189        required_caps,
190    )
191}
192
193/// Discover and compile the script tools in `dirs`, returning the compiled set,
194/// the routable names (collisions against `reserved` excluded), and the
195/// advertised `Tool` defs.
196///
197/// A tool whose `@requires` capabilities the current platform can't satisfy is
198/// dropped here (self-declared platform gating) - mirroring how
199/// built-ins filter against [`PlatformCapabilities`].
200pub(crate) fn discover_script_tools_in(
201    dirs: &[std::path::PathBuf],
202    reserved: &HashSet<String>,
203) -> (leviath_scripting::ScriptToolSet, HashSet<String>, Vec<Tool>) {
204    let (set, skipped) = leviath_scripting::ScriptToolSet::discover(dirs);
205    for s in &skipped {
206        // Pre-format the path to a plain string so the `tracing` field carries no
207        // inline method call (an inline `%s.path.display()` leaves a macro
208        // sub-region llvm-cov can't attribute even with the event enabled).
209        let path = s.path.display().to_string();
210        tracing::warn!(tool = %path, reason = %s.reason, "skipping invalid script tool");
211    }
212    let platform = leviath_tools::PlatformCapabilities::current();
213    let mut names = HashSet::new();
214    let mut defs = Vec::new();
215    for meta in set.metas() {
216        if reserved.contains(&meta.name) {
217            tracing::warn!(tool = %meta.name, "script tool name collides with an existing tool - ignoring");
218            continue;
219        }
220        if !platform_satisfies_caps(&platform, &meta.required_caps) {
221            let caps = meta.required_caps.join(", ");
222            tracing::warn!(tool = %meta.name, requires = %caps, "script tool requires a capability this platform lacks - ignoring");
223            continue;
224        }
225        names.insert(meta.name.clone());
226        defs.push(Tool {
227            name: meta.name.clone(),
228            description: meta.description.clone(),
229            parameters: meta.parameters_schema(),
230        });
231    }
232    (set, names, defs)
233}
234
235/// Discover the agent's Rhai script tools and build their `Tool`
236/// defs (the spawn-time entry point). `extra_dir` adds the run workdir's `tools/`
237/// for `dynamic_tools` agents.
238fn discover_script_tools(
239    blueprint_path: &str,
240    builtin_names: &HashSet<String>,
241    mcp_tool_defs: &[Tool],
242    extra_dir: Option<std::path::PathBuf>,
243) -> (leviath_scripting::ScriptToolSet, HashSet<String>, Vec<Tool>) {
244    let dirs = script_scan_dirs(blueprint_path, extra_dir);
245    let reserved = reserved_tool_names(builtin_names, mcp_tool_defs);
246    discover_script_tools_in(&dirs, &reserved)
247}
248
249/// Build one agent's [`AgentToolState`] from the shared executors + config.
250///
251/// `stage_perms_by_index` holds every stage's `[tool_permissions]` (in stage
252/// order); the entry stage's map seeds `stage_perms`, and the pipeline's
253/// `sync_stage` swaps in the right one as the agent changes stage.
254#[allow(clippy::too_many_arguments)]
255fn build_tool_state(
256    builtins: Arc<leviath_tools::BuiltinTools>,
257    builtin_names: HashSet<String>,
258    mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
259    config: &Config,
260    hub: &InteractionHub,
261    run_id: &str,
262    entry_stage: &str,
263    entry_index: usize,
264    stage_perms_by_index: Vec<HashMap<String, String>>,
265    stage_required_by_index: Vec<HashSet<String>>,
266    agent_perms: HashMap<String, String>,
267    agent_name: &str,
268    launch_overrides: HashMap<String, crate::config::ToolPolicy>,
269    subagent: Option<SubAgentHandle>,
270    sandbox: Option<Arc<crate::daemon::sandbox_manager::SandboxManager>>,
271    script_tools: leviath_scripting::ScriptToolSet,
272    script_tool_names: HashSet<String>,
273    script_host: Arc<dyn leviath_scripting::ScriptHost>,
274    dynamic: Option<Arc<crate::daemon::tool_service::DynamicToolCtx>>,
275    unattended: bool,
276) -> Arc<AgentToolState> {
277    let entry_perms = stage_perms_by_index
278        .get(entry_index)
279        .cloned()
280        .unwrap_or_default();
281    let entry_required = stage_required_by_index
282        .get(entry_index)
283        .cloned()
284        .unwrap_or_default();
285    Arc::new(AgentToolState {
286        builtins,
287        mcp,
288        builtin_names,
289        launch_overrides: Arc::new(launch_overrides),
290        session_allows: Arc::new(Mutex::new(HashSet::new())),
291        stage_perms: Arc::new(StdMutex::new(entry_perms)),
292        stage_perms_by_index: Arc::new(stage_perms_by_index),
293        stage_required: Arc::new(StdMutex::new(entry_required)),
294        stage_required_by_index: Arc::new(stage_required_by_index),
295        agent_perms: Arc::new(agent_perms),
296        // The ceiling a blueprint may tighten but not loosen: the user's global
297        // `[tool_permissions]` plus any `[agent_tool_permissions.<name>]` grant
298        // they made for this specific agent. Resolved once here so every later
299        // `resolve_policy` reads one flat map.
300        global_perms: Arc::new(config.permissions_for_agent(agent_name)),
301        interaction: hub.backend_for(run_id),
302        unattended,
303        stage_name: Arc::new(StdMutex::new(entry_stage.to_string())),
304        subagent,
305        sandbox,
306        script_tools: Arc::new(StdMutex::new(script_tools)),
307        script_tool_names: Arc::new(StdMutex::new(script_tool_names)),
308        script_host,
309        dynamic,
310    })
311}
312
313/// Resolve every region's initial content from its blueprint-declared
314/// [`RegionSeed`] plus the caller-provided values on `args`, into a
315/// name→content map ready for [`spawn_agent_seeded`].
316///
317/// The caller map is `{ "task": args.task } ∪ args.regions` (a `regions["task"]`
318/// wins). Then:
319/// - `CallerInput { name }` pulls from the caller map; if the region is
320///   `required` and the value is missing/blank this returns `Err` - the
321///   required-at-spawn gate, before any inference.
322/// - `Files` / `Glob` read workdir files; `Literal` is verbatim; `Rhai` runs a
323///   workdir script whose `String` return seeds the region.
324/// - `Command` runs a shell command in the workdir under `commands` -
325///   sandboxed, time- and size-capped, and skippable. Every failure is
326///   non-fatal unless the region is `required`.
327/// - Any caller key (other than `task`) that isn't a declared `CallerInput`
328///   region is rejected (typo protection, mirrors the CLI-side check).
329fn resolve_seeds(
330    blueprint: &Blueprint,
331    args: &SpawnArgs,
332    workdir: &str,
333    commands: &SeedCommandPolicy,
334) -> Result<HashMap<String, String>, String> {
335    use leviath_core::layout::RegionSeed;
336
337    // The effective caller-supplied values: task text plus any named regions.
338    let mut caller: HashMap<String, String> = HashMap::new();
339    caller.insert("task".to_string(), args.task.clone());
340    for (k, v) in &args.regions {
341        caller.insert(k.clone(), v.clone());
342    }
343
344    // Unknown caller keys are tolerated here (silently unused): the CLI already
345    // rejects typos client-side in `resolve_spawn_args`, and an ACP host sending
346    // a stray `---region:...---` marker shouldn't fail the whole turn over it.
347
348    let base = std::path::Path::new(workdir);
349    let mut seeds: HashMap<String, String> = HashMap::new();
350
351    for region in &blueprint.context_layout.regions {
352        let Some(seed) = &region.seed else { continue };
353        match seed {
354            RegionSeed::CallerInput { name } => {
355                let value = caller.get(name).map(|s| s.as_str()).unwrap_or("");
356                if value.trim().is_empty() {
357                    if region.required {
358                        return Err(region.required_message.clone().unwrap_or_else(|| {
359                            format!(
360                                "required region '{}' was not provided; supply it via \
361                                 --{name} <text|@file> (CLI), a ---region:{name}--- block \
362                                 (ACP), or the API `regions` field",
363                                region.name
364                            )
365                        }));
366                    }
367                    // Optional and unprovided - leave the region empty.
368                    continue;
369                }
370                seeds.insert(region.name.clone(), value.to_string());
371            }
372            RegionSeed::Literal { text } => {
373                seeds.insert(region.name.clone(), text.clone());
374            }
375            RegionSeed::Files { paths } => {
376                let content = read_and_concat(
377                    &region.name,
378                    paths.iter().map(|p| base.join(p)),
379                    region.required,
380                )?;
381                if let Some(content) = content {
382                    seeds.insert(region.name.clone(), content);
383                }
384            }
385            RegionSeed::Glob { pattern } => {
386                let full = base.join(pattern);
387                let full = full.to_string_lossy();
388                let matches = glob::glob(&full)
389                    .map_err(|e| format!("region '{}': bad glob '{pattern}': {e}", region.name))?;
390                let paths: Vec<std::path::PathBuf> = matches.filter_map(|m| m.ok()).collect();
391                let content = read_and_concat(&region.name, paths.into_iter(), region.required)?;
392                match content {
393                    Some(content) => {
394                        seeds.insert(region.name.clone(), content);
395                    }
396                    None if region.required => {
397                        return Err(format!(
398                            "required region '{}': glob '{pattern}' matched no files",
399                            region.name
400                        ));
401                    }
402                    None => {}
403                }
404            }
405            RegionSeed::Rhai { script } => {
406                let path = base.join(script);
407                let src = std::fs::read_to_string(&path).map_err(|e| {
408                    format!(
409                        "region '{}': read rhai seed '{}': {e}",
410                        region.name,
411                        path.display()
412                    )
413                })?;
414                let mut input = rhai::Map::new();
415                input.insert("task".into(), rhai::Dynamic::from(args.task.clone()));
416                input.insert("workdir".into(), rhai::Dynamic::from(workdir.to_string()));
417                let out = leviath_scripting::ScriptEngine::new()
418                    .transform(&src, input)
419                    .map_err(|e| format!("region '{}': rhai seed failed: {e}", region.name))?;
420                if !out.trim().is_empty() {
421                    seeds.insert(region.name.clone(), out);
422                } else if region.required {
423                    return Err(format!(
424                        "required region '{}': rhai seed '{script}' returned empty",
425                        region.name
426                    ));
427                }
428            }
429            // A command seed *executes* at spawn, before any inference and so
430            // before any tool-approval prompt. It is therefore skipped outright
431            // when disabled, and every failure mode is non-fatal unless the
432            // region is `required` (mirroring the Files/Glob arms above): a
433            // discovery nicety must never be able to sink a run.
434            RegionSeed::Command { command } => {
435                if !commands.allowed {
436                    if region.required {
437                        return Err(format!(
438                            "required region '{}': command seeds are disabled \
439                             (`[security] allow_seed_commands = false` or --no-seed-commands)",
440                            region.name
441                        ));
442                    }
443                    tracing::warn!(
444                        region = %region.name,
445                        "command seed skipped: command seeds are disabled"
446                    );
447                    continue;
448                }
449                match commands.run(command, base) {
450                    Ok(out) if !out.trim().is_empty() => {
451                        seeds.insert(region.name.clone(), out);
452                    }
453                    Ok(_) => {
454                        if region.required {
455                            return Err(format!(
456                                "required region '{}': command seed '{command}' returned empty",
457                                region.name
458                            ));
459                        }
460                        tracing::warn!(
461                            region = %region.name,
462                            command = %command,
463                            "command seed returned no output; region left empty"
464                        );
465                    }
466                    Err(e) => {
467                        if region.required {
468                            return Err(format!(
469                                "required region '{}': command seed '{command}' failed: {e}",
470                                region.name
471                            ));
472                        }
473                        tracing::warn!(
474                            region = %region.name,
475                            command = %command,
476                            error = %e,
477                            "command seed failed; region left empty"
478                        );
479                    }
480                }
481            }
482        }
483    }
484
485    Ok(seeds)
486}
487
488/// Read each file and concatenate with `--- <path> ---` headers. Returns
489/// `Ok(None)` when the list is empty; a missing/unreadable file is an error only
490/// when `required`, else it is skipped.
491fn read_and_concat(
492    region: &str,
493    paths: impl Iterator<Item = std::path::PathBuf>,
494    required: bool,
495) -> Result<Option<String>, String> {
496    let mut parts: Vec<String> = Vec::new();
497    for path in paths {
498        match std::fs::read_to_string(&path) {
499            Ok(text) => parts.push(format!("--- {} ---\n{}", path.display(), text)),
500            Err(e) => {
501                if required {
502                    return Err(format!(
503                        "region '{region}': read seed file '{}': {e}",
504                        path.display()
505                    ));
506                }
507            }
508        }
509    }
510    Ok((!parts.is_empty()).then(|| parts.join("\n\n")))
511}
512
513/// Resolve an agent's `[read_paths]` declarations against the user's config
514/// into the policy its file tools enforce, plus a warning to surface when the
515/// declarations exist but nothing grants them.
516///
517/// A declared-but-ungranted agent still spawns - its out-of-workdir reads are
518/// refused per path with the same guidance - but the warning fires once here
519/// so the user learns about it at spawn rather than from a mid-run tool error.
520/// A malformed entry (in the blueprint or in the user's own grant list) is a
521/// hard spawn error: silently dropping it would either under-grant or run the
522/// agent with less vision than its author designed for.
523fn build_read_path_policy(
524    blueprint: &leviath_core::Blueprint,
525    config: &crate::config::Config,
526    workdir: &std::path::Path,
527) -> Result<(leviath_core::ReadPathPolicy, Option<String>), String> {
528    let Some(rp) = blueprint
529        .read_paths
530        .as_ref()
531        .filter(|rp| !rp.allow.is_empty())
532    else {
533        return Ok((leviath_core::ReadPathPolicy::inactive(), None));
534    };
535    let home = leviath_core::home_dir();
536    let declared =
537        leviath_core::ReadPathSet::compile(&rp.allow, workdir, home.as_deref(), cfg!(windows))
538            .map_err(|e| format!("agent '{}' [read_paths]: {e}", blueprint.name))?;
539    let grant_entries = config.read_path_grants_for_agent(&blueprint.name);
540    let grants =
541        leviath_core::ReadPathSet::compile(&grant_entries, workdir, home.as_deref(), cfg!(windows))
542            .map_err(|e| format!("read_paths grant in your config.toml: {e}"))?;
543    let allow_blueprint = config.security.allow_blueprint_read_paths;
544    let warning = (!allow_blueprint && grants.is_empty()).then(|| {
545        let entries = rp
546            .allow
547            .iter()
548            .map(|e| format!("\"{e}\""))
549            .collect::<Vec<_>>()
550            .join(", ");
551        format!(
552            "agent '{name}' declares [read_paths] but nothing grants them; reads outside \
553             the workdir will be refused. To grant them, add to your config.toml either:\n\
554             [security]\nallow_blueprint_read_paths = true\n\
555             or the specific paths:\n[agent_read_paths.{name}]\nallow = [{entries}]",
556            name = blueprint.name,
557        )
558    });
559    Ok((
560        leviath_core::ReadPathPolicy {
561            agent: blueprint.name.clone(),
562            blueprint: declared,
563            grants,
564            allow_blueprint,
565        },
566        warning,
567    ))
568}
569
570/// How many of a blueprint's `[read_paths]` entries the config grants, for the
571/// run listing. `None` when the blueprint declares none, and when the user's
572/// own grant list will not compile - that is a hard spawn error a line above,
573/// so there is no half-answer to record.
574fn read_path_grant_counts(
575    blueprint: &leviath_core::Blueprint,
576    config: &crate::config::Config,
577    workdir: &std::path::Path,
578) -> Option<leviath_core::run_meta::ReadPathGrantCounts> {
579    let report = crate::read_path_report::build(blueprint, config, workdir)?.ok()?;
580    Some(leviath_core::run_meta::ReadPathGrantCounts {
581        declared: report.declared(),
582        granted: report.granted(),
583    })
584}
585
586/// Raise the read tools to `Private` for an agent whose `[read_paths]` are
587/// actually granted: they can pull in content from outside the workdir -
588/// design docs, run archives, whatever else was granted - which the default
589/// `Internal` classification (written for workdir files) understates.
590fn bump_read_sensitivities(
591    map: &mut HashMap<String, leviath_core::TaintLevel>,
592    read_paths_granted: bool,
593) {
594    if !read_paths_granted {
595        return;
596    }
597    for tool in ["read_file", "read_files", "list_dir"] {
598        if let Some(level) = map.get_mut(tool) {
599            *level = (*level).max(leviath_core::TaintLevel::Private);
600        }
601    }
602}
603
604/// Load the blueprint at `args.blueprint_path`, spawn the agent into `world`,
605/// register its tool state, and return the new entity. Operates on the raw ECS
606/// [`World`] so it is callable both from the host's spawner (via
607/// `PipelineWorld::world_mut`) and from a fan-out world-system.
608///
609/// Enforces the required-at-spawn region gate - a fresh spawn whose required
610/// caller-input regions weren't provided fails here. Use
611/// [`build_agent_for_reload`] on the recovery path, where the window is restored
612/// from a snapshot afterward and the gate must not re-fire.
613#[allow(clippy::too_many_arguments)]
614pub fn build_agent(
615    world: &mut World,
616    tool_service: &CliToolService,
617    config: &Config,
618    shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
619    mcp_tool_defs: &[Tool],
620    hub: &InteractionHub,
621    args: &SpawnArgs,
622    now_secs: i64,
623    subagent_tx: UnboundedSender<SubAgentOp>,
624) -> Result<Entity, String> {
625    build_agent_inner(
626        world,
627        tool_service,
628        config,
629        shared_mcp,
630        mcp_tool_defs,
631        hub,
632        args,
633        now_secs,
634        subagent_tx,
635        true,
636    )
637}
638
639/// Like [`build_agent`], but skips the required-at-spawn region gate - used by
640/// restart recovery, which reloads a run that already passed the gate when first
641/// spawned and whose context window is restored from a snapshot after this call.
642#[allow(clippy::too_many_arguments)]
643pub fn build_agent_for_reload(
644    world: &mut World,
645    tool_service: &CliToolService,
646    config: &Config,
647    shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
648    mcp_tool_defs: &[Tool],
649    hub: &InteractionHub,
650    args: &SpawnArgs,
651    now_secs: i64,
652    subagent_tx: UnboundedSender<SubAgentOp>,
653) -> Result<Entity, String> {
654    build_agent_inner(
655        world,
656        tool_service,
657        config,
658        shared_mcp,
659        mcp_tool_defs,
660        hub,
661        args,
662        now_secs,
663        subagent_tx,
664        false,
665    )
666}
667
668/// Log whatever `lev validate` would have reported about this manifest.
669///
670/// The lint env is built from the manifest's own directory so the agent's
671/// `tools/*.rhai` resolve, and deliberately without the provider check: the
672/// stage resolution a few steps later already fails a spawn outright when
673/// nothing in a stage's models list is registered, and re-deriving that here
674/// would cost a provider-registry build per agent to say the same thing more
675/// quietly.
676fn log_blueprint_lint(content: &str, blueprint: &Blueprint, manifest_path: &str) {
677    let agent_dir = std::path::Path::new(manifest_path)
678        .parent()
679        .map(std::path::Path::to_path_buf)
680        .unwrap_or_default();
681    let env = crate::lint::LintEnv::offline(&agent_dir);
682    for finding in crate::lint::lint_manifest(content, blueprint, &env) {
683        // Notes describe things the blueprint means to do; only the checks that
684        // found something questionable are worth a daemon log line.
685        if finding.severity == crate::lint::LintSeverity::Note {
686            continue;
687        }
688        // Built before the macro rather than inside it: `tracing::warn!` only
689        // evaluates its arguments when the level is enabled, so a call in the
690        // argument list is a region that does not run under a subscriber that
691        // filters WARN out.
692        let line = format!(
693            "blueprint '{}': {} [{}]",
694            blueprint.name,
695            finding.one_line(),
696            finding.code
697        );
698        tracing::warn!("{line}");
699    }
700}
701
702#[allow(clippy::too_many_arguments)]
703fn build_agent_inner(
704    world: &mut World,
705    tool_service: &CliToolService,
706    config: &Config,
707    shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
708    mcp_tool_defs: &[Tool],
709    hub: &InteractionHub,
710    args: &SpawnArgs,
711    now_secs: i64,
712    subagent_tx: UnboundedSender<SubAgentOp>,
713    enforce_seeds: bool,
714) -> Result<Entity, String> {
715    // 0. The working directory must exist before anything is built over it.
716    // `ToolContext::new` silently keeps a path it can't canonicalize, so without
717    // this a bogus workdir spawns a healthy-looking agent whose every tool call
718    // fails with a message naming the shell rather than the directory (#107).
719    if !std::fs::metadata(&args.workdir).is_ok_and(|m| m.is_dir()) {
720        return Err(format!(
721            "workspace '{}' does not exist or is not a directory",
722            args.workdir
723        ));
724    }
725
726    // 1. Load the blueprint (the client resolves the manifest path).
727    let content = std::fs::read_to_string(&args.blueprint_path)
728        .map_err(|e| format!("read manifest '{}': {e}", args.blueprint_path))?;
729    let mut blueprint = leviath_core::manifest::parse_manifest(&content)
730        .map_err(|e| format!("parse manifest: {e}"))?;
731    blueprint
732        .validate()
733        .map_err(|e| format!("invalid blueprint: {e}"))?;
734    // What `lev validate` would have said, in the daemon log. Nothing here
735    // refuses a spawn: these are authoring mistakes whose cost is a run that
736    // behaves oddly hours later, and the whole point is that they are invisible
737    // until then. Logging them means the answer is already in `daemon.log`
738    // whenever someone goes looking for why a run stalled.
739    log_blueprint_lint(&content, &blueprint, &args.blueprint_path);
740    // A request-level `--max-depth` overrides the blueprint's sub-agent depth cap.
741    if let Some(md) = args.max_depth {
742        blueprint.max_child_depth = Some(md);
743    }
744    // Apply the config's `default_max_iterations` to any stage that doesn't set
745    // its own, so an agent can't loop forever with no completion signal
746    // (`enforce_max_iterations` treats `None`/0 as unbounded). A stage's explicit
747    // `max_iterations` always wins.
748    if let Some(default_max) = config.limits.default_max_iterations {
749        for stage in &mut blueprint.stages {
750            // `0` means *unbounded* to the pipeline, and `get_or_insert` only
751            // fills `None` - so a manifest writing `max_iterations = 0` looked
752            // like "unset" while actually opting out of the user's ceiling
753            // entirely, and looped without limit against their API keys. A
754            // manifest may still declare its own finite number; it may not
755            // declare "no limit" over a user who asked for one.
756            match stage.max_iterations {
757                None | Some(0) => stage.max_iterations = Some(default_max),
758                Some(_) => {}
759            }
760        }
761    }
762
763    // 2a. Entry stage + per-stage sandbox resolution. Each stage's effective
764    // sandbox cascades stage → agent → global (`resolve_sandbox`); building the
765    // manager creates any containers up front and fails here (returning the
766    // error to the spawner) when a required runtime is unavailable and the config
767    // says to error. `None` means no stage is sandboxed → no executor attached
768    // (zero overhead, exact prior host behavior).
769    let entry_stage = blueprint
770        .entry_stage
771        .clone()
772        .or_else(|| blueprint.stages.first().map(|s| s.name.clone()))
773        .unwrap_or_default();
774    let entry_index = blueprint
775        .stages
776        .iter()
777        .position(|s| s.name == entry_stage)
778        .unwrap_or(0);
779    let stage_sandbox_by_index: Vec<leviath_core::ToolSandboxConfig> = blueprint
780        .stages
781        .iter()
782        .map(|s| {
783            leviath_core::resolve_sandbox(
784                config.sandbox.as_ref(),
785                blueprint.sandbox.as_ref(),
786                s.sandbox.as_ref(),
787            )
788        })
789        .collect();
790    let sandbox = crate::daemon::sandbox_manager::SandboxManager::build(
791        &args.run_id,
792        stage_sandbox_by_index,
793        &args.workdir,
794        entry_index,
795    )?
796    .map(Arc::new);
797
798    // 2b. Per-agent built-in tools (over the agent's workdir), routing shell
799    // execution through the sandbox when one is configured. The blueprint's
800    // `[read_paths]` declarations are resolved against the user's config here -
801    // declared AND granted, or the read tools never leave the workdir.
802    let (read_path_policy, read_path_warning) =
803        build_read_path_policy(&blueprint, config, std::path::Path::new(&args.workdir))?;
804    if let Some(warning) = &read_path_warning {
805        tracing::warn!(agent_name = %blueprint.name, "{warning}");
806    }
807    // Whether the agent can actually read outside its workdir - feeds the
808    // taint bump below, captured before the policy moves into the context.
809    let read_paths_granted = read_path_policy.is_active()
810        && (read_path_policy.allow_blueprint || !read_path_policy.grants.is_empty());
811    // The same question, per entry, recorded on the run so `lev ps` can show
812    // that a live run is up but blind to paths its author designed it around.
813    let read_path_counts =
814        read_path_grant_counts(&blueprint, config, std::path::Path::new(&args.workdir));
815    let tool_ctx = leviath_tools::ToolContext::new(std::path::PathBuf::from(&args.workdir))
816        .with_read_paths(read_path_policy);
817    let mut builtins = leviath_tools::BuiltinTools::new(tool_ctx);
818    if let Some(mgr) = &sandbox {
819        builtins =
820            builtins.with_shell_executor(mgr.clone() as Arc<dyn leviath_tools::ShellExecutor>);
821    }
822    let builtins = Arc::new(builtins);
823    let builtin_names: HashSet<String> = builtins.names().into_iter().collect();
824    let mut all_tool_defs = builtins.tool_defs();
825    all_tool_defs.extend(leviath_tools::BuiltinTools::subagent_tool_defs());
826    all_tool_defs.extend(mcp_tool_defs.iter().cloned());
827    // The non-script defs (built-in + sub-agent + MCP), captured before script
828    // defs are appended - a `dynamic_tools` agent re-filters against these plus a
829    // fresh script scan on each mid-run refresh.
830    let static_tool_defs = all_tool_defs.clone();
831
832    // 2c. Rhai script tools (issue #97): discover and compile the agent's
833    // `tools/` dir plus the global `~/.leviath/tools/` (per-agent wins on a name
834    // collision). Their defs are added to `all_tool_defs` *before* stage
835    // resolution so a stage's `available_tools` (Layer 1) and taint
836    // classification see them. A script tool whose name collides with a built-in,
837    // sub-agent, or MCP tool is ignored (the existing tool wins), so it never
838    // shadows a core tool.
839    // A `dynamic_tools` agent also scans its run workdir's `tools/`, so a tool it
840    // writes mid-run (into a workdir it can reach) is discoverable on re-scan.
841    let dynamic_tools = blueprint.dynamic_tools;
842    let workdir_tools_dir =
843        dynamic_tools.then(|| std::path::PathBuf::from(&args.workdir).join("tools"));
844    let (script_tools, script_tool_names, script_defs) = discover_script_tools(
845        &args.blueprint_path,
846        &builtin_names,
847        mcp_tool_defs,
848        workdir_tools_dir.clone(),
849    );
850    all_tool_defs.extend(script_defs);
851
852    // 3. Resolve stages against the world's providers.
853    let stages = {
854        let registry = &world
855            .get_resource::<Providers>()
856            .expect("Providers resource present in a PipelineWorld")
857            .0;
858        resolve_stages(
859            &blueprint,
860            args.model.as_deref(),
861            &model_defaults(config),
862            registry,
863            &all_tool_defs,
864            args.yolo,
865        )?
866    };
867
868    // 4. Snapshot the blueprint bits we need after it's moved into the world.
869    let agent_name = blueprint.name.clone();
870    let num_stages = blueprint.stages.len();
871    let compaction = blueprint.compaction_config.clone();
872    let max_child_depth = blueprint.max_child_depth.unwrap_or(DEFAULT_SUBAGENT_DEPTH);
873    // Taint gate: opt-in via the blueprint's `[security]` block, else the global
874    // config's `taint_tracking`, else off. Cascading through
875    // `resolve_security` (rather than `unwrap_or_default`, which forced taint on
876    // for every agent because `SecurityConfig::default()` is taint-on) means a
877    // blueprint with no `[security]` block correctly inherits the global setting -
878    // off by default. When on, the agent's outbound tool calls are gated
879    // against its context taint + the policy allowlist; when off no gate is
880    // attached (zero enforcement overhead).
881    let security = leviath_core::taint::resolve_security(
882        config.taint_tracking,
883        blueprint.security.as_ref(),
884        None,
885    );
886    // The `[mcp_overrides]` from policy.toml (loaded into the world at daemon
887    // setup), applied to every gate this agent gets so a user's reclassified
888    // MCP tool is enforced, not just printed by `lev policy list`.
889    let mcp_overrides = world
890        .get_resource::<leviath_runtime::pipeline::PolicyGate>()
891        .map(|p| p.0.mcp_overrides.clone())
892        .unwrap_or_default();
893    let tool_sensitivities: Option<HashMap<String, leviath_core::TaintLevel>> =
894        security.taint_tracking.then(|| {
895            let mut gate = leviath_runtime::TaintGate::new(security.clone());
896            gate.apply_mcp_overrides(&mcp_overrides);
897            let mut map: HashMap<String, leviath_core::TaintLevel> = all_tool_defs
898                .iter()
899                .map(|t| {
900                    (
901                        t.name.clone(),
902                        gate.tool_classification(&t.name).sensitivity,
903                    )
904                })
905                .collect();
906            bump_read_sensitivities(&mut map, read_paths_granted);
907            map
908        });
909    // Per-stage tool permissions (in stage order) + the entry stage's index, for
910    // the tool state's stage-scoped policy layer.
911    let stage_perms_by_index: Vec<HashMap<String, String>> = blueprint
912        .stages
913        .iter()
914        .map(|s| s.tool_permissions.clone())
915        .collect();
916    // Agent-level tool permissions (the manifest's top-level `[tool_permissions]`,
917    // recorded in blueprint metadata). Populates the tool state's agent-level
918    // policy layer (between stage and global in `resolve_policy`) - without this
919    // the manifest's top-level block would be silently ignored.
920    let agent_perms = blueprint.agent_tool_permissions();
921    // Each stage's `available_tools` (Layer-1 allowlist), captured before the
922    // blueprint moves - a `dynamic_tools` agent re-filters against these on refresh.
923    let stage_available: Vec<Vec<String>> = blueprint
924        .stages
925        .iter()
926        .map(|s| s.available_tools.clone())
927        .collect();
928    // Alongside it, each stage's `required_tools` - the human tools it keeps even
929    // when nobody is watching - so a refresh re-applies the same unattended cut.
930    let stage_required: Vec<Vec<String>> = blueprint
931        .stages
932        .iter()
933        .map(|s| s.required_tools.clone())
934        .collect();
935    // The same list as a lookup set, canonicalised, for the tool state: an
936    // interaction for a kept tool has to reach a real person rather than the
937    // auto-answering backend, and dispatch tests one name at a time.
938    let stage_required_by_index: Vec<HashSet<String>> = stage_required
939        .iter()
940        .map(|names| {
941            names
942                .iter()
943                .map(|n| leviath_tools::canonical_tool_name(n).to_string())
944                .collect()
945        })
946        .collect();
947    let model_label = stages
948        .first()
949        .map(|s| format!("{}/{}", s.provider_name, s.model));
950
951    // 5. Resolve region seeds (caller input + blueprint-declared sources) into
952    // concrete content. On a fresh spawn (`enforce_seeds`), required caller-input
953    // regions that weren't provided fail here - before any inference, so no
954    // tokens are spent. On reload the window is restored from a snapshot after
955    // this, so seeding is skipped entirely.
956    // Command seeds (issue #108) run here, so they inherit the entry stage's
957    // sandbox (built in step 2a above) and are refused by either the machine-wide
958    // `[security] allow_seed_commands` switch or this run's `--no-seed-commands`.
959    let seeds = if enforce_seeds {
960        let policy = SeedCommandPolicy::new(
961            config.security.allow_seed_commands && !args.no_seed_commands,
962            std::time::Duration::from_secs(config.limits.script_shell_timeout_secs),
963            sandbox.clone(),
964        );
965        resolve_seeds(&blueprint, args, &args.workdir, &policy)?
966    } else {
967        HashMap::new()
968    };
969
970    // 5b. Read + compile-check custom regions' Rhai scripts (issue #152) -
971    // once per distinct path, blueprint-dir-relative. Runs on fresh spawns
972    // AND reloads (the hooks must work after a restart), and a broken script
973    // is a hard error either way.
974    let region_scripts = resolve_region_scripts(&blueprint, &args.blueprint_path)?;
975
976    // Whether any stage can produce a file change the framework would see -
977    // asked here, while the blueprint is still in hand, because it cannot
978    // change for the rest of the run. A run that could never write is never
979    // reported as having written nothing (issue #192).
980    let outcome_flags = leviath_runtime::persistence::RunOutcomeFlags::for_blueprint(&blueprint);
981
982    // 6. Spawn the agent.
983    let entity = spawn_agent_seeded(
984        world,
985        args.run_id.clone(),
986        blueprint,
987        &seeds,
988        stages,
989        leviath_core::config::PromptHints {
990            batch_tool: config.batch_tool_hint,
991            shell: config.shell_hint,
992        },
993        config.nudge.clone(),
994        region_scripts,
995    )?;
996
997    // 7. Attach run metadata / token totals / persistence watermark (+ optional
998    // compaction settings).
999    let metadata = RunMetadata {
1000        run_id: args.run_id.clone(),
1001        agent_name: agent_name.clone(),
1002        agent_path: args.blueprint_path.clone(),
1003        task: args.task.clone(),
1004        model: model_label,
1005        workdir: args.workdir.clone(),
1006        num_stages,
1007        started_at: now_secs,
1008        parent_run_id: args.parent_run_id.clone(),
1009        metadata: args.metadata.clone(),
1010        callback_url: args.callback_url.clone(),
1011        callback_secret: args.callback_secret.clone(),
1012        title: None,
1013        unattended: args.yolo,
1014        read_paths: read_path_counts,
1015    };
1016    {
1017        let mut entity_mut = world.entity_mut(entity);
1018        entity_mut.insert((
1019            metadata,
1020            TokenTotals::default(),
1021            PersistWatermark::default(),
1022            // Fresh counters; a reloaded run gets its accumulated flags put back
1023            // by `recovery::reload_persisted_agents`.
1024            outcome_flags,
1025        ));
1026        // Mark eligible runs for one-shot title generation (the `title` module
1027        // fills `RunMetadata.title`, which the dashboard displays and
1028        // searches). Root runs only: sub-agents inherit their parent's context
1029        // in the run list, and titling every fan-out worker would multiply
1030        // cheap-but-nonzero LLM calls for no UX gain.
1031        (config.title.enabled && !args.task.is_empty() && args.parent_run_id.is_none())
1032            .then_some(leviath_runtime::title::PendingTitle)
1033            .into_iter()
1034            .for_each(|marker| {
1035                entity_mut.insert(marker);
1036            });
1037        // `--yolo` means run unattended, so a blueprint's stage-boundary
1038        // checkpoints are approved rather than parked on a hub nobody is
1039        // watching. (`.then_some(..).into_iter()` keeps the non-yolo path
1040        // branch-free, matching the taint-gate marker below.)
1041        args.yolo
1042            .then_some(leviath_runtime::components::InteractionAutoApprove)
1043            .into_iter()
1044            .for_each(|marker| {
1045                entity_mut.insert(marker);
1046            });
1047        // `Option`'s iterator inserts compaction settings when present without a
1048        // dangling `if let` block-end region.
1049        compaction.into_iter().for_each(|cc| {
1050            entity_mut.insert(CompactionSettings(cc));
1051        });
1052        // Attach the taint gate + per-tool sensitivities and turn on the window's
1053        // taint tracking when the blueprint opts in (`Option`'s iterator keeps the
1054        // enforcement path region-free when taint is off).
1055        tool_sensitivities.into_iter().for_each(|sensitivities| {
1056            let mut gate = leviath_runtime::TaintGate::new(security.clone());
1057            gate.apply_mcp_overrides(&mcp_overrides);
1058            entity_mut.insert((
1059                gate,
1060                leviath_runtime::pipeline::ToolSensitivities(sensitivities),
1061            ));
1062            // `--yolo` means run unattended: waive taint-gate prompts (the
1063            // tool-policy wildcard below doesn't cover them), so a headless run
1064            // never blocks on a gate no one can answer.
1065            if args.yolo {
1066                entity_mut.insert(leviath_runtime::components::GateAutoApprove);
1067            }
1068            // `Option`'s iterator enables tracking without a dead "no window" arm
1069            // (a freshly spawned agent always carries a ContextWindow).
1070            entity_mut
1071                .get_mut::<leviath_runtime::components::ContextWindow>()
1072                .into_iter()
1073                .for_each(|mut window| window.enable_taint_tracking());
1074        });
1075    }
1076
1077    // 8. Register the per-agent tool state.
1078    // Launch overrides: `--yolo` allows every tool (`*` wildcard); `--allow X`
1079    // allows tool `X` outright.
1080    let mut launch_overrides: HashMap<String, crate::config::ToolPolicy> = HashMap::new();
1081    if args.yolo {
1082        launch_overrides.insert("*".to_string(), crate::config::ToolPolicy::Allow);
1083    }
1084    for tool in &args.allow {
1085        launch_overrides.insert(tool.clone(), crate::config::ToolPolicy::Allow);
1086    }
1087    let subagent = SubAgentHandle {
1088        sender: subagent_tx,
1089        parent_run_id: args.run_id.clone(),
1090        workdir: args.workdir.clone(),
1091        max_depth: max_child_depth,
1092        no_seed_commands: args.no_seed_commands,
1093        unattended: args.yolo,
1094    };
1095    // Rhai script-tool host (Layer 3): resolve `[tool_script_permissions]` once,
1096    // with `read_file`/`shell` `inherit` deferring to the agent's own resolved
1097    // policy for that built-in (evaluated against the entry stage).
1098    let entry_stage_perms = stage_perms_by_index
1099        .get(entry_index)
1100        .cloned()
1101        .unwrap_or_default();
1102    // The agent may carry its own `[tool_script_permissions]` (it can ship its own
1103    // tool scripts), overlaid per field on the global config.
1104    let effective_script_perms = crate::daemon::script_host::effective_script_permissions(
1105        &config.tool_script_permissions,
1106        &content,
1107    );
1108    // Same ceiling `build_tool_state` resolves for the built-in tools: the
1109    // global `[tool_permissions]` with this agent's `[agent_tool_permissions]`
1110    // grants overlaid. Passing the raw global map here would silently ignore a
1111    // per-agent grant when a script tool's `inherit` defers to the built-in.
1112    let agent_scoped_perms = config.permissions_for_agent(&agent_name);
1113    let script_allow = crate::daemon::script_host::resolve_script_permissions(
1114        &effective_script_perms,
1115        &|builtin| {
1116            crate::tools::resolve_policy(
1117                builtin,
1118                true,
1119                &launch_overrides,
1120                &entry_stage_perms,
1121                &agent_perms,
1122                &agent_scoped_perms,
1123            )
1124        },
1125    );
1126    let script_host: Arc<dyn leviath_scripting::ScriptHost> = Arc::new(
1127        crate::daemon::script_host::DaemonScriptHost::new(
1128            script_allow,
1129            std::path::PathBuf::from(&args.workdir),
1130        )
1131        // Route a script `shell()` through the agent's per-stage sandbox (so a
1132        // script can't escape the isolation the stage declared) and cap it at the
1133        // configured wall-clock timeout.
1134        .with_shell(
1135            sandbox.clone(),
1136            std::time::Duration::from_secs(config.limits.script_shell_timeout_secs),
1137        )
1138        // `[security] allow_local_network`. Off by default, so a `web_fetch` URL
1139        // the model picked out of attacker-influenced context cannot reach cloud
1140        // metadata, the user's own `lev serve`, or their LAN.
1141        .with_local_network(config.security.allow_local_network)
1142        // `[security] allow_env_vars`. Empty by default, so a script tool cannot
1143        // read the user's provider keys and post them somewhere.
1144        .with_env_allowlist(config.security.allow_env_vars.clone()),
1145    );
1146    // Build the dynamic-tools re-resolution context (issue #97 escape hatch) and
1147    // tag the entity `DynamicTools` so the runtime polls it for mid-run re-scans.
1148    let dynamic = dynamic_tools.then(|| {
1149        world
1150            .entity_mut(entity)
1151            .insert(leviath_runtime::pipeline::DynamicTools);
1152        Arc::new(crate::daemon::tool_service::DynamicToolCtx {
1153            scan_dirs: script_scan_dirs(&args.blueprint_path, workdir_tools_dir),
1154            reserved_names: reserved_tool_names(&builtin_names, mcp_tool_defs),
1155            static_defs: static_tool_defs,
1156            stage_available,
1157            stage_required,
1158            unattended: args.yolo,
1159            dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1160        })
1161    });
1162    let state = build_tool_state(
1163        builtins,
1164        builtin_names,
1165        shared_mcp,
1166        config,
1167        hub,
1168        &args.run_id,
1169        &entry_stage,
1170        entry_index,
1171        stage_perms_by_index,
1172        stage_required_by_index,
1173        agent_perms,
1174        &agent_name,
1175        launch_overrides,
1176        Some(subagent),
1177        sandbox,
1178        script_tools,
1179        script_tool_names,
1180        script_host,
1181        dynamic,
1182        args.yolo,
1183    );
1184    tool_service.register(entity, state);
1185
1186    Ok(entity)
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192    use leviath_core::blueprint::ModelConfig;
1193    use leviath_runtime::ProviderRegistry;
1194    use leviath_runtime::world::PipelineWorld;
1195
1196    /// A throwaway sub-agent op sender for tests that don't exercise the bridge.
1197    fn sub_tx() -> UnboundedSender<SubAgentOp> {
1198        tokio::sync::mpsc::unbounded_channel().0
1199    }
1200
1201    /// What the daemon logs about a blueprint at spawn. A run is never refused
1202    /// for a lint finding, so the only way this surfaces is the log line - which
1203    /// makes it worth exercising directly rather than through a whole spawn.
1204    #[test]
1205    fn log_blueprint_lint_warns_about_findings_and_skips_notes() {
1206        crate::test_support::with_tracing(|| {});
1207        let home = tempfile::tempdir().unwrap();
1208        temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1209            // No `mode`, no `max_iterations`, an unattended `ask_user_text` and
1210            // a `[read_paths]` block: three warnings and one note.
1211            let manifest = r#"
1212[agent]
1213name = "noisy"
1214version = "0.1.0"
1215
1216[stages.main]
1217model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }] }
1218available_tools = ["ask_user_text"]
1219
1220[read_paths]
1221allow = ["~/.leviath/runs"]
1222
1223[context.regions]
1224system = { kind = "pinned", max_tokens = 1000 }
1225"#;
1226            let bp = leviath_core::manifest::parse_manifest(manifest).unwrap();
1227            let dir = tempfile::tempdir().unwrap();
1228            let path = dir.path().join("agent.leviath");
1229            std::fs::write(&path, manifest).unwrap();
1230
1231            // The findings the log walks, so the test asserts what is being
1232            // logged rather than only that logging did not panic.
1233            let env = crate::lint::LintEnv::offline(dir.path());
1234            let findings = crate::lint::lint_manifest(manifest, &bp, &env);
1235            assert!(
1236                findings
1237                    .iter()
1238                    .any(|f| f.severity == crate::lint::LintSeverity::Note),
1239                "the fixture needs a note for the skip arm to run"
1240            );
1241            assert!(
1242                findings
1243                    .iter()
1244                    .any(|f| f.severity == crate::lint::LintSeverity::Warning),
1245                "the fixture needs a warning for the log arm to run"
1246            );
1247
1248            log_blueprint_lint(manifest, &bp, &path.to_string_lossy());
1249        });
1250    }
1251
1252    /// A blueprint with nothing to say produces no log lines at all.
1253    #[test]
1254    fn log_blueprint_lint_is_silent_for_a_clean_blueprint() {
1255        crate::test_support::with_tracing(|| {});
1256        let home = tempfile::tempdir().unwrap();
1257        temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1258            let manifest = r#"
1259[agent]
1260name = "quiet"
1261version = "0.1.0"
1262
1263[stages.main]
1264mode = "autonomous"
1265model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }] }
1266max_iterations = 5
1267
1268[context.regions]
1269system = { kind = "pinned", max_tokens = 1000 }
1270"#;
1271            let bp = leviath_core::manifest::parse_manifest(manifest).unwrap();
1272            let dir = tempfile::tempdir().unwrap();
1273            let path = dir.path().join("agent.leviath");
1274            std::fs::write(&path, manifest).unwrap();
1275            let env = crate::lint::LintEnv::offline(dir.path());
1276            assert!(crate::lint::lint_manifest(manifest, &bp, &env).is_empty());
1277            log_blueprint_lint(manifest, &bp, &path.to_string_lossy());
1278        });
1279    }
1280
1281    #[test]
1282    fn fallback_order_parses_provider_slash_model_and_drops_junk() {
1283        // The `tracing::warn!` on the reject path evaluates its field
1284        // expressions only under a real subscriber.
1285        crate::test_support::with_tracing(|| {
1286            let parsed = parse_fallback_order(&[
1287                // A model id containing a slash must survive intact, which is
1288                // the common OpenRouter shape.
1289                "openrouter/deepseek/deepseek-v4-flash".to_string(),
1290                "anthropic/claude-sonnet-5".to_string(),
1291                // Rejected: a bare provider gives us no model to send.
1292                "anthropic".to_string(),
1293                "/no-provider".to_string(),
1294                "no-model/".to_string(),
1295                String::new(),
1296            ]);
1297            assert_eq!(
1298                parsed
1299                    .iter()
1300                    .map(|e| (e.provider.as_str(), e.model.as_str()))
1301                    .collect::<Vec<_>>(),
1302                vec![
1303                    ("openrouter", "deepseek/deepseek-v4-flash"),
1304                    ("anthropic", "claude-sonnet-5"),
1305                ]
1306            );
1307        });
1308    }
1309
1310    #[test]
1311    fn model_defaults_carries_the_fallback_chain_from_config() {
1312        let mut config = Config {
1313            default_provider: "openrouter".to_string(),
1314            default_model: Some("deepseek".to_string()),
1315            ..Default::default()
1316        };
1317        config.providers.fallback_order = vec!["anthropic/claude-sonnet-5".to_string()];
1318        let defaults = model_defaults(&config);
1319        assert_eq!(defaults.provider, "openrouter");
1320        assert_eq!(defaults.model.as_deref(), Some("deepseek"));
1321        assert_eq!(defaults.fallback_order.len(), 1);
1322        assert_eq!(defaults.fallback_order[0].provider, "anthropic");
1323    }
1324
1325    #[test]
1326    fn discover_script_tools_registers_and_drops_collisions() {
1327        crate::test_support::with_tracing(|| {});
1328        // Point LEVIATH_HOME at an empty temp dir so the global tools/ scan is
1329        // hermetic (no real ~/.leviath/tools leaking in).
1330        let home = tempfile::tempdir().unwrap();
1331        temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1332            let agent_dir = tempfile::tempdir().unwrap();
1333            let tools = agent_dir.path().join("tools");
1334            std::fs::create_dir(&tools).unwrap();
1335            std::fs::write(tools.join("echo.rhai"), "// @tool echo\nparams.x").unwrap();
1336            // A tool named after a built-in must be dropped (never shadow it).
1337            std::fs::write(tools.join("read_file.rhai"), "// @tool read_file\n1").unwrap();
1338            // A tool colliding with an MCP tool is also dropped (exercises the
1339            // mcp_tool_defs reservation).
1340            std::fs::write(tools.join("mcp_tool.rhai"), "// @tool mcp_tool\n1").unwrap();
1341            // A malformed script is skipped + warned about (the skipped loop).
1342            std::fs::write(tools.join("bad.rhai"), "no tool directive\nlet").unwrap();
1343            // A tool requiring a capability this platform can't provide is dropped
1344            // (unknown cap name → never satisfiable). Desktop has every real cap,
1345            // so a bogus name is the portable way to exercise the drop branch.
1346            std::fs::write(
1347                tools.join("needs_gpu.rhai"),
1348                "// @tool needs_gpu\n// @requires gpu\n1",
1349            )
1350            .unwrap();
1351            // A tool requiring a capability the desktop platform *does* provide is kept.
1352            std::fs::write(
1353                tools.join("net_tool.rhai"),
1354                "// @tool net_tool\n// @requires network\n1",
1355            )
1356            .unwrap();
1357            let blueprint = agent_dir.path().join("agent.leviath");
1358
1359            let builtins: HashSet<String> = ["read_file".to_string()].into_iter().collect();
1360            let mcp = vec![leviath_providers::Tool {
1361                name: "mcp_tool".to_string(),
1362                description: String::new(),
1363                parameters: serde_json::json!({}),
1364            }];
1365            let (set, names, defs) =
1366                discover_script_tools(blueprint.to_str().unwrap(), &builtins, &mcp, None);
1367            // Compiled the valid ones; only the non-colliding, platform-satisfiable
1368            // ones are routable.
1369            assert!(set.contains("echo") && set.contains("read_file"));
1370            assert!(names.contains("echo"));
1371            assert!(!names.contains("read_file"));
1372            assert!(!names.contains("mcp_tool"));
1373            assert!(!names.contains("needs_gpu"), "unsatisfiable cap dropped");
1374            assert!(names.contains("net_tool"), "satisfiable cap kept");
1375            let mut def_names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
1376            def_names.sort_unstable();
1377            assert_eq!(def_names, vec!["echo", "net_tool"]);
1378        });
1379    }
1380
1381    #[test]
1382    fn script_cap_maps_known_and_unknown_names() {
1383        use leviath_tools::ToolCapability::*;
1384        assert_eq!(script_cap("network"), Some(Network));
1385        assert_eq!(script_cap("http"), Some(Network));
1386        assert_eq!(script_cap("shell"), Some(ProcessSpawn));
1387        assert_eq!(script_cap("process_spawn"), Some(ProcessSpawn));
1388        assert_eq!(script_cap("filesystem"), Some(FileSystem));
1389        assert_eq!(script_cap("fs"), Some(FileSystem));
1390        assert_eq!(script_cap("gpu"), None);
1391    }
1392
1393    #[test]
1394    fn platform_satisfies_caps_gates_on_support() {
1395        use leviath_tools::{PlatformCapabilities, ToolCapability};
1396        // Empty requirement is always satisfied.
1397        let mobile = PlatformCapabilities::mobile();
1398        assert!(platform_satisfies_caps(&mobile, &[]));
1399        // Mobile has filesystem/network but not process spawning.
1400        assert!(platform_satisfies_caps(&mobile, &["network".to_string()]));
1401        assert!(!platform_satisfies_caps(&mobile, &["shell".to_string()]));
1402        // An unknown cap name is never satisfiable, even on a full desktop.
1403        let desktop = PlatformCapabilities::from_capabilities([
1404            ToolCapability::Network,
1405            ToolCapability::FileSystem,
1406            ToolCapability::ProcessSpawn,
1407        ]);
1408        assert!(!platform_satisfies_caps(&desktop, &["mystery".to_string()]));
1409    }
1410
1411    #[test]
1412    fn discover_script_tools_empty_when_no_tools_dir() {
1413        let home = tempfile::tempdir().unwrap();
1414        temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1415            let agent_dir = tempfile::tempdir().unwrap();
1416            let blueprint = agent_dir.path().join("agent.leviath");
1417            let (set, names, defs) =
1418                discover_script_tools(blueprint.to_str().unwrap(), &HashSet::new(), &[], None);
1419            assert!(set.is_empty() && names.is_empty() && defs.is_empty());
1420        });
1421    }
1422
1423    #[test]
1424    fn discover_script_tools_handles_pathless_blueprint() {
1425        // A blueprint path with no parent exercises the "no agent dir" arm; the
1426        // global tools/ scan still runs (empty here).
1427        let home = tempfile::tempdir().unwrap();
1428        temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1429            let (set, _n, _d) = discover_script_tools("", &HashSet::new(), &[], None);
1430            assert!(set.is_empty());
1431        });
1432    }
1433    use leviath_core::blueprint::ModelEntry;
1434
1435    fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
1436        ModelConfig {
1437            models: models
1438                .into_iter()
1439                .map(|(p, m)| ModelEntry {
1440                    provider: p.to_string(),
1441                    model: m.to_string(),
1442                })
1443                .collect(),
1444            allow_user_default: true,
1445            parameters: HashMap::new(),
1446            request_timeout_secs: None,
1447        }
1448    }
1449
1450    fn registry_with(providers: &[&str]) -> ProviderRegistry {
1451        let mut r = ProviderRegistry::new();
1452        for p in providers {
1453            r.register(p.to_string(), Arc::new(FakeProvider));
1454        }
1455        r
1456    }
1457
1458    struct FakeProvider;
1459    #[async_trait::async_trait]
1460    impl leviath_providers::Provider for FakeProvider {
1461        async fn infer(
1462            &self,
1463            _r: leviath_providers::InferenceRequest,
1464        ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
1465            Err(leviath_providers::ProviderError::Other(
1466                "test provider".to_string(),
1467            ))
1468        }
1469        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1470            1
1471        }
1472        fn max_context_tokens(&self, _m: &str) -> usize {
1473            1000
1474        }
1475        fn name(&self) -> &str {
1476            "fake"
1477        }
1478        fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
1479            leviath_providers::ModelCapabilities::default()
1480        }
1481    }
1482
1483    // ── build_agent (full spawn from a manifest) ──
1484
1485    use leviath_providers::Provider;
1486    use leviath_runtime::components::AgentStatus;
1487    use leviath_runtime::inference_pool::InferencePoolConfig;
1488    use tokio::runtime::Handle;
1489
1490    fn coder_manifest() -> String {
1491        // Self-contained fixture - not the shipped blueprint, so these spawn-logic
1492        // tests stay isolated from agents/coder edits.
1493        crate::test_support::inline_coder_manifest()
1494    }
1495
1496    fn test_world() -> (PipelineWorld, Arc<CliToolService>) {
1497        let cli = Arc::new(CliToolService::new());
1498        let world = PipelineWorld::new(
1499            registry_with(&["anthropic", "openai", "ollama"]),
1500            cli.clone(),
1501            InferencePoolConfig::new(),
1502            1,
1503            None,
1504            Handle::current(),
1505        );
1506        (world, cli)
1507    }
1508
1509    fn spawn_args(path: &str) -> SpawnArgs {
1510        SpawnArgs {
1511            run_id: "run-x".to_string(),
1512            blueprint_path: path.to_string(),
1513            task: "do the thing".to_string(),
1514            regions: HashMap::new(),
1515            model: None,
1516            workdir: std::env::temp_dir().to_string_lossy().to_string(),
1517            metadata: HashMap::new(),
1518            callback_url: None,
1519            callback_secret: None,
1520            yolo: false,
1521            no_seed_commands: false,
1522            allow: Vec::new(),
1523            max_depth: None,
1524            parent_run_id: None,
1525        }
1526    }
1527
1528    // ─── resolve_region_scripts ──────────────────────────────────────────
1529
1530    /// Manifest with a global custom region and a per-stage one, both
1531    /// pointing into `hooks/` next to the manifest.
1532    fn custom_region_manifest() -> &'static str {
1533        "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1534         [context.regions.brain]\nkind = \"custom\"\nscript = \"hooks/brain.rhai\"\nmax_tokens = 4000\n\n\
1535         [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1536         [stages.main.context.regions.stage_view]\nkind = \"custom\"\nscript = \"hooks/stage.rhai\"\nmax_tokens = 2000\n"
1537    }
1538
1539    #[test]
1540    fn resolve_region_scripts_empty_without_custom_regions() {
1541        let dir = tempfile::tempdir().unwrap();
1542        let manifest = dir.path().join("agent.leviath");
1543        let bp = leviath_core::manifest::parse_manifest(
1544            "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1545             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1546        )
1547        .unwrap();
1548        let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1549        assert!(scripts.is_empty());
1550    }
1551
1552    #[test]
1553    fn resolve_region_scripts_collects_global_and_per_stage_layouts() {
1554        let dir = tempfile::tempdir().unwrap();
1555        let manifest = dir.path().join("agent.leviath");
1556        std::fs::create_dir(dir.path().join("hooks")).unwrap();
1557        std::fs::write(
1558            dir.path().join("hooks/brain.rhai"),
1559            "fn render(ctx) { \"b\" }",
1560        )
1561        .unwrap();
1562        std::fs::write(
1563            dir.path().join("hooks/stage.rhai"),
1564            "fn render(ctx) { \"s\" }",
1565        )
1566        .unwrap();
1567        let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1568        let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1569        assert_eq!(scripts.len(), 2);
1570        assert!(scripts.contains_key("hooks/brain.rhai"));
1571        assert!(scripts.contains_key("hooks/stage.rhai"));
1572    }
1573
1574    #[test]
1575    fn resolve_region_scripts_reads_a_shared_path_once() {
1576        // Two regions declaring the same script share one compiled Arc.
1577        let dir = tempfile::tempdir().unwrap();
1578        let manifest = dir.path().join("agent.leviath");
1579        std::fs::create_dir(dir.path().join("hooks")).unwrap();
1580        std::fs::write(
1581            dir.path().join("hooks/shared.rhai"),
1582            "fn render(ctx) { \"x\" }",
1583        )
1584        .unwrap();
1585        let bp = leviath_core::manifest::parse_manifest(
1586            "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1587             [context.regions.a]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1588             [context.regions.b]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1589             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1590        )
1591        .unwrap();
1592        let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1593        assert_eq!(scripts.len(), 1);
1594    }
1595
1596    #[test]
1597    fn resolve_region_scripts_missing_file_is_a_hard_error() {
1598        let dir = tempfile::tempdir().unwrap();
1599        let manifest = dir.path().join("agent.leviath");
1600        let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1601        let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1602        assert!(err.contains("region 'brain'"), "{err}");
1603        assert!(err.contains("hooks/brain.rhai"), "{err}");
1604    }
1605
1606    #[test]
1607    fn resolve_region_scripts_uncompilable_script_is_a_hard_error() {
1608        let dir = tempfile::tempdir().unwrap();
1609        let manifest = dir.path().join("agent.leviath");
1610        std::fs::create_dir(dir.path().join("hooks")).unwrap();
1611        std::fs::write(dir.path().join("hooks/brain.rhai"), "fn render(ctx) {").unwrap();
1612        std::fs::write(
1613            dir.path().join("hooks/stage.rhai"),
1614            "fn render(ctx) { \"s\" }",
1615        )
1616        .unwrap();
1617        let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1618        let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1619        assert!(err.contains("failed to compile"), "{err}");
1620        assert!(err.contains("region 'brain'"), "{err}");
1621    }
1622
1623    #[tokio::test]
1624    async fn build_agent_fails_fast_on_a_broken_custom_region_script() {
1625        // The resolve error propagates out of build_agent before any tokens
1626        // are spent - a hook that silently never ran would change every
1627        // inference with no signal.
1628        let dir = tempfile::tempdir().unwrap();
1629        let manifest = dir.path().join("agent.leviath");
1630        std::fs::write(&manifest, custom_region_manifest()).unwrap();
1631
1632        let (mut world, cli) = test_world();
1633        let hub = InteractionHub::new();
1634        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1635        let args = spawn_args(&manifest.to_string_lossy());
1636        let err = build_agent(
1637            world.world_mut(),
1638            cli.as_ref(),
1639            &Config::default(),
1640            mcp,
1641            &[],
1642            &hub,
1643            &args,
1644            100,
1645            sub_tx(),
1646        )
1647        .unwrap_err();
1648        assert!(err.contains("region 'brain'"), "got: {err}");
1649        assert!(err.contains("hooks/brain.rhai"), "got: {err}");
1650    }
1651
1652    #[tokio::test]
1653    async fn build_agent_rejects_a_workdir_that_is_missing_or_not_a_directory() {
1654        // `ToolContext::new` silently keeps a path it can't canonicalize, so
1655        // without this check a bogus workdir spawns a healthy-looking agent
1656        // whose every tool call then fails with ENOENT (issue #107).
1657        let dir = tempfile::tempdir().unwrap();
1658        let manifest = dir.path().join("agent.leviath");
1659        std::fs::write(
1660            &manifest,
1661            "[agent]\nname = \"w\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1662             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1663        )
1664        .unwrap();
1665        let not_a_dir = dir.path().join("a-file");
1666        std::fs::write(&not_a_dir, "x").unwrap();
1667
1668        for workdir in [
1669            dir.path()
1670                .join("does-not-exist")
1671                .to_string_lossy()
1672                .to_string(),
1673            not_a_dir.to_string_lossy().to_string(),
1674        ] {
1675            let (mut world, cli) = test_world();
1676            let hub = InteractionHub::new();
1677            let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1678            let mut args = spawn_args(&manifest.to_string_lossy());
1679            args.workdir = workdir.clone();
1680            let err = build_agent(
1681                world.world_mut(),
1682                cli.as_ref(),
1683                &Config::default(),
1684                mcp,
1685                &[],
1686                &hub,
1687                &args,
1688                100,
1689                sub_tx(),
1690            )
1691            .unwrap_err();
1692            assert!(err.contains("workspace"), "got: {err}");
1693            assert!(err.contains(&workdir), "got: {err}");
1694        }
1695    }
1696
1697    #[tokio::test]
1698    async fn build_agent_attaches_taint_gate_when_security_enabled() {
1699        let dir = tempfile::tempdir().unwrap();
1700        let manifest = dir.path().join("agent.leviath");
1701        std::fs::write(
1702            &manifest,
1703            "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1704             [security]\ntaint_tracking = true\n\n\
1705             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1706        )
1707        .unwrap();
1708        let (mut world, cli) = test_world();
1709        let hub = InteractionHub::new();
1710        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1711        let entity = build_agent(
1712            world.world_mut(),
1713            cli.as_ref(),
1714            &Config::default(),
1715            mcp,
1716            &[],
1717            &hub,
1718            &spawn_args(&manifest.to_string_lossy()),
1719            100,
1720            sub_tx(),
1721        )
1722        .expect("spawn succeeds");
1723
1724        // Taint opt-in ⇒ gate + sensitivities attached and window tracking on.
1725        assert!(
1726            world
1727                .world()
1728                .get::<leviath_runtime::TaintGate>(entity)
1729                .is_some()
1730        );
1731        assert!(
1732            world
1733                .world()
1734                .get::<leviath_runtime::pipeline::ToolSensitivities>(entity)
1735                .is_some()
1736        );
1737        assert!(
1738            world
1739                .world()
1740                .get::<leviath_runtime::components::ContextWindow>(entity)
1741                .unwrap()
1742                .overall_taint()
1743                .is_some()
1744        );
1745        // Without `--yolo`, the gate stays interactive: no auto-approve marker.
1746        assert!(
1747            world
1748                .world()
1749                .get::<leviath_runtime::components::GateAutoApprove>(entity)
1750                .is_none()
1751        );
1752    }
1753
1754    #[tokio::test]
1755    async fn build_agent_marks_root_runs_for_titling_but_not_subagents() {
1756        let dir = tempfile::tempdir().unwrap();
1757        let manifest = dir.path().join("agent.leviath");
1758        std::fs::write(
1759            &manifest,
1760            "[agent]\nname = \"titler\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1761             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1762        )
1763        .unwrap();
1764        let (mut world, cli) = test_world();
1765        let hub = InteractionHub::new();
1766
1767        // Root run with the default-enabled [title] config: marked.
1768        let root = build_agent(
1769            world.world_mut(),
1770            cli.as_ref(),
1771            &Config::default(),
1772            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1773            &[],
1774            &hub,
1775            &spawn_args(&manifest.to_string_lossy()),
1776            100,
1777            sub_tx(),
1778        )
1779        .expect("spawn succeeds");
1780        assert!(
1781            world
1782                .world()
1783                .get::<leviath_runtime::title::PendingTitle>(root)
1784                .is_some()
1785        );
1786
1787        // A sub-agent run is never marked: titles serve the top-level run list.
1788        let mut child_args = spawn_args(&manifest.to_string_lossy());
1789        child_args.run_id = "run-child".to_string();
1790        child_args.parent_run_id = Some("run-x".to_string());
1791        let child = build_agent(
1792            world.world_mut(),
1793            cli.as_ref(),
1794            &Config::default(),
1795            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1796            &[],
1797            &hub,
1798            &child_args,
1799            100,
1800            sub_tx(),
1801        )
1802        .expect("spawn succeeds");
1803        assert!(
1804            world
1805                .world()
1806                .get::<leviath_runtime::title::PendingTitle>(child)
1807                .is_none()
1808        );
1809
1810        // Disabled config: not marked.
1811        let config = Config {
1812            title: leviath_core::config::TitleConfig {
1813                enabled: false,
1814                provider: None,
1815                model: None,
1816            },
1817            ..Config::default()
1818        };
1819        let mut off_args = spawn_args(&manifest.to_string_lossy());
1820        off_args.run_id = "run-off".to_string();
1821        let off = build_agent(
1822            world.world_mut(),
1823            cli.as_ref(),
1824            &config,
1825            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1826            &[],
1827            &hub,
1828            &off_args,
1829            100,
1830            sub_tx(),
1831        )
1832        .expect("spawn succeeds");
1833        assert!(
1834            world
1835                .world()
1836                .get::<leviath_runtime::title::PendingTitle>(off)
1837                .is_none()
1838        );
1839    }
1840
1841    #[tokio::test]
1842    async fn build_agent_applies_policy_mcp_overrides_to_the_gate() {
1843        let dir = tempfile::tempdir().unwrap();
1844        let manifest = dir.path().join("agent.leviath");
1845        std::fs::write(
1846            &manifest,
1847            "[agent]\nname = \"sec-ov\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1848             [security]\ntaint_tracking = true\n\n\
1849             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1850        )
1851        .unwrap();
1852        let (mut world, cli) = test_world();
1853        // The daemon loads policy.toml into this resource at setup; an
1854        // [mcp_overrides] entry there must reach the gate attached at spawn,
1855        // not just `lev policy list` output.
1856        world
1857            .world_mut()
1858            .insert_resource(leviath_runtime::pipeline::PolicyGate(
1859                leviath_core::PolicyConfig {
1860                    allowlist: Vec::new(),
1861                    mcp_overrides: HashMap::from([(
1862                        "notes.share".to_string(),
1863                        leviath_core::policy::McpToolOverride {
1864                            sensitivity: None,
1865                            direction: Some("outbound".to_string()),
1866                            clearance: Some(leviath_core::TaintLevel::Private),
1867                        },
1868                    )]),
1869                },
1870            ));
1871        let hub = InteractionHub::new();
1872        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1873        let entity = build_agent(
1874            world.world_mut(),
1875            cli.as_ref(),
1876            &Config::default(),
1877            mcp,
1878            &[],
1879            &hub,
1880            &spawn_args(&manifest.to_string_lossy()),
1881            100,
1882            sub_tx(),
1883        )
1884        .expect("spawn succeeds");
1885
1886        let gate = world
1887            .world()
1888            .get::<leviath_runtime::TaintGate>(entity)
1889            .expect("gate attached");
1890        let classification = gate.tool_classification("notes.share");
1891        assert_eq!(
1892            classification.direction,
1893            leviath_core::taint::ToolDirection::Outbound
1894        );
1895        assert_eq!(classification.clearance, leviath_core::TaintLevel::Private);
1896    }
1897
1898    #[tokio::test]
1899    async fn build_agent_errors_when_required_caller_region_missing() {
1900        // A required caller-input region that the request doesn't provide makes
1901        // build_agent fail (via resolve_seeds) before spawning - no inference.
1902        let dir = tempfile::tempdir().unwrap();
1903        let manifest = dir.path().join("agent.leviath");
1904        std::fs::write(
1905            &manifest,
1906            "[agent]\nname = \"needs\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1907             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1908             [context.regions]\n\
1909             spec = { kind = \"pinned\", max_tokens = 2000, seed = \"input\", required = true }\n\
1910             conversation = { kind = \"sliding_window\", max_items = 20, max_tokens = 10000 }\n",
1911        )
1912        .unwrap();
1913        let (mut world, cli) = test_world();
1914        let hub = InteractionHub::new();
1915        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1916        // spawn_args() provides only the task, not the required `spec` region.
1917        let err = build_agent(
1918            world.world_mut(),
1919            cli.as_ref(),
1920            &Config::default(),
1921            mcp,
1922            &[],
1923            &hub,
1924            &spawn_args(&manifest.to_string_lossy()),
1925            100,
1926            sub_tx(),
1927        )
1928        .unwrap_err();
1929        assert!(err.contains("spec"), "got: {err}");
1930    }
1931
1932    #[tokio::test]
1933    async fn build_agent_attaches_sandbox_when_configured() {
1934        // A `namespace` sandbox with `on_unavailable = "warn"` builds on every
1935        // platform without running any external command, so this deterministically
1936        // exercises the spawn-side sandbox wiring (manager built + attached).
1937        let dir = tempfile::tempdir().unwrap();
1938        let manifest = dir.path().join("agent.leviath");
1939        std::fs::write(
1940            &manifest,
1941            "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1942             [sandbox]\nkind = \"namespace\"\non_unavailable = \"warn\"\n\n\
1943             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1944        )
1945        .unwrap();
1946        let (mut world, cli) = test_world();
1947        let hub = InteractionHub::new();
1948        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1949        let entity = build_agent(
1950            world.world_mut(),
1951            cli.as_ref(),
1952            &Config::default(),
1953            mcp,
1954            &[],
1955            &hub,
1956            &spawn_args(&manifest.to_string_lossy()),
1957            100,
1958            sub_tx(),
1959        )
1960        .expect("spawn succeeds");
1961        // The agent's tool state carries a sandbox manager.
1962        let state = cli.take(entity).expect("state registered");
1963        assert!(state.sandbox.is_some(), "sandbox manager attached");
1964    }
1965
1966    #[tokio::test]
1967    async fn build_agent_errors_when_sandbox_runtime_unavailable() {
1968        // A container sandbox naming a nonexistent engine fails to start on every
1969        // platform (no runtime needed), so build_agent surfaces the error - this
1970        // covers the `?` on `SandboxManager::build` uniformly across OSes,
1971        // independent of which container runtimes happen to be installed.
1972        let dir = tempfile::tempdir().unwrap();
1973        let manifest = dir.path().join("agent.leviath");
1974        std::fs::write(
1975            &manifest,
1976            "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1977             [sandbox]\nkind = \"container\"\nimage = \"x\"\nengine = \"leviath-no-such-engine\"\n\n\
1978             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1979        )
1980        .unwrap();
1981        let (mut world, cli) = test_world();
1982        let hub = InteractionHub::new();
1983        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1984        let err = build_agent(
1985            world.world_mut(),
1986            cli.as_ref(),
1987            &Config::default(),
1988            mcp,
1989            &[],
1990            &hub,
1991            &spawn_args(&manifest.to_string_lossy()),
1992            100,
1993            sub_tx(),
1994        )
1995        .expect_err("a nonexistent engine can't start the container");
1996        assert!(err.contains("sandbox unavailable"), "got: {err}");
1997    }
1998
1999    #[tokio::test]
2000    async fn build_agent_yolo_attaches_gate_auto_approve_when_taint_on() {
2001        let dir = tempfile::tempdir().unwrap();
2002        let manifest = dir.path().join("agent.leviath");
2003        std::fs::write(
2004            &manifest,
2005            "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2006             [security]\ntaint_tracking = true\n\n\
2007             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2008        )
2009        .unwrap();
2010        let (mut world, cli) = test_world();
2011        let hub = InteractionHub::new();
2012        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2013        let mut args = spawn_args(&manifest.to_string_lossy());
2014        args.yolo = true;
2015        let entity = build_agent(
2016            world.world_mut(),
2017            cli.as_ref(),
2018            &Config::default(),
2019            mcp,
2020            &[],
2021            &hub,
2022            &args,
2023            100,
2024            sub_tx(),
2025        )
2026        .expect("spawn succeeds");
2027        // Taint on + `--yolo` ⇒ gate is auto-approved (marker attached) so a
2028        // headless run never blocks on a gate prompt.
2029        assert!(
2030            world
2031                .world()
2032                .get::<leviath_runtime::components::GateAutoApprove>(entity)
2033                .is_some()
2034        );
2035        // ...and likewise for the blueprint's own stage-boundary checkpoints and
2036        // the agent's `ask_user_*` tools (#107): unattended means unattended.
2037        assert!(
2038            world
2039                .world()
2040                .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
2041                .is_some()
2042        );
2043        assert!(cli.take(entity).expect("tool state registered").unattended);
2044        // Recorded on the agent, so the sub-agent and fan-out spawners can pass
2045        // it down and `meta.json` can carry it across a restart.
2046        assert!(
2047            world
2048                .world()
2049                .get::<RunMetadata>(entity)
2050                .expect("run metadata attached")
2051                .unattended
2052        );
2053    }
2054
2055    /// The status a `--yolo` run reports is `active`, not `waiting`: nothing
2056    /// should be opening a prompt for it in the first place.
2057    #[tokio::test]
2058    async fn build_agent_yolo_leaves_the_run_active_and_unattended() {
2059        let dir = tempfile::tempdir().unwrap();
2060        let manifest = dir.path().join("agent.leviath");
2061        std::fs::write(
2062            &manifest,
2063            "[agent]\nname = \"a\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2064             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2065        )
2066        .unwrap();
2067        let (mut world, cli) = test_world();
2068        let mut args = spawn_args(&manifest.to_string_lossy());
2069        args.yolo = true;
2070        let entity = build_agent(
2071            world.world_mut(),
2072            cli.as_ref(),
2073            &Config::default(),
2074            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
2075            &[],
2076            &InteractionHub::new(),
2077            &args,
2078            100,
2079            sub_tx(),
2080        )
2081        .expect("spawn succeeds");
2082
2083        assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2084        let meta = world
2085            .world()
2086            .get::<RunMetadata>(entity)
2087            .expect("run metadata attached");
2088        assert!(meta.unattended);
2089    }
2090
2091    /// A stage that kept a human tool through an unattended run has to reach the
2092    /// tool state with that tool in hand: the cut takes it out of the advertised
2093    /// set, and this set is what puts a call to it back in front of a person
2094    /// instead of the auto-answering backend (issue #204).
2095    #[tokio::test]
2096    async fn build_agent_carries_required_tools_into_the_tool_state() {
2097        let dir = tempfile::tempdir().unwrap();
2098        let manifest = dir.path().join("agent.leviath");
2099        std::fs::write(
2100            &manifest,
2101            "[agent]\nname = \"asks\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2102             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
2103             available_tools = [\"read_file\", \"ask_user_text\"]\n\
2104             required_tools = [\"ask_user_text\"]\n",
2105        )
2106        .unwrap();
2107        let (mut world, cli) = test_world();
2108        let mut args = spawn_args(&manifest.to_string_lossy());
2109        args.yolo = true;
2110        let entity = build_agent(
2111            world.world_mut(),
2112            cli.as_ref(),
2113            &Config::default(),
2114            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
2115            &[],
2116            &InteractionHub::new(),
2117            &args,
2118            100,
2119            sub_tx(),
2120        )
2121        .expect("spawn succeeds");
2122
2123        let state = cli.take(entity).expect("tool state registered");
2124        assert!(
2125            state
2126                .stage_required
2127                .lock()
2128                .unwrap()
2129                .contains("ask_user_text")
2130        );
2131        assert_eq!(state.stage_required_by_index.len(), 1);
2132    }
2133
2134    #[tokio::test]
2135    async fn build_agent_without_yolo_keeps_prompts_interactive() {
2136        let dir = tempfile::tempdir().unwrap();
2137        let manifest = dir.path().join("agent.leviath");
2138        std::fs::write(
2139            &manifest,
2140            "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2141             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2142        )
2143        .unwrap();
2144        let (mut world, cli) = test_world();
2145        let hub = InteractionHub::new();
2146        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2147        let entity = build_agent(
2148            world.world_mut(),
2149            cli.as_ref(),
2150            &Config::default(),
2151            mcp,
2152            &[],
2153            &hub,
2154            &spawn_args(&manifest.to_string_lossy()),
2155            100,
2156            sub_tx(),
2157        )
2158        .expect("spawn succeeds");
2159        assert!(
2160            world
2161                .world()
2162                .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
2163                .is_none()
2164        );
2165        assert!(!cli.take(entity).expect("tool state registered").unattended);
2166    }
2167
2168    #[tokio::test]
2169    async fn build_agent_no_security_block_leaves_taint_off_by_default() {
2170        // Bug regression: a blueprint with no `[security]` block and a default
2171        // (taint-off) global config must NOT attach the taint gate - an
2172        // `unwrap_or_default()` on the resolved security forces it on for
2173        // every agent.
2174        let dir = tempfile::tempdir().unwrap();
2175        let manifest = dir.path().join("agent.leviath");
2176        std::fs::write(
2177            &manifest,
2178            "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2179             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2180        )
2181        .unwrap();
2182        let (mut world, cli) = test_world();
2183        let hub = InteractionHub::new();
2184        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2185        let entity = build_agent(
2186            world.world_mut(),
2187            cli.as_ref(),
2188            &Config::default(), // taint_tracking defaults to false
2189            mcp,
2190            &[],
2191            &hub,
2192            &spawn_args(&manifest.to_string_lossy()),
2193            100,
2194            sub_tx(),
2195        )
2196        .expect("spawn succeeds");
2197        assert!(
2198            world
2199                .world()
2200                .get::<leviath_runtime::TaintGate>(entity)
2201                .is_none(),
2202            "no [security] block + global off ⇒ no taint gate"
2203        );
2204    }
2205
2206    /// The `no_output_tools` a freshly built agent carries.
2207    async fn spawned_no_output_tools(manifest_body: &str) -> bool {
2208        let dir = tempfile::tempdir().unwrap();
2209        let manifest = dir.path().join("agent.leviath");
2210        std::fs::write(&manifest, manifest_body).unwrap();
2211        let (mut world, cli) = test_world();
2212        let hub = InteractionHub::new();
2213        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2214        let entity = build_agent(
2215            world.world_mut(),
2216            cli.as_ref(),
2217            &Config::default(),
2218            mcp,
2219            &[],
2220            &hub,
2221            &spawn_args(&manifest.to_string_lossy()),
2222            100,
2223            sub_tx(),
2224        )
2225        .expect("spawn succeeds");
2226        world
2227            .world()
2228            .get::<leviath_runtime::persistence::RunOutcomeFlags>(entity)
2229            .expect("build_agent attaches run outcome flags")
2230            .0
2231            .no_output_tools
2232    }
2233
2234    #[tokio::test]
2235    async fn build_agent_records_whether_the_blueprint_can_write_at_all() {
2236        // A coding agent writes in `implement`, so silence from it is worth
2237        // reporting.
2238        assert!(!spawned_no_output_tools(&coder_manifest()).await);
2239        // A router-shaped agent delegates and never writes. Reporting it as
2240        // having "modified nothing" is an accusation the framework has no
2241        // grounds for (issue #192).
2242        assert!(
2243            spawned_no_output_tools(
2244                "[agent]\nname = \"router\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2245                 [stages.triage]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
2246                 available_tools = [\"read_file\", \"spawn_agent\"]\n",
2247            )
2248            .await
2249        );
2250    }
2251
2252    #[tokio::test]
2253    async fn build_agent_spawns_registers_and_wires_tools() {
2254        let dir = tempfile::tempdir().unwrap();
2255        let manifest = dir.path().join("agent.leviath");
2256        std::fs::write(&manifest, coder_manifest()).unwrap();
2257
2258        let (mut world, cli) = test_world();
2259        let hub = InteractionHub::new();
2260        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2261        let entity = build_agent(
2262            world.world_mut(),
2263            cli.as_ref(),
2264            &Config::default(),
2265            mcp,
2266            &[],
2267            &hub,
2268            &spawn_args(&manifest.to_string_lossy()),
2269            100,
2270            sub_tx(),
2271        )
2272        .expect("spawn succeeds");
2273
2274        assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2275        // The run metadata was attached.
2276        let md = world
2277            .world()
2278            .get::<RunMetadata>(entity)
2279            .expect("run metadata");
2280        assert_eq!(md.run_id, "run-x");
2281        assert_eq!(md.agent_name, "coder");
2282        // Tool state was registered: a tool batch dispatches (not "no tool state").
2283        let out = leviath_runtime::pipeline::ToolService::exec_for(
2284            cli.as_ref(),
2285            entity,
2286            vec![leviath_providers::ToolCall {
2287                id: "c1".to_string(),
2288                name: "list_dir".to_string(),
2289                arguments: serde_json::json!({"path": "."}),
2290                thought_signature: None,
2291            }],
2292            leviath_runtime::pipeline::noop_progress(),
2293        )()
2294        .await;
2295        assert_eq!(out[0].0, "c1");
2296        assert!(!out[0].1.contains("no tool state"));
2297    }
2298
2299    #[tokio::test]
2300    async fn build_agent_tags_dynamic_tools_agent() {
2301        // A blueprint opting into dynamic_tools gets the DynamicTools marker so the
2302        // runtime polls it for mid-run re-scans; the agent's tool state carries the
2303        // re-resolution context (exercised via refresh_tools).
2304        let dir = tempfile::tempdir().unwrap();
2305        let manifest = dir.path().join("agent.leviath");
2306        std::fs::write(
2307            &manifest,
2308            coder_manifest().replace("[agent]", "[agent]\ndynamic_tools = true"),
2309        )
2310        .unwrap();
2311
2312        let (mut world, cli) = test_world();
2313        let hub = InteractionHub::new();
2314        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2315        let entity = build_agent(
2316            world.world_mut(),
2317            cli.as_ref(),
2318            &Config::default(),
2319            mcp,
2320            &[],
2321            &hub,
2322            &spawn_args(&manifest.to_string_lossy()),
2323            100,
2324            sub_tx(),
2325        )
2326        .expect("spawn succeeds");
2327
2328        assert!(
2329            world
2330                .world()
2331                .get::<leviath_runtime::pipeline::DynamicTools>(entity)
2332                .is_some(),
2333            "dynamic_tools agent must carry the DynamicTools marker"
2334        );
2335        // The dynamic context is wired: refresh_tools returns Some for stage 0.
2336        assert!(
2337            leviath_runtime::pipeline::ToolService::refresh_tools(cli.as_ref(), entity, 0)
2338                .is_some()
2339        );
2340    }
2341
2342    #[tokio::test]
2343    async fn build_agent_applies_yolo_allow_and_max_depth() {
2344        let dir = tempfile::tempdir().unwrap();
2345        let manifest = dir.path().join("agent.leviath");
2346        std::fs::write(&manifest, coder_manifest()).unwrap();
2347
2348        let (mut world, cli) = test_world();
2349        let hub = InteractionHub::new();
2350        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2351        // The user's config denies read_file. Neither `--yolo` nor an explicit
2352        // `--allow read_file` lifts that: a deny rule is a decision, and skipping
2353        // *prompts* is all `--yolo` is for.
2354        let config = Config {
2355            tool_permissions: HashMap::from([(
2356                "read_file".to_string(),
2357                crate::config::ToolPolicy::Deny,
2358            )]),
2359            ..Default::default()
2360        };
2361        let mut args = spawn_args(&manifest.to_string_lossy());
2362        args.yolo = true;
2363        args.allow = vec!["read_file".to_string()];
2364        args.max_depth = Some(7);
2365
2366        let entity = build_agent(
2367            world.world_mut(),
2368            cli.as_ref(),
2369            &config,
2370            mcp,
2371            &[],
2372            &hub,
2373            &args,
2374            100,
2375            sub_tx(),
2376        )
2377        .expect("spawn succeeds");
2378        assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2379
2380        // The config deny stands: read_file is refused, not executed.
2381        let out = leviath_runtime::pipeline::ToolService::exec_for(
2382            cli.as_ref(),
2383            entity,
2384            vec![leviath_providers::ToolCall {
2385                id: "c1".to_string(),
2386                name: "read_file".to_string(),
2387                arguments: serde_json::json!({"path": "/no/such/file"}),
2388                thought_signature: None,
2389            }],
2390            leviath_runtime::pipeline::noop_progress(),
2391        )()
2392        .await;
2393        let result = out[0].1.clone();
2394        assert!(
2395            result.contains("[denied]"),
2396            "a configured deny must survive --yolo, got: {result}"
2397        );
2398
2399        // `--yolo` still does its job for a tool the config did not deny:
2400        // `list_dir` runs unattended with no approval prompt.
2401        let out = leviath_runtime::pipeline::ToolService::exec_for(
2402            cli.as_ref(),
2403            entity,
2404            vec![leviath_providers::ToolCall {
2405                id: "c2".to_string(),
2406                name: "list_dir".to_string(),
2407                arguments: serde_json::json!({"path": "."}),
2408                thought_signature: None,
2409            }],
2410            leviath_runtime::pipeline::noop_progress(),
2411        )()
2412        .await;
2413        let result = out[0].1.clone();
2414        assert!(
2415            !result.contains("[denied]"),
2416            "--yolo must still waive approval where nothing denies, got: {result}"
2417        );
2418    }
2419
2420    #[tokio::test]
2421    async fn build_agent_honors_agent_level_tool_permissions() {
2422        let dir = tempfile::tempdir().unwrap();
2423        let manifest = dir.path().join("agent.leviath");
2424        // A top-level `[tool_permissions]` block denying a builtin - no stage
2425        // perms, no launch overrides, no global config deny. Only the agent-level
2426        // layer can produce the deny, so this proves it is wired through.
2427        std::fs::write(
2428            &manifest,
2429            "[agent]\nname = \"perm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2430             [tool_permissions]\nread_file = \"deny\"\n\n\
2431             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2432        )
2433        .unwrap();
2434
2435        let (mut world, cli) = test_world();
2436        let hub = InteractionHub::new();
2437        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2438        let entity = build_agent(
2439            world.world_mut(),
2440            cli.as_ref(),
2441            &Config::default(),
2442            mcp,
2443            &[],
2444            &hub,
2445            &spawn_args(&manifest.to_string_lossy()),
2446            100,
2447            sub_tx(),
2448        )
2449        .expect("spawn succeeds");
2450
2451        let out = leviath_runtime::pipeline::ToolService::exec_for(
2452            cli.as_ref(),
2453            entity,
2454            vec![leviath_providers::ToolCall {
2455                id: "c1".to_string(),
2456                name: "read_file".to_string(),
2457                arguments: serde_json::json!({"path": "/no/such/file"}),
2458                thought_signature: None,
2459            }],
2460            leviath_runtime::pipeline::noop_progress(),
2461        )()
2462        .await;
2463        assert!(
2464            out[0].1.contains("[denied]"),
2465            "agent-level deny should block read_file"
2466        );
2467    }
2468
2469    #[tokio::test]
2470    async fn build_agent_script_host_honors_agent_level_grants() {
2471        let dir = tempfile::tempdir().unwrap();
2472        let manifest = dir.path().join("agent.leviath");
2473        std::fs::write(
2474            &manifest,
2475            "[agent]\nname = \"scriptperm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2476             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2477        )
2478        .unwrap();
2479
2480        // `write_file` defaults to Ask, and a script-permission `Inherit`
2481        // permits the host function only on a hard Allow. The grant below
2482        // lives solely in the user's per-agent block, so the script host can
2483        // only see it through the agent-scoped ceiling - the raw global
2484        // `[tool_permissions]` map is empty here.
2485        let mut config = Config::default();
2486        config.agent_tool_permissions.insert(
2487            "scriptperm".to_string(),
2488            HashMap::from([("write_file".to_string(), crate::config::ToolPolicy::Allow)]),
2489        );
2490
2491        let (mut world, cli) = test_world();
2492        let hub = InteractionHub::new();
2493        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2494        let mut args = spawn_args(&manifest.to_string_lossy());
2495        args.workdir = dir.path().to_string_lossy().to_string();
2496        let entity = build_agent(
2497            world.world_mut(),
2498            cli.as_ref(),
2499            &config,
2500            mcp,
2501            &[],
2502            &hub,
2503            &args,
2504            100,
2505            sub_tx(),
2506        )
2507        .expect("spawn succeeds");
2508
2509        let state = cli.take(entity).expect("tool state registered at spawn");
2510        state
2511            .script_host
2512            .write_file("granted.txt", "ok")
2513            .expect("agent-level write_file grant must reach the script host");
2514        assert_eq!(
2515            std::fs::read_to_string(dir.path().join("granted.txt")).unwrap(),
2516            "ok"
2517        );
2518    }
2519
2520    #[tokio::test]
2521    async fn build_agent_applies_default_max_iterations_only_when_stage_omits_it() {
2522        let dir = tempfile::tempdir().unwrap();
2523        let manifest = dir.path().join("agent.leviath");
2524        // Two stages: one omits max_iterations, one sets it explicitly to 3.
2525        std::fs::write(
2526            &manifest,
2527            "[agent]\nname = \"iters\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2528             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
2529             [stages.capped]\nmax_iterations = 3\n\
2530             model = { provider = \"anthropic\", model = \"m\" }\n",
2531        )
2532        .unwrap();
2533
2534        let (mut world, cli) = test_world();
2535        let hub = InteractionHub::new();
2536        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2537        // A non-default cap so the assertion can't accidentally match the built-in.
2538        let config = Config {
2539            limits: crate::config::LimitsConfig {
2540                default_max_iterations: Some(42),
2541                ..Default::default()
2542            },
2543            ..Default::default()
2544        };
2545        let entity = build_agent(
2546            world.world_mut(),
2547            cli.as_ref(),
2548            &config,
2549            mcp,
2550            &[],
2551            &hub,
2552            &spawn_args(&manifest.to_string_lossy()),
2553            100,
2554            sub_tx(),
2555        )
2556        .expect("spawn succeeds");
2557
2558        let bp = world
2559            .world()
2560            .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2561            .expect("blueprint");
2562        let by_name = |n: &str| {
2563            bp.0.stages
2564                .iter()
2565                .find(|s| s.name == n)
2566                .unwrap()
2567                .max_iterations
2568        };
2569        // The stage that omitted it inherits the config default …
2570        assert_eq!(by_name("main"), Some(42));
2571        // … while an explicit per-stage cap is left untouched.
2572        assert_eq!(by_name("capped"), Some(3));
2573    }
2574
2575    #[tokio::test]
2576    async fn build_agent_leaves_max_iterations_unset_when_config_default_is_none() {
2577        let dir = tempfile::tempdir().unwrap();
2578        let manifest = dir.path().join("agent.leviath");
2579        std::fs::write(
2580            &manifest,
2581            "[agent]\nname = \"nolimit\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2582             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2583        )
2584        .unwrap();
2585
2586        let (mut world, cli) = test_world();
2587        let hub = InteractionHub::new();
2588        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2589        // `None` disables the config default entirely - the stage stays uncapped.
2590        let config = Config {
2591            limits: crate::config::LimitsConfig {
2592                default_max_iterations: None,
2593                ..Default::default()
2594            },
2595            ..Default::default()
2596        };
2597        let entity = build_agent(
2598            world.world_mut(),
2599            cli.as_ref(),
2600            &config,
2601            mcp,
2602            &[],
2603            &hub,
2604            &spawn_args(&manifest.to_string_lossy()),
2605            100,
2606            sub_tx(),
2607        )
2608        .expect("spawn succeeds");
2609
2610        let bp = world
2611            .world()
2612            .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2613            .expect("blueprint");
2614        assert_eq!(bp.0.stages[0].max_iterations, None);
2615    }
2616
2617    #[tokio::test]
2618    async fn fake_provider_methods_are_exercised() {
2619        let p = FakeProvider;
2620        assert_eq!(p.name(), "fake");
2621        assert_eq!(p.count_tokens("t", "m").await, 1);
2622        assert_eq!(p.max_context_tokens("m"), 1000);
2623        let _ = p.capabilities("m");
2624        assert!(
2625            p.infer(leviath_providers::InferenceRequest {
2626                system: vec![],
2627                messages: vec![],
2628                model: "m".to_string(),
2629                max_tokens: 1,
2630                temperature: 0.0,
2631                tools: vec![],
2632                extra: serde_json::Value::Null,
2633                request_timeout_secs: None,
2634            })
2635            .await
2636            .is_err()
2637        );
2638    }
2639
2640    // ── [read_paths] policy resolution ────────────────────────────────────
2641
2642    use std::path::Path;
2643
2644    fn blueprint_declaring(read_paths: &[&str]) -> Blueprint {
2645        let stage = leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
2646        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
2647        let mut bp = Blueprint::new("cto".to_string(), "d".to_string(), vec![stage], layout);
2648        if !read_paths.is_empty() {
2649            bp.read_paths = Some(leviath_core::ReadPathsConfig {
2650                allow: read_paths.iter().map(|s| s.to_string()).collect(),
2651            });
2652        }
2653        bp
2654    }
2655
2656    /// The counts recorded on the run for `lev ps`. A blueprint that declares
2657    /// nothing has nothing to count, and a grant list that will not compile is
2658    /// a hard spawn error a line earlier - neither leaves a half-answer behind.
2659    #[test]
2660    fn read_path_grant_counts_are_recorded_for_a_declaring_blueprint() {
2661        let bp = blueprint_declaring(&["/data/runs", "/data/docs"]);
2662        let mut config = Config::default();
2663        config.security.read_paths = vec!["/data/runs".to_string()];
2664        let counts = read_path_grant_counts(&bp, &config, Path::new("/w")).expect("declares paths");
2665        assert_eq!(counts.declared, 2);
2666        assert_eq!(counts.granted, 1);
2667
2668        assert!(
2669            read_path_grant_counts(&blueprint_declaring(&[]), &config, Path::new("/w")).is_none()
2670        );
2671
2672        let mut broken = Config::default();
2673        broken.security.read_paths = vec!["regex:relative/.*".to_string()];
2674        assert!(read_path_grant_counts(&bp, &broken, Path::new("/w")).is_none());
2675    }
2676
2677    #[test]
2678    fn read_path_policy_is_inactive_without_declarations() {
2679        let bp = blueprint_declaring(&[]);
2680        let (policy, warning) =
2681            build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap();
2682        assert!(!policy.is_active());
2683        assert!(warning.is_none());
2684
2685        // An explicitly empty `[read_paths]` block is the same as none.
2686        let mut bp = blueprint_declaring(&[]);
2687        bp.read_paths = Some(leviath_core::ReadPathsConfig { allow: vec![] });
2688        let (policy, warning) =
2689            build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap();
2690        assert!(!policy.is_active());
2691        assert!(warning.is_none());
2692    }
2693
2694    /// Declared but ungranted: the agent still spawns, and the warning names
2695    /// the agent and shows both config stanzas that would grant the paths.
2696    #[test]
2697    fn read_path_policy_warns_when_nothing_grants() {
2698        let bp = blueprint_declaring(&["/data/runs", "glob:/data/docs/**"]);
2699        let (policy, warning) =
2700            build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap();
2701        assert!(policy.is_active());
2702        assert!(!policy.allow_blueprint);
2703        assert!(policy.grants.is_empty());
2704        let warning = warning.expect("ungranted declarations must warn");
2705        assert!(warning.contains("allow_blueprint_read_paths"), "{warning}");
2706        assert!(warning.contains("[agent_read_paths.cto]"), "{warning}");
2707        assert!(warning.contains("\"/data/runs\""), "{warning}");
2708        assert!(warning.contains("\"glob:/data/docs/**\""), "{warning}");
2709    }
2710
2711    #[test]
2712    fn read_path_policy_is_quiet_when_granted() {
2713        let bp = blueprint_declaring(&["/data/runs"]);
2714        let mut config = Config::default();
2715        config.agent_read_paths.insert(
2716            "cto".to_string(),
2717            crate::config::ReadPathGrants {
2718                allow: vec!["/data/runs".to_string()],
2719            },
2720        );
2721        let (policy, warning) = build_read_path_policy(&bp, &config, Path::new("/w")).unwrap();
2722        assert!(policy.is_active());
2723        assert!(!policy.grants.is_empty());
2724        assert!(warning.is_none());
2725    }
2726
2727    #[test]
2728    fn read_path_policy_is_quiet_under_the_override() {
2729        let bp = blueprint_declaring(&["/data/runs"]);
2730        let mut config = Config::default();
2731        config.security.allow_blueprint_read_paths = true;
2732        let (policy, warning) = build_read_path_policy(&bp, &config, Path::new("/w")).unwrap();
2733        assert!(policy.allow_blueprint);
2734        assert!(warning.is_none());
2735    }
2736
2737    /// A malformed entry is a hard spawn error naming its source - the
2738    /// blueprint's section or the user's own grant list.
2739    #[test]
2740    fn read_path_policy_rejects_bad_entries_loudly() {
2741        let bp = blueprint_declaring(&["glob:["]);
2742        let err = build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap_err();
2743        assert!(err.contains("agent 'cto' [read_paths]"), "{err}");
2744
2745        let bp = blueprint_declaring(&["/data/runs"]);
2746        let mut config = Config::default();
2747        config.security.read_paths = vec!["regex:(".to_string()];
2748        let err = build_read_path_policy(&bp, &config, Path::new("/w")).unwrap_err();
2749        assert!(err.contains("config.toml"), "{err}");
2750    }
2751
2752    /// Granted read paths raise the read tools to `Private`; nothing else
2753    /// moves, and an ungranted or missing tool entry is left alone.
2754    #[test]
2755    fn read_sensitivities_bump_only_the_read_tools_when_granted() {
2756        use leviath_core::TaintLevel;
2757        let base = || {
2758            HashMap::from([
2759                ("read_file".to_string(), TaintLevel::Internal),
2760                ("list_dir".to_string(), TaintLevel::Public),
2761                ("write_file".to_string(), TaintLevel::Internal),
2762            ])
2763        };
2764
2765        let mut map = base();
2766        bump_read_sensitivities(&mut map, true);
2767        assert_eq!(map.get("read_file"), Some(&TaintLevel::Private));
2768        assert_eq!(map.get("list_dir"), Some(&TaintLevel::Private));
2769        assert_eq!(map.get("write_file"), Some(&TaintLevel::Internal));
2770        // `read_files` was absent from the map: no entry invented for it.
2771        assert!(!map.contains_key("read_files"));
2772
2773        let mut map = base();
2774        bump_read_sensitivities(&mut map, false);
2775        assert_eq!(map, base(), "no grant, no change");
2776    }
2777
2778    #[tokio::test]
2779    async fn build_agent_read_error() {
2780        let (mut world, cli) = test_world();
2781        let hub = InteractionHub::new();
2782        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2783        let err = build_agent(
2784            world.world_mut(),
2785            cli.as_ref(),
2786            &Config::default(),
2787            mcp,
2788            &[],
2789            &hub,
2790            &spawn_args("/no/such/manifest.leviath"),
2791            100,
2792            sub_tx(),
2793        )
2794        .unwrap_err();
2795        assert!(err.contains("read manifest"));
2796    }
2797
2798    /// A minimal single-stage manifest with a tiny task region and a `system_prompt`
2799    /// large enough to overflow it, so stage-0 setup fails in `spawn_agent`.
2800    const OVERSIZED_MANIFEST: &str = r#"
2801[agent]
2802name = "tiny"
2803version = "0.1.0"
2804description = "d"
2805entry_stage = "main"
2806
2807[context.regions]
2808task = { kind = "pinned", max_tokens = 20 }
2809
2810[stages.main]
2811mode = "autonomous"
2812model = { models = [{ provider = "anthropic", model = "m" }] }
2813description = "d"
2814available_tools = []
2815system_prompt = "SYSTEM_PROMPT_PLACEHOLDER"
2816"#;
2817
2818    #[tokio::test]
2819    async fn build_agent_propagates_spawn_error() {
2820        let dir = tempfile::tempdir().unwrap();
2821        let manifest = dir.path().join("tiny.leviath");
2822        // A huge prompt that cannot fit the 20-token "task" region.
2823        let content = OVERSIZED_MANIFEST.replace("SYSTEM_PROMPT_PLACEHOLDER", &"x ".repeat(5000));
2824        std::fs::write(&manifest, content).unwrap();
2825
2826        let (mut world, cli) = test_world();
2827        let hub = InteractionHub::new();
2828        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2829        let result = build_agent(
2830            world.world_mut(),
2831            cli.as_ref(),
2832            &Config::default(),
2833            mcp,
2834            &[],
2835            &hub,
2836            &spawn_args(&manifest.to_string_lossy()),
2837            100,
2838            sub_tx(),
2839        );
2840        assert!(result.is_err(), "expected spawn error, got {result:?}");
2841    }
2842
2843    #[tokio::test]
2844    async fn build_agent_refuses_a_manifest_with_no_usable_provider() {
2845        // The end-to-end shape of issue #190: this used to build an agent
2846        // pointed at a provider nothing answers to, which then sat at
2847        // iteration 0 for the life of the daemon.
2848        let dir = tempfile::tempdir().unwrap();
2849        let manifest = dir.path().join("ghostly.leviath");
2850        std::fs::write(
2851            &manifest,
2852            r#"
2853[agent]
2854name = "ghostly"
2855version = "0.1.0"
2856description = "d"
2857entry_stage = "main"
2858
2859[context.regions]
2860task = { kind = "pinned", max_tokens = 4000 }
2861
2862[stages.main]
2863mode = "autonomous"
2864model = { models = [{ provider = "ghost", model = "m" }], allow_user_default = false }
2865description = "d"
2866available_tools = []
2867"#,
2868        )
2869        .unwrap();
2870        let (mut world, cli) = test_world();
2871        let hub = InteractionHub::new();
2872        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2873        let err = build_agent(
2874            world.world_mut(),
2875            cli.as_ref(),
2876            &Config::default(),
2877            mcp,
2878            &[],
2879            &hub,
2880            &spawn_args(&manifest.to_string_lossy()),
2881            100,
2882            sub_tx(),
2883        )
2884        .unwrap_err();
2885        assert!(err.contains("main"), "names the stage: {err}");
2886        assert!(err.contains("ghost"), "names what it tried: {err}");
2887    }
2888
2889    #[tokio::test]
2890    async fn build_agent_invalid_blueprint() {
2891        let dir = tempfile::tempdir().unwrap();
2892        let manifest = dir.path().join("bad.leviath");
2893        // entry_stage names a stage that doesn't exist ⇒ validate() fails.
2894        std::fs::write(
2895            &manifest,
2896            r#"
2897[agent]
2898name = "bad"
2899version = "0.1.0"
2900description = "d"
2901entry_stage = "ghost"
2902
2903[context.regions]
2904task = { kind = "pinned", max_tokens = 4000 }
2905
2906[stages.main]
2907mode = "autonomous"
2908model = { models = [{ provider = "anthropic", model = "m" }] }
2909description = "d"
2910available_tools = []
2911"#,
2912        )
2913        .unwrap();
2914        let (mut world, cli) = test_world();
2915        let hub = InteractionHub::new();
2916        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2917        let err = build_agent(
2918            world.world_mut(),
2919            cli.as_ref(),
2920            &Config::default(),
2921            mcp,
2922            &[],
2923            &hub,
2924            &spawn_args(&manifest.to_string_lossy()),
2925            100,
2926            sub_tx(),
2927        )
2928        .unwrap_err();
2929        assert!(err.contains("invalid blueprint"));
2930    }
2931
2932    #[tokio::test]
2933    async fn build_agent_without_entry_stage_and_with_compaction() {
2934        let dir = tempfile::tempdir().unwrap();
2935        let manifest = dir.path().join("mini.leviath");
2936        // No entry_stage (falls back to the first stage) + a compaction section.
2937        std::fs::write(
2938            &manifest,
2939            r#"
2940[agent]
2941name = "mini"
2942version = "0.1.0"
2943description = "d"
2944
2945[compaction]
2946provider = "anthropic"
2947model = "claude-x"
2948
2949[context.regions]
2950task = { kind = "pinned", max_tokens = 4000 }
2951
2952[stages.main]
2953mode = "autonomous"
2954model = { models = [{ provider = "anthropic", model = "m" }] }
2955description = "d"
2956available_tools = []
2957system_prompt = "be brief"
2958"#,
2959        )
2960        .unwrap();
2961        let (mut world, cli) = test_world();
2962        let hub = InteractionHub::new();
2963        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2964        let entity = build_agent(
2965            world.world_mut(),
2966            cli.as_ref(),
2967            &Config::default(),
2968            mcp,
2969            &[],
2970            &hub,
2971            &spawn_args(&manifest.to_string_lossy()),
2972            100,
2973            sub_tx(),
2974        )
2975        .expect("spawn succeeds");
2976        assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2977        // Compaction settings were attached.
2978        assert!(world.world().get::<CompactionSettings>(entity).is_some());
2979    }
2980
2981    /// A manifest that returns `[agent] name` and `write` a `read_paths.leviath`
2982    /// declaring an out-of-workdir read. Used by the wiring tests below.
2983    fn write_read_paths_manifest(dir: &std::path::Path, allow: &str) -> std::path::PathBuf {
2984        let manifest = dir.join("reader.leviath");
2985        std::fs::write(
2986            &manifest,
2987            format!(
2988                r#"
2989[agent]
2990name = "reader"
2991version = "0.1.0"
2992description = "d"
2993
2994[read_paths]
2995allow = [{allow}]
2996
2997[context.regions]
2998task = {{ kind = "pinned", max_tokens = 4000 }}
2999
3000[stages.main]
3001mode = "autonomous"
3002model = {{ models = [{{ provider = "anthropic", model = "m" }}] }}
3003description = "d"
3004available_tools = []
3005system_prompt = "be brief"
3006"#
3007            ),
3008        )
3009        .unwrap();
3010        manifest
3011    }
3012
3013    /// A granted `[read_paths]` spawns cleanly, with taint on so the read-tool
3014    /// sensitivity bump path runs end to end.
3015    #[tokio::test]
3016    async fn build_agent_wires_granted_read_paths() {
3017        let dir = tempfile::tempdir().unwrap();
3018        let manifest = write_read_paths_manifest(dir.path(), "\"/tmp\"");
3019        let (mut world, cli) = test_world();
3020        let hub = InteractionHub::new();
3021        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3022        let mut config = Config::default();
3023        config.security.allow_blueprint_read_paths = true;
3024        config.taint_tracking = true;
3025        let entity = build_agent(
3026            world.world_mut(),
3027            cli.as_ref(),
3028            &config,
3029            mcp,
3030            &[],
3031            &hub,
3032            &spawn_args(&manifest.to_string_lossy()),
3033            100,
3034            sub_tx(),
3035        )
3036        .expect("spawn succeeds");
3037        assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
3038    }
3039
3040    /// A declared-but-ungranted `[read_paths]` still spawns; the warning-logging
3041    /// branch fires.
3042    #[tokio::test]
3043    async fn build_agent_wires_ungranted_read_paths() {
3044        let dir = tempfile::tempdir().unwrap();
3045        let manifest = write_read_paths_manifest(dir.path(), "\"/tmp\"");
3046        let (mut world, cli) = test_world();
3047        let hub = InteractionHub::new();
3048        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3049        let entity = build_agent(
3050            world.world_mut(),
3051            cli.as_ref(),
3052            &Config::default(),
3053            mcp,
3054            &[],
3055            &hub,
3056            &spawn_args(&manifest.to_string_lossy()),
3057            100,
3058            sub_tx(),
3059        )
3060        .expect("spawn succeeds even when nothing grants the declaration");
3061        assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
3062    }
3063
3064    /// A malformed grant entry in the user's own config fails the spawn - the
3065    /// error propagates out of `build_read_path_policy`.
3066    #[tokio::test]
3067    async fn build_agent_rejects_a_malformed_config_grant() {
3068        let dir = tempfile::tempdir().unwrap();
3069        let manifest = write_read_paths_manifest(dir.path(), "\"/tmp\"");
3070        let (mut world, cli) = test_world();
3071        let hub = InteractionHub::new();
3072        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3073        let mut config = Config::default();
3074        config.security.read_paths = vec!["glob:[".to_string()];
3075        let err = build_agent(
3076            world.world_mut(),
3077            cli.as_ref(),
3078            &config,
3079            mcp,
3080            &[],
3081            &hub,
3082            &spawn_args(&manifest.to_string_lossy()),
3083            100,
3084            sub_tx(),
3085        )
3086        .expect_err("a broken config grant must fail the spawn");
3087        assert!(err.contains("config.toml"), "{err}");
3088    }
3089
3090    #[tokio::test]
3091    async fn build_agent_parse_error() {
3092        let dir = tempfile::tempdir().unwrap();
3093        let manifest = dir.path().join("bad.leviath");
3094        std::fs::write(&manifest, "this is not valid toml : : :").unwrap();
3095        let (mut world, cli) = test_world();
3096        let hub = InteractionHub::new();
3097        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3098        let err = build_agent(
3099            world.world_mut(),
3100            cli.as_ref(),
3101            &Config::default(),
3102            mcp,
3103            &[],
3104            &hub,
3105            &spawn_args(&manifest.to_string_lossy()),
3106            100,
3107            sub_tx(),
3108        )
3109        .unwrap_err();
3110        assert!(err.contains("parse manifest"));
3111    }
3112
3113    // ─── resolve_seeds ────────────────────────────────────────────────────────
3114
3115    fn bp(regions_toml: &str) -> Blueprint {
3116        let toml = format!(
3117            r#"
3118[agent]
3119name = "seedy"
3120
3121[stages.main]
3122mode = "autonomous"
3123
3124[stages.main.model]
3125provider = "anthropic"
3126model = "claude-sonnet-5"
3127
3128[context.regions]
3129{regions_toml}
3130conversation = {{ kind = "sliding_window", max_items = 20, max_tokens = 10000 }}
3131"#
3132        );
3133        leviath_core::manifest::parse_manifest(&toml).unwrap()
3134    }
3135
3136    fn args_with(task: &str, regions: HashMap<String, String>, workdir: &str) -> SpawnArgs {
3137        SpawnArgs {
3138            run_id: "r".to_string(),
3139            blueprint_path: "/bp".to_string(),
3140            task: task.to_string(),
3141            regions,
3142            model: None,
3143            workdir: workdir.to_string(),
3144            metadata: HashMap::new(),
3145            callback_url: None,
3146            callback_secret: None,
3147            yolo: false,
3148            no_seed_commands: false,
3149            allow: Vec::new(),
3150            max_depth: None,
3151            parent_run_id: None,
3152        }
3153    }
3154
3155    /// The default policy for the non-command seed tests: command seeds off, so
3156    /// nothing is ever executed by a test that isn't about command seeds.
3157    fn seed_policy() -> SeedCommandPolicy {
3158        SeedCommandPolicy::disabled()
3159    }
3160
3161    /// A policy whose runner is a stub returning `result`, for the command-seed
3162    /// arms (no real process, deterministic on every platform).
3163    fn stub_policy(result: Result<String, String>) -> SeedCommandPolicy {
3164        SeedCommandPolicy {
3165            allowed: true,
3166            timeout: std::time::Duration::from_secs(1),
3167            runner: std::sync::Arc::new(move |_, _, _| result.clone()),
3168        }
3169    }
3170
3171    #[test]
3172    fn resolve_seeds_fills_task_and_caller_input() {
3173        let bp = bp(
3174            r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
3175criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }"#,
3176        );
3177        let args = args_with(
3178            "build it",
3179            HashMap::from([("criteria".to_string(), "be safe".to_string())]),
3180            "/tmp",
3181        );
3182        let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
3183        assert_eq!(seeds.get("task").map(String::as_str), Some("build it"));
3184        assert_eq!(seeds.get("criteria").map(String::as_str), Some("be safe"));
3185    }
3186
3187    #[test]
3188    fn resolve_seeds_required_caller_input_missing_is_error() {
3189        let bp =
3190            bp(r#"spec = { kind = "pinned", max_tokens = 2000, seed = "input", required = true }"#);
3191        let args = args_with("t", HashMap::new(), "/tmp");
3192        let err = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap_err();
3193        assert!(err.contains("spec"), "got: {err}");
3194    }
3195
3196    #[test]
3197    fn resolve_seeds_optional_caller_input_missing_is_omitted() {
3198        let bp = bp(r#"notes = { kind = "pinned", max_tokens = 2000, seed = "input" }"#);
3199        let args = args_with("t", HashMap::new(), "/tmp");
3200        let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
3201        assert!(!seeds.contains_key("notes"));
3202    }
3203
3204    #[test]
3205    fn resolve_seeds_literal_and_files() {
3206        let dir = tempfile::tempdir().unwrap();
3207        std::fs::write(dir.path().join("a.txt"), "alpha").unwrap();
3208        std::fs::write(dir.path().join("b.txt"), "beta").unwrap();
3209        let bp = bp(
3210            r#"lit = { kind = "pinned", max_tokens = 500, seed = { literal = "hello" } }
3211docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["a.txt", "b.txt"] } }"#,
3212        );
3213        let args = args_with("t", HashMap::new(), &dir.path().to_string_lossy());
3214        let seeds =
3215            resolve_seeds(&bp, &args, &dir.path().to_string_lossy(), &seed_policy()).unwrap();
3216        assert_eq!(seeds.get("lit").map(String::as_str), Some("hello"));
3217        let docs = seeds.get("docs").unwrap();
3218        assert!(docs.contains("alpha") && docs.contains("beta"));
3219    }
3220
3221    #[test]
3222    fn resolve_seeds_glob_concatenates_matches() {
3223        let dir = tempfile::tempdir().unwrap();
3224        std::fs::create_dir(dir.path().join("specs")).unwrap();
3225        std::fs::write(dir.path().join("specs/one.md"), "spec one").unwrap();
3226        std::fs::write(dir.path().join("specs/two.md"), "spec two").unwrap();
3227        let bp =
3228            bp(r#"specs = { kind = "pinned", max_tokens = 4000, seed = { glob = "specs/*.md" } }"#);
3229        let wd = dir.path().to_string_lossy().to_string();
3230        let args = args_with("t", HashMap::new(), &wd);
3231        let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap();
3232        let specs = seeds.get("specs").unwrap();
3233        assert!(specs.contains("spec one") && specs.contains("spec two"));
3234    }
3235
3236    #[test]
3237    fn resolve_seeds_rhai_runs_script() {
3238        let dir = tempfile::tempdir().unwrap();
3239        // A script that returns the task text uppercased-ish via concatenation.
3240        std::fs::write(
3241            dir.path().join("init.rhai"),
3242            r#""seeded: " + input["task"]"#,
3243        )
3244        .unwrap();
3245        let bp = bp(
3246            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "init.rhai" } }"#,
3247        );
3248        let wd = dir.path().to_string_lossy().to_string();
3249        let args = args_with("hello", HashMap::new(), &wd);
3250        let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap();
3251        assert_eq!(
3252            seeds.get("scripted").map(String::as_str),
3253            Some("seeded: hello")
3254        );
3255    }
3256
3257    #[test]
3258    fn resolve_seeds_files_required_missing_errors_optional_skips() {
3259        let dir = tempfile::tempdir().unwrap();
3260        let wd = dir.path().to_string_lossy().to_string();
3261        // Required + a missing file → error.
3262        let req = bp(
3263            r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] }, required = true }"#,
3264        );
3265        let args = args_with("t", HashMap::new(), &wd);
3266        let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
3267        assert!(err.contains("missing.txt"), "got: {err}");
3268        // Optional + a missing file → the region is simply omitted.
3269        let opt = bp(
3270            r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] } }"#,
3271        );
3272        let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
3273        assert!(!seeds.contains_key("docs"));
3274    }
3275
3276    #[test]
3277    fn resolve_seeds_glob_no_match_required_errors_optional_skips() {
3278        let dir = tempfile::tempdir().unwrap();
3279        let wd = dir.path().to_string_lossy().to_string();
3280        let args = args_with("t", HashMap::new(), &wd);
3281        // Required glob with no matches → error.
3282        let req = bp(
3283            r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" }, required = true }"#,
3284        );
3285        let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
3286        assert!(err.contains("matched no files"), "got: {err}");
3287        // Optional glob with no matches → region omitted.
3288        let opt =
3289            bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" } }"#);
3290        let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
3291        assert!(!seeds.contains_key("specs"));
3292    }
3293
3294    #[test]
3295    fn resolve_seeds_bad_glob_pattern_errors() {
3296        // An unclosed `[` is an invalid glob pattern → `glob::glob` returns Err.
3297        let dir = tempfile::tempdir().unwrap();
3298        let wd = dir.path().to_string_lossy().to_string();
3299        let bp = bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "[" } }"#);
3300        let args = args_with("t", HashMap::new(), &wd);
3301        let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
3302        assert!(err.contains("bad glob"), "got: {err}");
3303    }
3304
3305    #[test]
3306    fn resolve_seeds_rhai_script_error() {
3307        let dir = tempfile::tempdir().unwrap();
3308        // A script that calls an undefined function → runtime error.
3309        std::fs::write(dir.path().join("boom.rhai"), "undefined_func()").unwrap();
3310        let wd = dir.path().to_string_lossy().to_string();
3311        let bp = bp(
3312            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "boom.rhai" } }"#,
3313        );
3314        let args = args_with("t", HashMap::new(), &wd);
3315        let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
3316        assert!(err.contains("rhai seed failed"), "got: {err}");
3317    }
3318
3319    // ─── command seeds (issue #108) ──────────────────────────────────────────
3320
3321    /// A blueprint with one command-seeded region, optionally `required`.
3322    fn command_bp(required: bool) -> leviath_core::Blueprint {
3323        let req = if required { ", required = true" } else { "" };
3324        bp(&format!(
3325            r#"facts = {{ kind = "pinned", max_tokens = 500, seed = {{ command = "scan-repo" }}{req} }}"#
3326        ))
3327    }
3328
3329    #[test]
3330    fn resolve_seeds_command_stores_output() {
3331        let bp = command_bp(false);
3332        let args = args_with("t", HashMap::new(), "/tmp");
3333        let seeds = resolve_seeds(
3334            &bp,
3335            &args,
3336            "/tmp",
3337            &stub_policy(Ok("src/lib.rs\nsrc/main.rs".to_string())),
3338        )
3339        .unwrap();
3340        assert_eq!(
3341            seeds.get("facts").map(String::as_str),
3342            Some("src/lib.rs\nsrc/main.rs")
3343        );
3344    }
3345
3346    #[test]
3347    fn resolve_seeds_command_receives_the_workdir_and_command() {
3348        // The declared command and the run's workdir reach the runner verbatim.
3349        let bp = command_bp(false);
3350        let args = args_with("t", HashMap::new(), "/work");
3351        let policy = SeedCommandPolicy {
3352            allowed: true,
3353            timeout: std::time::Duration::from_secs(9),
3354            runner: std::sync::Arc::new(|command, workdir, timeout| {
3355                Ok(format!(
3356                    "{command}@{}#{}",
3357                    workdir.display(),
3358                    timeout.as_secs()
3359                ))
3360            }),
3361        };
3362        let seeds = resolve_seeds(&bp, &args, "/work", &policy).unwrap();
3363        assert_eq!(
3364            seeds.get("facts").map(String::as_str),
3365            Some("scan-repo@/work#9")
3366        );
3367    }
3368
3369    #[test]
3370    fn resolve_seeds_command_failure_is_skipped_when_optional() {
3371        let bp = command_bp(false);
3372        let args = args_with("t", HashMap::new(), "/tmp");
3373        let seeds = resolve_seeds(
3374            &bp,
3375            &args,
3376            "/tmp",
3377            &stub_policy(Err("timed out".to_string())),
3378        )
3379        .unwrap();
3380        assert!(
3381            !seeds.contains_key("facts"),
3382            "an optional command seed must not sink the spawn"
3383        );
3384    }
3385
3386    #[test]
3387    fn resolve_seeds_command_failure_errors_when_required() {
3388        let bp = command_bp(true);
3389        let args = args_with("t", HashMap::new(), "/tmp");
3390        let err =
3391            resolve_seeds(&bp, &args, "/tmp", &stub_policy(Err("boom".to_string()))).unwrap_err();
3392        assert!(err.contains("scan-repo"), "got: {err}");
3393        assert!(err.contains("boom"), "got: {err}");
3394    }
3395
3396    #[test]
3397    fn resolve_seeds_command_empty_output_is_skipped_when_optional() {
3398        let bp = command_bp(false);
3399        let args = args_with("t", HashMap::new(), "/tmp");
3400        let seeds =
3401            resolve_seeds(&bp, &args, "/tmp", &stub_policy(Ok("   \n".to_string()))).unwrap();
3402        assert!(!seeds.contains_key("facts"));
3403    }
3404
3405    #[test]
3406    fn resolve_seeds_command_empty_output_errors_when_required() {
3407        let bp = command_bp(true);
3408        let args = args_with("t", HashMap::new(), "/tmp");
3409        let err = resolve_seeds(&bp, &args, "/tmp", &stub_policy(Ok(String::new()))).unwrap_err();
3410        assert!(err.contains("returned empty"), "got: {err}");
3411    }
3412
3413    #[test]
3414    fn resolve_seeds_command_skipped_when_disabled() {
3415        // `[security] allow_seed_commands = false` / `--no-seed-commands`: the
3416        // runner is never consulted. The stub would have produced content, so an
3417        // empty region proves the seed was skipped rather than merely failing.
3418        let bp = command_bp(false);
3419        let args = args_with("t", HashMap::new(), "/tmp");
3420        let mut policy = stub_policy(Ok("SHOULD NOT BE USED".to_string()));
3421        policy.allowed = false;
3422        let seeds = resolve_seeds(&bp, &args, "/tmp", &policy).unwrap();
3423        assert!(!seeds.contains_key("facts"));
3424    }
3425
3426    #[test]
3427    fn resolve_seeds_required_command_errors_when_disabled() {
3428        // A required region can't be silently left empty - the run stops with a
3429        // message naming the switch that turned command seeds off.
3430        let bp = command_bp(true);
3431        let args = args_with("t", HashMap::new(), "/tmp");
3432        let err = resolve_seeds(&bp, &args, "/tmp", &SeedCommandPolicy::disabled()).unwrap_err();
3433        assert!(err.contains("allow_seed_commands"), "got: {err}");
3434    }
3435
3436    #[test]
3437    fn resolve_seeds_glob_matching_directory_required_errors() {
3438        // A required glob that matches a directory entry → reading it as a file
3439        // fails, so read_and_concat returns Err and resolve_seeds propagates it.
3440        let dir = tempfile::tempdir().unwrap();
3441        std::fs::create_dir(dir.path().join("subdir")).unwrap();
3442        let wd = dir.path().to_string_lossy().to_string();
3443        let bp = bp(
3444            r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "sub*" }, required = true }"#,
3445        );
3446        let args = args_with("t", HashMap::new(), &wd);
3447        let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
3448        assert!(err.contains("read seed file"), "got: {err}");
3449    }
3450
3451    #[test]
3452    fn resolve_seeds_rhai_read_error() {
3453        let dir = tempfile::tempdir().unwrap();
3454        let wd = dir.path().to_string_lossy().to_string();
3455        let bp = bp(
3456            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "nope.rhai" } }"#,
3457        );
3458        let args = args_with("t", HashMap::new(), &wd);
3459        let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
3460        assert!(err.contains("read rhai seed"), "got: {err}");
3461    }
3462
3463    #[test]
3464    fn resolve_seeds_rhai_empty_required_errors_optional_skips() {
3465        let dir = tempfile::tempdir().unwrap();
3466        // A script returning an empty string.
3467        std::fs::write(dir.path().join("empty.rhai"), r#""""#).unwrap();
3468        let wd = dir.path().to_string_lossy().to_string();
3469        let args = args_with("t", HashMap::new(), &wd);
3470        let req = bp(
3471            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" }, required = true }"#,
3472        );
3473        let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
3474        assert!(err.contains("returned empty"), "got: {err}");
3475        // Optional + empty → region omitted (no error).
3476        let opt = bp(
3477            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" } }"#,
3478        );
3479        let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
3480        assert!(!seeds.contains_key("scripted"));
3481    }
3482
3483    #[test]
3484    fn resolve_seeds_tolerates_unknown_caller_region() {
3485        // Unknown caller keys are silently unused (CLI validates client-side;
3486        // ACP stray markers must not fail the spawn).
3487        let bp = bp(r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }"#);
3488        let args = args_with(
3489            "t",
3490            HashMap::from([("ghost".to_string(), "x".to_string())]),
3491            "/tmp",
3492        );
3493        let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
3494        assert_eq!(seeds.get("task").map(String::as_str), Some("t"));
3495        assert!(!seeds.contains_key("ghost"));
3496    }
3497}