use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use leviath_core::Blueprint;
use leviath_core::blueprint::StageMode;
use leviath_runtime::dynamic_interaction::BLOCKING_INTERACTION_TOOLS;
use leviath_tools::canonical_tool_name;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LintSeverity {
Error,
Warning,
Note,
}
impl LintSeverity {
pub fn label(self) -> &'static str {
match self {
Self::Error => "ERR ",
Self::Warning => "WARN",
Self::Note => "NOTE",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LintFinding {
pub severity: LintSeverity,
pub code: &'static str,
pub stage: Option<String>,
pub message: String,
pub fix: Option<String>,
}
impl LintFinding {
fn new(severity: LintSeverity, code: &'static str, message: String) -> Self {
Self {
severity,
code,
stage: None,
message,
fix: None,
}
}
fn in_stage(mut self, stage: &str) -> Self {
self.stage = Some(stage.to_string());
self
}
fn with_fix(mut self, fix: impl Into<String>) -> Self {
self.fix = Some(fix.into());
self
}
pub fn is_error(&self) -> bool {
self.severity == LintSeverity::Error
}
pub fn one_line(&self) -> String {
match &self.stage {
Some(stage) => format!("stage '{stage}': {}", self.message),
None => self.message.clone(),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct LintEnv {
pub known_tools: HashSet<String>,
pub known_models: Vec<(String, String)>,
pub available_providers: Option<HashSet<String>>,
pub read_paths: Option<Result<crate::read_path_report::GrantReport, String>>,
}
impl LintEnv {
pub fn offline(agent_dir: &Path) -> Self {
let mut known_tools: HashSet<String> = leviath_tools::BuiltinTools::new(
leviath_tools::ToolContext::new(agent_dir.to_path_buf()),
)
.names()
.into_iter()
.collect();
known_tools.extend(leviath_tools::BuiltinTools::subagent_tool_names());
let dirs: Vec<PathBuf> = [Some(agent_dir.join("tools")), leviath_core::tools_dir()]
.into_iter()
.flatten()
.filter(|d| d.is_dir())
.collect();
let (set, _skipped) = leviath_scripting::ScriptToolSet::discover(&dirs);
known_tools.extend(set.names());
Self {
known_tools,
known_models: crate::commands::models::closed_catalog_models(),
available_providers: None,
read_paths: None,
}
}
pub fn with_providers(mut self, blueprint: &Blueprint, config: &crate::config::Config) -> Self {
let registry = crate::commands::run::build_provider_registry_from_config(config);
self.available_providers = Some(
blueprint
.stages
.iter()
.flat_map(|s| s.model.models.iter())
.map(|e| e.provider.clone())
.filter(|p| registry.has(p))
.collect(),
);
self
}
pub fn with_read_paths(
mut self,
blueprint: &Blueprint,
config: &crate::config::Config,
workdir: &Path,
) -> Self {
self.read_paths = crate::read_path_report::build(blueprint, config, workdir);
self
}
}
pub fn lint_manifest(content: &str, blueprint: &Blueprint, env: &LintEnv) -> Vec<LintFinding> {
let declared = Declared::from_text(content);
let mut findings = Vec::new();
if declared.agent_model_block {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"agent-model-block-ignored",
"the top-level [model] block is not read by anything: model \
selection is per stage"
.to_string(),
)
.with_fix("move it into each [stages.<name>.model] that needs it"),
);
}
findings.extend(lint_command_seeds(blueprint));
findings.extend(lint_read_paths(blueprint, env));
findings.extend(lint_graph(blueprint));
let agent_permissions = blueprint.agent_tool_permissions();
for stage in &blueprint.stages {
let keys = declared.stage(&stage.name);
findings.extend(lint_declarations(stage, keys));
findings.extend(lint_tools(stage, env));
findings.extend(lint_blocking_tools(stage));
findings.extend(lint_tool_policies(stage, &agent_permissions));
findings.extend(lint_models(stage, env));
}
findings.sort_by_key(|f| f.severity);
findings
}
#[derive(Debug, Default)]
struct Declared {
agent_model_block: bool,
stages: HashMap<String, StageKeys>,
opaque: bool,
}
#[derive(Debug, Default, Clone, Copy)]
struct StageKeys {
mode: bool,
model: bool,
}
impl Declared {
fn from_text(content: &str) -> Self {
let Ok(root) = toml::from_str::<toml::Table>(content) else {
return Self {
opaque: true,
..Self::default()
};
};
let agent_model_block = root.get("model").is_some_and(toml::Value::is_table);
let stages = root
.get("stages")
.and_then(toml::Value::as_table)
.map(|t| {
t.iter()
.map(|(name, body)| {
(
name.clone(),
StageKeys {
mode: body.get("mode").is_some(),
model: body.get("model").is_some(),
},
)
})
.collect()
})
.unwrap_or_default();
Self {
agent_model_block,
stages,
opaque: false,
}
}
fn stage(&self, stage: &str) -> StageKeys {
if self.opaque {
return StageKeys {
mode: true,
model: true,
};
}
self.stages.get(stage).copied().unwrap_or(StageKeys {
mode: true,
model: true,
})
}
}
fn lint_declarations(stage: &leviath_core::Stage, keys: StageKeys) -> Vec<LintFinding> {
let mut findings = Vec::new();
if !keys.mode {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"stage-missing-mode",
"no mode is set, so the stage runs as autonomous".to_string(),
)
.in_stage(&stage.name)
.with_fix("write mode = \"autonomous\" if that is what you meant"),
);
}
if !keys.model {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"stage-missing-model",
format!(
"no [stages.{}.model] block, so the stage runs on your \
configured default_provider, whatever that is",
stage.name
),
)
.in_stage(&stage.name)
.with_fix(format!(
"add model = {{ models = [{{ provider = \"...\", model = \"...\" }}] }} \
to [stages.{}]",
stage.name
)),
);
}
let counts_iterations = !matches!(stage.mode, StageMode::FanOut { .. });
if counts_iterations && stage.max_iterations.is_none() {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"stage-missing-max-iterations",
"no max_iterations, so the stage is unbounded unless your config \
sets [limits] default_max_iterations"
.to_string(),
)
.in_stage(&stage.name)
.with_fix("give the stage a max_iterations it should never reach"),
);
}
findings
}
fn lint_tools(stage: &leviath_core::Stage, env: &LintEnv) -> Vec<LintFinding> {
let mut findings = Vec::new();
if !env.known_tools.is_empty() {
for tool in &stage.available_tools {
if tool.contains("__") || env.known_tools.contains(tool) {
continue;
}
findings.push(
LintFinding::new(
LintSeverity::Error,
"unknown-tool",
format!(
"grants '{tool}', which is not a built-in, a sub-agent \
tool, or one of this agent's own tools/*.rhai"
),
)
.in_stage(&stage.name)
.with_fix("check the spelling, or drop the entry"),
);
}
}
let granted: HashSet<&str> = stage.available_tools.iter().map(String::as_str).collect();
for tool in stage.tool_permissions.keys() {
if granted.contains(tool.as_str()) {
continue;
}
findings.push(
LintFinding::new(
LintSeverity::Error,
"orphan-stage-permission",
format!(
"sets a permission for '{tool}', which it does not grant in \
available_tools - it reads as a grant and is not one"
),
)
.in_stage(&stage.name)
.with_fix(format!(
"add '{tool}' to available_tools, or drop the permission"
)),
);
}
findings
}
fn lint_blocking_tools(stage: &leviath_core::Stage) -> Vec<LintFinding> {
if !matches!(stage.mode, StageMode::Autonomous) || stage.allow_blocking_tools {
return Vec::new();
}
stage
.available_tools
.iter()
.filter(|t| BLOCKING_INTERACTION_TOOLS.contains(&canonical_tool_name(t)))
.filter(|t| !stage.required_tools.contains(t))
.map(|tool| {
LintFinding::new(
LintSeverity::Warning,
"blocking-tool-in-autonomous-stage",
format!(
"is autonomous but grants '{tool}', which suspends the run \
until a person answers"
),
)
.in_stage(&stage.name)
.with_fix(
"drop the tool, switch the stage to an interactive mode, list it in \
required_tools so it survives an unattended run too, or set \
allow_blocking_tools = true to say you meant it",
)
})
.collect()
}
fn lint_tool_policies(
stage: &leviath_core::Stage,
agent_permissions: &HashMap<String, String>,
) -> Vec<LintFinding> {
let has_policy = |name: &str| {
stage.tool_permissions.contains_key(name) || agent_permissions.contains_key(name)
};
stage
.available_tools
.iter()
.filter(|t| !has_policy(t))
.filter_map(|tool| {
match alias_siblings(tool).into_iter().find(|s| has_policy(s)) {
Some(other) => Some(
LintFinding::new(
LintSeverity::Warning,
"permission-name-mismatch",
format!(
"grants '{tool}' but its permission is written for \
'{other}'. Policy is matched on the name the model \
calls, which is '{tool}', so that entry has no effect"
),
)
.in_stage(&stage.name)
.with_fix(format!("rename the permission key '{other}' to '{tool}'")),
),
None if canonical_tool_name(tool) == "shell" => Some(
LintFinding::new(
LintSeverity::Warning,
"implicit-shell-policy",
format!(
"grants '{tool}' with no permission set for it, so it \
defaults to ask - and an unattended run waits on that \
prompt rather than being denied"
),
)
.in_stage(&stage.name)
.with_fix(format!(
"set {tool} = \"allow\" or \"deny\" in [tool_permissions] or \
[stages.{}.tool_permissions]",
stage.name
)),
),
None => None,
}
})
.collect()
}
fn alias_siblings(name: &str) -> Vec<String> {
let canonical = canonical_tool_name(name);
std::iter::once(canonical)
.chain(
leviath_tools::TOOL_ALIASES
.iter()
.filter(|(_, c)| *c == canonical)
.map(|(alias, _)| *alias),
)
.filter(|s| *s != name)
.map(str::to_string)
.collect()
}
fn lint_command_seeds(blueprint: &Blueprint) -> Vec<LintFinding> {
let seeds: Vec<String> = blueprint
.context_layout
.regions
.iter()
.filter_map(|r| match &r.seed {
Some(leviath_core::layout::RegionSeed::Command { command }) => {
Some(format!("{}: {command}", r.name))
}
_ => None,
})
.collect();
if seeds.is_empty() {
return Vec::new();
}
vec![
LintFinding::new(
LintSeverity::Note,
"command-seed",
format!(
"{} region(s) run a shell command at spawn, before the first \
inference and before any tool-approval prompt: {}",
seeds.len(),
seeds.join(", ")
),
)
.with_fix(
"disable with `--no-seed-commands`, or machine-wide via \
`[security] allow_seed_commands = false`",
),
]
}
fn lint_read_paths(blueprint: &Blueprint, env: &LintEnv) -> Vec<LintFinding> {
let Some(rp) = blueprint
.read_paths
.as_ref()
.filter(|rp| !rp.allow.is_empty())
else {
return Vec::new();
};
let mut findings = match &env.read_paths {
Some(Ok(report)) => grant_findings(report),
Some(Err(e)) => vec![
LintFinding::new(LintSeverity::Warning, "read-paths-grant-invalid", e.clone())
.with_fix("fix the entry in your config.toml, or remove it"),
],
None => vec![
LintFinding::new(
LintSeverity::Note,
"read-paths-declared",
format!(
"declares [read_paths] (reads outside the run workdir): {}",
rp.allow.join(", ")
),
)
.with_fix("these are refused unless your own config grants them"),
],
};
findings.extend(
rp.allow
.iter()
.filter(|e| read_path_entry_is_broad(e))
.map(|entry| {
LintFinding::new(
LintSeverity::Warning,
"broad-read-path",
format!(
"read_paths entry '{entry}' is very broad - it can match \
your entire home directory or any path on this machine"
),
)
.with_fix("name the directory it actually needs")
}),
);
findings
}
fn grant_findings(report: &crate::read_path_report::GrantReport) -> Vec<LintFinding> {
let mut findings = vec![
LintFinding::new(
LintSeverity::Note,
"read-paths-declared",
format!(
"declares [read_paths] (reads outside the run workdir): {}",
report.summary()
),
)
.with_fix(match report.allow_blueprint {
true => "all granted by [security] allow_blueprint_read_paths = true".to_string(),
false => report
.entries
.iter()
.map(|e| format!("{}: {}", e.raw, e.status.label()))
.collect::<Vec<_>>()
.join("; "),
}),
];
if report.has_ungranted() {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"read-paths-not-granted",
format!(
"your config does not grant {}: reads matching them will be refused",
report.ungranted().join(", ")
),
)
.with_fix(format!(
"add to your config.toml: {}",
report.grant_stanza().join(" ")
)),
);
}
findings
}
fn read_path_entry_is_broad(entry: &str) -> bool {
let pattern = entry
.strip_prefix("glob:")
.or_else(|| entry.strip_prefix("regex:"))
.unwrap_or(entry);
let pattern = pattern.replace('\\', "/");
let trimmed = pattern.trim_end_matches('/');
matches!(trimmed, "~" | "")
|| trimmed == "/**"
|| pattern.starts_with("**")
|| pattern.starts_with("/.*")
|| trimmed == "/.+"
}
fn lint_graph(blueprint: &Blueprint) -> Vec<LintFinding> {
if !blueprint.stages.iter().any(|s| s.transitions.is_some()) {
return Vec::new();
}
let stage_names: HashSet<&str> = blueprint.stages.iter().map(|s| s.name.as_str()).collect();
let entry = blueprint.resolve_entry_stage_name();
let mut reachable = HashSet::new();
let mut queue = std::collections::VecDeque::from([entry.clone()]);
while let Some(name) = queue.pop_front() {
if !reachable.insert(name.clone()) {
continue;
}
let Some(stage) = blueprint.find_stage(&name) else {
continue;
};
let fan_out = match &stage.mode {
StageMode::FanOut { config } => [
config.worker_stage.as_deref(),
config.merge_stage.as_deref(),
],
_ => [None, None],
};
let edges = stage
.transitions
.iter()
.flat_map(|t| t.keys().map(String::as_str))
.chain(fan_out.into_iter().flatten());
for target in edges {
if !reachable.contains(target) && stage_names.contains(target) {
queue.push_back(target.to_string());
}
}
}
let mut findings: Vec<LintFinding> = blueprint
.stages
.iter()
.filter(|s| !reachable.contains(s.name.as_str()))
.map(|s| {
LintFinding::new(
LintSeverity::Warning,
"unreachable-stage",
format!("cannot be reached from entry stage '{entry}'"),
)
.in_stage(&s.name)
.with_fix("give some stage a transition to it, or delete it")
})
.collect();
for stage in &blueprint.stages {
let Some(transitions) = &stage.transitions else {
continue;
};
for target in transitions.keys().filter(|t| **t != stage.name) {
let Some(target_stage) = blueprint.find_stage(target) else {
continue;
};
let Some(t2) = &target_stage.transitions else {
continue;
};
if t2.contains_key(&stage.name) && target_stage.max_revisits.is_none() {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"cycle-without-max-revisits",
format!(
"is in a cycle with '{}' and has no max_revisits",
stage.name
),
)
.in_stage(target)
.with_fix("set max_revisits so the loop has to end"),
);
}
}
}
findings
}
fn lint_models(stage: &leviath_core::Stage, env: &LintEnv) -> Vec<LintFinding> {
let mut findings = Vec::new();
for entry in &stage.model.models {
let catalog_known = env.known_models.iter().any(|(p, _)| *p == entry.provider);
let listed = env
.known_models
.iter()
.any(|(p, m)| *p == entry.provider && *m == entry.model);
if catalog_known && !listed {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"unknown-model",
format!(
"names {}/{}, which is not a model this build knows about",
entry.provider, entry.model
),
)
.in_stage(&stage.name)
.with_fix(
"check `lev models list`, or `lev models list --remote` \
if it is newer than this build",
),
);
}
}
if let Some(available) = &env.available_providers
&& !stage.model.models.is_empty()
&& !stage
.model
.models
.iter()
.any(|e| available.contains(&e.provider))
{
let tried: Vec<&str> = stage
.model
.models
.iter()
.map(|e| e.provider.as_str())
.collect();
findings.push(
LintFinding::new(
LintSeverity::Warning,
"no-reachable-provider",
format!(
"names no provider this install can reach (tried {}), so it \
falls back to your default model",
tried.join(", ")
),
)
.in_stage(&stage.name)
.with_fix("run `lev setup` to configure one of them, or add a provider you have"),
);
}
findings
}
#[cfg(test)]
mod tests;