use super::*;
pub(super) fn lint_tool_policies(
stage: &leviath_core::Stage,
agent_permissions: &HashMap<String, String>,
) -> Vec<LintFinding> {
let has_policy = |name: &str| {
leviath_tools::tool_name_spellings(name)
.any(|n| stage.tool_permissions.contains_key(n) || agent_permissions.contains_key(n))
};
stage
.available_tools
.iter()
.filter(|t| !has_policy(t))
.filter(|t| canonical_tool_name(t) == "shell")
.map(|tool| {
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
))
})
.collect()
}
pub(super) 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 }) => {
let verdict = match default_safe_keys_cover(command) {
true => "pre-approved",
false => "NOT pre-approved by the default safe list, so it will be refused",
};
Some(format!("{}: {command} ({verdict})", 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(
"a refused seed needs its programs in `[safe_commands] shell`; disable seeds \
entirely with `--no-seed-commands`, or machine-wide via \
`[security] allow_seed_commands = false`",
),
]
}
pub(super) fn default_safe_keys_cover(command: &str) -> bool {
let safe = crate::approvals::resolve_safe_keys(&Default::default(), None, None, false);
let keys = crate::shell_keys::command_keys(command);
!keys.is_empty()
&& keys
.iter()
.all(|k| safe.contains_key(k) || safe.contains_key(crate::shell_keys::program_of(k)))
}
pub(super) fn lint_held_checkpoints(blueprint: &Blueprint) -> Vec<LintFinding> {
let held = crate::held_checkpoints::held_points(blueprint)
.into_iter()
.map(|h| (h, "an interaction point that declares unattended = \"ask\""))
.chain(
crate::held_checkpoints::held_tools(blueprint)
.into_iter()
.map(|h| (h, "a blocking tool the stage keeps in required_tools")),
);
held.map(|(h, what)| {
LintFinding::new(
LintSeverity::Note,
"holds-under-yolo",
format!(
"'{}' still stops an unattended run for a person: {what}",
h.name
),
)
.in_stage(&h.stage)
.with_fix(
"this is deliberate if the checkpoint needs a person. `[limits] \
interaction_timeout_secs` bounds the wait, and an unanswered \
checkpoint stops the run with an error rather than approving it",
)
})
.collect()
}
pub(super) fn lint_safe_commands(blueprint: &Blueprint, env: &LintEnv) -> Vec<LintFinding> {
let Some(sc) = blueprint.safe_commands.as_ref() else {
return Vec::new();
};
let mut findings = Vec::new();
for entry in &sc.shell {
if !crate::shell_keys::is_valid_prefix(entry) {
findings.push(
LintFinding::new(
LintSeverity::Error,
"unparseable-safe-command",
format!(
"[safe_commands] shell entry '{entry}' is not a bare command prefix, so \
no call can ever match it"
),
)
.with_fix(
"write a program, optionally with the subcommand that narrows it: \
'rg', 'cargo test', 'git status'. No flags, arguments, redirects, \
quotes or chained commands",
),
);
}
}
if !sc.shell.is_empty() || !sc.tools.is_empty() {
let message = match env.safe_commands_granted {
Some(true) => None,
Some(false) => Some(
"declares [safe_commands], and your config does not honour blueprint \
safe-commands, so none of it applies"
.to_string(),
),
None => Some(
"declares [safe_commands]. Declaring is not granting: entries apply only \
where the user opts in"
.to_string(),
),
};
if let Some(message) = message {
findings.push(
LintFinding::new(LintSeverity::Note, "safe-commands-declared", message).with_fix(
format!(
"set [agent_safe_commands.{}] allow_blueprint = true in your \
config.toml, or [security] allow_blueprint_safe_commands for every \
agent",
blueprint.name
),
),
);
}
}
findings
}
pub(super) 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
}
pub(super) 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
}
pub(super) 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 == "/.+"
}