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