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>>,
pub safe_commands_granted: Option<bool>,
}
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,
safe_commands_granted: 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.as_ref().is_ok_and(|r| r.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.safe_commands_granted = Some(
config.security.allow_blueprint_safe_commands
|| config
.agent_safe_commands
.get(&blueprint.name)
.is_some_and(|a| a.allow_blueprint),
);
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_dropped_seeds(&declared, blueprint));
findings.extend(lint_command_seeds(blueprint));
findings.extend(lint_read_paths(blueprint, env));
findings.extend(lint_safe_commands(blueprint, env));
findings.extend(lint_held_checkpoints(blueprint));
findings.extend(lint_graph(blueprint));
findings.extend(lint_output_reachable(blueprint));
findings.extend(lint_dead_end_possible(blueprint));
findings.extend(lint_compacted_deliverables(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.extend(lint_output_stage(stage));
}
findings.sort_by_key(|f| f.severity);
findings
}
fn lint_dropped_seeds(declared: &Declared, blueprint: &Blueprint) -> Vec<LintFinding> {
declared
.seeded_regions
.iter()
.filter(|name| {
blueprint
.context_layout
.get_region(name)
.is_some_and(|r| r.seed.is_none())
})
.map(|name| {
LintFinding::new(
LintSeverity::Warning,
"region-seed-not-understood",
format!(
"region '{name}' declares a seed that isn't one of the \
recognized forms, so it is ignored and the region starts empty"
),
)
.with_fix(
"use a string (the caller input key), or one of \
{ caller = }, { literal = }, { files = }, { glob = }, \
{ rhai = }, { command = }",
)
})
.collect()
}
#[derive(Debug, Default)]
struct Declared {
agent_model_block: bool,
seeded_regions: Vec<String>,
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 seeded_regions = root
.get("context")
.and_then(toml::Value::as_table)
.and_then(|c| c.get("regions"))
.and_then(toml::Value::as_table)
.map(|regions| {
regions
.iter()
.filter(|(_, body)| body.get("seed").is_some())
.map(|(name, _)| name.clone())
.collect()
})
.unwrap_or_default();
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,
seeded_regions,
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,
})
}
}
mod checks;
use checks::*;
mod security;
use security::*;
#[cfg(test)]
mod tests;