Skip to main content

leviath_cli/lint/
mod.rs

1//! Blueprint lint: the checks [`Blueprint::validate`] deliberately does not make.
2//!
3//! `Blueprint::validate` answers "is this manifest structurally coherent" - the
4//! layout fits, the graph resolves, fan-out wiring points at real stages. It
5//! says nothing about the fields whose *absence* quietly changes what a run
6//! does, and those are what actually bite:
7//!
8//! - a stage with no `[stages.<name>.model]` table parses fine, because the
9//!   parser substitutes a default, and then runs on whatever the user's default
10//!   provider happens to be
11//! - an agent-level `[model]` block is never read at all, so the author's model
12//!   choice is discarded silently
13//! - a typo in `available_tools` matches nothing, and the stage just advertises
14//!   one tool fewer - the model is told the tool does not exist
15//! - an autonomous stage granting `ask_user_text` parks in `WaitingInput` the
16//!   first time it asks, with nobody there to answer
17//!
18//! Each of those is invisible on inspection and shows up hours later as a stuck
19//! run. This module names them at author time instead.
20//!
21//! Questions about what the author *declared* ("is there a `mode` key?") are
22//! answered from the manifest text, not from the parsed [`Blueprint`]: by then
23//! the parser has already filled in its defaults, and asking the struct cannot
24//! tell "wrote `autonomous`" apart from "wrote nothing".
25//!
26//! [`Blueprint::validate`]: leviath_core::Blueprint::validate
27
28use std::collections::{HashMap, HashSet};
29use std::path::Path;
30
31use leviath_core::Blueprint;
32use leviath_core::blueprint::StageMode;
33use leviath_runtime::dynamic_interaction::BLOCKING_INTERACTION_TOOLS;
34use leviath_tools::canonical_tool_name;
35use serde::{Deserialize, Serialize};
36
37/// How much a finding matters. Only [`LintSeverity::Error`] fails
38/// `lev validate`; warnings are printed and the command still exits zero
39/// (unless `--deny-warnings` is passed); notes never fail anything.
40///
41/// Declared worst-first so sorting by it groups the report.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum LintSeverity {
45    /// The manifest says something that cannot be what the author meant - a
46    /// tool name matching nothing, a permission for a tool the stage never
47    /// granted.
48    Error,
49    /// The manifest leaves a decision to a default the author may not know
50    /// about.
51    Warning,
52    /// Nothing is wrong; the blueprint is doing something worth knowing before
53    /// you run it, like reaching outside its workdir or running a shell command
54    /// at spawn. A note must never fail a build, so `--deny-warnings` skips it.
55    Note,
56}
57
58impl LintSeverity {
59    /// Fixed-width label for the report, so the messages line up.
60    pub fn label(self) -> &'static str {
61        match self {
62            Self::Error => "ERR ",
63            Self::Warning => "WARN",
64            Self::Note => "NOTE",
65        }
66    }
67}
68
69/// One thing worth telling the author about.
70///
71/// Serialize only: `code` is a `&'static str` pointing at a literal in this
72/// file, which no deserializer can produce.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74pub struct LintFinding {
75    /// How much this matters, and therefore whether it fails the check.
76    pub severity: LintSeverity,
77    /// Stable slug (`"unknown-tool"`), so a finding can be referenced in an
78    /// issue or grepped for in daemon logs without quoting prose.
79    pub code: &'static str,
80    /// The stage it belongs to, when it belongs to one.
81    pub stage: Option<String>,
82    /// What is wrong.
83    pub message: String,
84    /// What to do about it. Rendered on its own indented line.
85    pub fix: Option<String>,
86}
87
88impl LintFinding {
89    fn new(severity: LintSeverity, code: &'static str, message: String) -> Self {
90        Self {
91            severity,
92            code,
93            stage: None,
94            message,
95            fix: None,
96        }
97    }
98
99    fn in_stage(mut self, stage: &str) -> Self {
100        self.stage = Some(stage.to_string());
101        self
102    }
103
104    fn with_fix(mut self, fix: impl Into<String>) -> Self {
105        self.fix = Some(fix.into());
106        self
107    }
108
109    /// Whether this finding should fail the command.
110    pub fn is_error(&self) -> bool {
111        self.severity == LintSeverity::Error
112    }
113
114    /// One-line rendering for a log record: `stage 'x': message`.
115    pub fn one_line(&self) -> String {
116        match &self.stage {
117            Some(stage) => format!("stage '{stage}': {}", self.message),
118            None => self.message.clone(),
119        }
120    }
121}
122
123/// Facts about the machine the blueprint will run on, which the manifest alone
124/// cannot supply.
125///
126/// Every field is "unknown" when empty/`None`, and an unknown field skips its
127/// check entirely rather than guessing. A linter that cannot see the installed
128/// MCP servers must not claim their tools do not exist.
129#[derive(Debug, Default, Clone)]
130pub struct LintEnv {
131    /// Every tool name a manifest may legally write: canonical built-ins, their
132    /// aliases, the sub-agent tools, this agent's own `tools/*.rhai`, and any
133    /// MCP tools already resolved. Empty skips the unknown-tool check.
134    pub known_tools: HashSet<String>,
135
136    /// `(provider, model)` rows for providers whose catalog is closed enough to
137    /// check against. A provider with no row here is not checked at all, which
138    /// is what keeps open catalogs (Ollama, OpenRouter, script providers) from
139    /// producing noise.
140    pub known_models: Vec<(String, String)>,
141
142    /// The providers the blueprint names that this install can actually reach,
143    /// as answered by `ProviderRegistry::has`. `None` means nobody asked, so
144    /// the check is skipped. Resolution lives with the caller because script
145    /// providers are loaded on demand and cannot be enumerated up front.
146    pub available_providers: Option<HashSet<String>>,
147
148    /// Which of the blueprint's `[read_paths]` this install's config grants.
149    /// `None` means nobody asked (the daemon's offline lint), in which case the
150    /// check only says that a declaration needs granting. `Some(Err(..))` is a
151    /// grant list of the user's own that will not compile.
152    pub read_paths: Option<Result<crate::read_path_report::GrantReport, String>>,
153
154    /// Whether this install's config honours the blueprint's own
155    /// `[safe_commands]`. `None` means nobody asked (the daemon's offline
156    /// lint), in which case the check only says the declaration needs granting.
157    ///
158    /// A bool rather than a report: unlike read paths, where *which* entries are
159    /// granted is the interesting part, a safe-commands block is honoured whole
160    /// or not at all.
161    pub safe_commands_granted: Option<bool>,
162
163    /// Context-window size per `(provider, model)`, for the models this build
164    /// ships a capability row for.
165    ///
166    /// Only needed to say what a percentage budget *resolves to*: "38%" is not
167    /// alarming until you know the denominator is a million. Empty skips the
168    /// unbounded-percentage check, because a warning that cannot name a number
169    /// is a warning nobody acts on.
170    pub model_windows: HashMap<(String, String), usize>,
171}
172
173impl LintEnv {
174    /// Everything that can be known without touching the user's config: the
175    /// built-in tools (aliases included), the sub-agent tools, the script tools
176    /// in `agent_dir/tools` and the global tools directory, and the model
177    /// catalogs this build ships.
178    ///
179    /// This is what the daemon lints against at spawn. It deliberately leaves
180    /// `available_providers` unset: the daemon already fails a spawn outright
181    /// when no listed provider is registered, so re-deriving that here would
182    /// cost a registry build per agent to say something the spawn will say
183    /// louder a moment later.
184    pub fn offline(agent_dir: &Path) -> Self {
185        // The four discovery rules live in `tool_inventory` rather than here,
186        // because `GET /api/tools` has to answer the same question and two
187        // copies of "where does a tool come from" would not have stayed equal.
188        // The lint wants only the names; the endpoint wants the sources too.
189        let known_tools =
190            crate::tool_inventory::ToolInventory::discover(Some(agent_dir), None).names();
191
192        Self {
193            known_tools,
194            known_models: crate::commands::models::closed_catalog_models(),
195            available_providers: None,
196            read_paths: None,
197            safe_commands_granted: None,
198            model_windows: crate::commands::models::builtin_model_windows(),
199        }
200    }
201
202    /// Add the answer to "can this install reach the providers the blueprint
203    /// names", asked of the same registry the runtime resolves stages against
204    /// so a script provider counts exactly when it would really load.
205    pub fn with_providers(mut self, blueprint: &Blueprint, config: &crate::config::Config) -> Self {
206        let registry = crate::commands::run::build_provider_registry_from_config(config);
207        self.available_providers = Some(
208            blueprint
209                .stages
210                .iter()
211                .flat_map(|s| s.model.models.iter())
212                .map(|e| e.provider.clone())
213                .filter(|p| registry.as_ref().is_ok_and(|r| r.has(p)))
214                .collect(),
215        );
216        self
217    }
218
219    /// Add the answer to "does this install's config grant what the blueprint
220    /// declares under `[read_paths]`", per entry.
221    ///
222    /// Separate from [`Self::with_providers`] because it needs a workdir:
223    /// relative entries resolve against the one a run would use, which for a
224    /// command run outside a run is the directory it was invoked from.
225    pub fn with_read_paths(
226        mut self,
227        blueprint: &Blueprint,
228        config: &crate::config::Config,
229        workdir: &Path,
230    ) -> Self {
231        self.read_paths = crate::read_path_report::build(blueprint, config, workdir);
232        // Asked here rather than in its own builder: both answers come from the
233        // same config, and a caller that has one always has the other.
234        self.safe_commands_granted = Some(
235            config.security.allow_blueprint_safe_commands
236                || config
237                    .agent_safe_commands
238                    .get(&blueprint.name)
239                    .is_some_and(|a| a.allow_blueprint),
240        );
241        self
242    }
243}
244
245/// Lint `blueprint`, which was parsed from `content`.
246///
247/// The two arguments describe the same manifest: `blueprint` for what the
248/// engine will do with it, `content` for what the author actually wrote.
249pub fn lint_manifest(content: &str, blueprint: &Blueprint, env: &LintEnv) -> Vec<LintFinding> {
250    let declared = Declared::from_text(content);
251    let mut findings = Vec::new();
252
253    if declared.agent_model_block {
254        findings.push(
255            LintFinding::new(
256                LintSeverity::Warning,
257                "agent-model-block-ignored",
258                "the top-level [model] block is not read by anything: model \
259                 selection is per stage"
260                    .to_string(),
261            )
262            .with_fix("move it into each [stages.<name>.model] that needs it"),
263        );
264    }
265
266    findings.extend(lint_dropped_seeds(&declared, blueprint));
267    findings.extend(lint_command_seeds(blueprint));
268    findings.extend(lint_read_paths(blueprint, env));
269    findings.extend(lint_safe_commands(blueprint, env));
270    findings.extend(lint_held_checkpoints(blueprint));
271    findings.extend(lint_graph(blueprint));
272    findings.extend(lint_output_reachable(blueprint));
273    findings.extend(lint_dead_end_possible(blueprint));
274    findings.extend(lint_compacted_deliverables(blueprint));
275    findings.extend(lint_unbounded_percentage(blueprint, env));
276
277    let agent_permissions = blueprint.agent_tool_permissions();
278
279    for stage in &blueprint.stages {
280        let keys = declared.stage(&stage.name);
281        findings.extend(lint_declarations(stage, keys));
282        findings.extend(lint_tools(stage, env));
283        findings.extend(lint_blocking_tools(stage));
284        findings.extend(lint_tool_policies(stage, &agent_permissions));
285        findings.extend(lint_models(stage, env));
286        findings.extend(lint_output_stage(stage));
287    }
288
289    // Worst first, stable within a severity so the order a check ran in is the
290    // order its findings read in.
291    findings.sort_by_key(|f| f.severity);
292    findings
293}
294
295/// A region wrote a `seed` the parser could not read, so it has none.
296///
297/// `parse_region_seed` returns `None` for a seed table with no recognized key
298/// and for a seed that is neither a string nor a table, and the region then
299/// simply starts empty. That is deliberate - an unknown key is not worth
300/// rejecting a whole manifest over - but it is invisible, and a one-character
301/// typo (`caller_input` for `caller`) reads exactly like a working blueprint
302/// until an agent answers a question it was never given. This is the check that
303/// says so.
304fn lint_dropped_seeds(declared: &Declared, blueprint: &Blueprint) -> Vec<LintFinding> {
305    declared
306        .seeded_regions
307        .iter()
308        .filter(|name| {
309            blueprint
310                .context_layout
311                .get_region(name)
312                .is_some_and(|r| r.seed.is_none())
313        })
314        .map(|name| {
315            LintFinding::new(
316                LintSeverity::Warning,
317                "region-seed-not-understood",
318                format!(
319                    "region '{name}' declares a seed that isn't one of the \
320                     recognized forms, so it is ignored and the region starts empty"
321                ),
322            )
323            .with_fix(
324                "use a string (the caller input key), or one of \
325                 { caller = }, { literal = }, { files = }, { glob = }, \
326                 { rhai = }, { command = }",
327            )
328        })
329        .collect()
330}
331
332// ─── Declared keys ────────────────────────────────────────────────────────────
333
334/// Which optional keys the manifest text actually writes, per stage, plus the
335/// one agent-level block that is silently discarded.
336#[derive(Debug, Default)]
337struct Declared {
338    /// A top-level `[model]` table exists. Nothing reads it.
339    agent_model_block: bool,
340    /// Regions whose text writes a `seed` key, whatever its shape. Compared
341    /// against the parsed seed to catch the ones the parser threw away.
342    seeded_regions: Vec<String>,
343    /// Per stage name, the keys that stage wrote.
344    stages: HashMap<String, StageKeys>,
345    /// The manifest text could not be re-read. Every key is then reported as
346    /// declared, so an unreadable manifest produces no declaration warnings
347    /// rather than a full set of false ones.
348    opaque: bool,
349}
350
351#[derive(Debug, Default, Clone, Copy)]
352struct StageKeys {
353    mode: bool,
354    model: bool,
355}
356
357impl Declared {
358    fn from_text(content: &str) -> Self {
359        // `toml::from_str` and not `str::parse`: the latter deserializes a bare
360        // TOML *value*, not a document, and rejects every real manifest.
361        let Ok(root) = toml::from_str::<toml::Table>(content) else {
362            return Self {
363                opaque: true,
364                ..Self::default()
365            };
366        };
367        let agent_model_block = root.get("model").is_some_and(toml::Value::is_table);
368        // Both region spellings - inline `name = { seed = ... }` under
369        // `[context.regions]` and a `[context.regions.name]` section - land here
370        // as the same nested table, so one path covers both.
371        let seeded_regions = root
372            .get("context")
373            .and_then(toml::Value::as_table)
374            .and_then(|c| c.get("regions"))
375            .and_then(toml::Value::as_table)
376            .map(|regions| {
377                regions
378                    .iter()
379                    .filter(|(_, body)| body.get("seed").is_some())
380                    .map(|(name, _)| name.clone())
381                    .collect()
382            })
383            .unwrap_or_default();
384        let stages = root
385            .get("stages")
386            .and_then(toml::Value::as_table)
387            .map(|t| {
388                t.iter()
389                    .map(|(name, body)| {
390                        (
391                            name.clone(),
392                            StageKeys {
393                                mode: body.get("mode").is_some(),
394                                model: body.get("model").is_some(),
395                            },
396                        )
397                    })
398                    .collect()
399            })
400            .unwrap_or_default();
401        Self {
402            agent_model_block,
403            seeded_regions,
404            stages,
405            opaque: false,
406        }
407    }
408
409    /// What `stage` declared. An unreadable manifest, or a stage the text has
410    /// no entry for, reports everything as declared so nothing is warned about.
411    fn stage(&self, stage: &str) -> StageKeys {
412        if self.opaque {
413            return StageKeys {
414                mode: true,
415                model: true,
416            };
417        }
418        self.stages.get(stage).copied().unwrap_or(StageKeys {
419            mode: true,
420            model: true,
421        })
422    }
423}
424
425// ─── Checks ───────────────────────────────────────────────────────────────────
426
427// The checks themselves, one module per question they answer. Imported rather
428// than re-exported: `lint_manifest` is the only caller and the only entry point
429// anyone outside this module needs, so the individual checks stay internal.
430mod checks;
431use checks::*;
432mod security;
433use security::*;
434
435#[cfg(test)]
436mod tests;