Skip to main content

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