mod hotspots;
mod roots;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use crate::config::ScanPathFilterSummary;
use crate::error::ShieldError;
use crate::ir::Language;
use crate::rules::{AttackCategory, Finding, Severity};
use crate::ScanReport;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoverageConfidence {
High,
Medium,
Low,
}
impl CoverageConfidence {
fn label(self) -> &'static str {
match self {
Self::High => "High",
Self::Medium => "Medium",
Self::Low => "Low",
}
}
fn reason(self) -> &'static str {
match self {
Self::High => "known adapter(s) matched and source files were parsed",
Self::Medium => "known adapter(s) matched, but code parsing coverage is limited",
Self::Low => "no supported agent extension surface was detected",
}
}
}
#[derive(Debug, Clone)]
pub struct ExplainOptions {
pub ignore_tests: bool,
}
#[derive(Debug, Clone)]
pub struct CiInstallOptions<'a> {
pub fail_on: &'a str,
pub ignore_tests: bool,
pub scan_path: &'a str,
pub baseline_path: Option<&'a str>,
pub upload_sarif: bool,
}
pub fn quickstart_config_toml(fail_on: Severity, ignore_tests: bool) -> String {
format!(
r#"# AgentShield configuration
# Generated by `agentshield quickstart`.
[policy]
fail_on = "{fail_on}"
[scan]
ignore_tests = {ignore_tests}
[runtime.proxy]
fail_on = "block"
"#
)
}
pub fn github_actions_workflow(options: &CiInstallOptions<'_>) -> String {
let baseline_input = options
.baseline_path
.map(|path| format!(" baseline: \"{path}\"\n"))
.unwrap_or_default();
format!(
r#"name: AgentShield
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
agentshield:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aiconnai/agentshield@main
with:
path: "{scan_path}"
fail-on: "{fail_on}"
ignore-tests: {ignore_tests}
{baseline_input} upload-sarif: {upload_sarif}
"#,
scan_path = options.scan_path,
fail_on = options.fail_on,
ignore_tests = options.ignore_tests,
baseline_input = baseline_input,
upload_sarif = options.upload_sarif,
)
}
pub fn render_explain(report: &ScanReport, options: &ExplainOptions) -> String {
let coverage = coverage_summary(report);
let confidence = confidence_for_report(report);
let runtime_findings: Vec<&Finding> = report
.findings
.iter()
.filter(|finding| finding.attack_category != AttackCategory::SupplyChain)
.collect();
let supply_chain_findings: Vec<&Finding> = report
.findings
.iter()
.filter(|finding| finding.attack_category == AttackCategory::SupplyChain)
.collect();
let mut output = String::new();
output.push_str("AgentShield explain\n");
output.push_str("===================\n\n");
output.push_str(&format!(
"Gate: {}\n",
if report.verdict.pass { "PASS" } else { "FAIL" }
));
output.push_str(&format!("Reason: {}\n", gate_reason(report)));
output.push_str(&format!(
"Security confidence: {} - {}\n\n",
confidence.label(),
confidence.reason()
));
output.push_str("Coverage:\n");
output.push_str(&format!(
"- Adapters: {}\n",
display_list(&coverage.frameworks, "none")
));
output.push_str(&roots::render(report));
output.push_str(&format!("- Targets: {}\n", coverage.targets));
output.push_str(&format!(
"- Source files parsed: {} ({})\n",
coverage.source_files,
display_list(&coverage.languages, "no code parser coverage")
));
output.push_str(&format!("- Tools discovered: {}\n", coverage.tools));
output.push_str(&format!(
"- Dependencies checked: {}\n",
coverage.dependencies
));
output.push_str(&format!("- Lockfiles detected: {}\n", coverage.lockfiles));
output.push_str(&format!(
"- Test file exclusion: {}\n",
if options.ignore_tests {
"enabled"
} else {
"disabled"
}
));
output.push_str(&format!(
"- Path filters: {}\n\n",
format_path_filters(&report.path_filter_summary)
));
output.push_str("Findings:\n");
output.push_str(&format!(
"- Runtime-risk findings: {}\n",
finding_group_summary(&runtime_findings)
));
output.push_str(&format!(
"- Supply-chain hygiene: {}\n",
finding_group_summary(&supply_chain_findings)
));
output.push_str(&format!(
"- Severity counts: {}\n\n",
severity_counts(&report.findings)
));
output.push_str(&hotspots::render(report));
output.push_str("Next actions:\n");
for action in next_actions(report) {
output.push_str(&format!("- {action}\n"));
}
output.push_str("\nWhat this does not prove:\n");
output.push_str("- This scan does not execute tools or prove absence of vulnerabilities.\n");
output.push_str(
"- It checks known risky patterns in supported agent surfaces and dependency metadata.\n",
);
output
}
pub fn render_no_adapter_explain(
path: &Path,
ignore_tests: bool,
path_filters: &ScanPathFilterSummary,
) -> String {
let mut output = String::new();
output.push_str("AgentShield explain\n");
output.push_str("===================\n\n");
output.push_str("Gate: INCONCLUSIVE\n");
output.push_str("Reason: no supported agent extension surface was detected.\n");
output.push_str(&format!(
"Security confidence: {} - {}\n\n",
CoverageConfidence::Low.label(),
CoverageConfidence::Low.reason()
));
output.push_str("Coverage:\n");
output.push_str("- Adapters: none\n");
output.push_str(&format!("- Target: {}\n", path.display()));
output.push_str(&format!(
"- Test file exclusion: {}\n",
if ignore_tests { "enabled" } else { "disabled" }
));
output.push_str(&format!(
"- Path filters: {}\n\n",
format_path_filters(path_filters)
));
output.push_str("Next actions:\n");
output.push_str("- Confirm this repository contains an MCP server, OpenClaw skill, Hermes agent, CrewAI/LangChain tool, GPT Action, or Cursor Rules surface.\n");
output.push_str("- If it does, add a framework manifest or dependency metadata that AgentShield can detect.\n");
output.push_str("- Run `agentshield doctor .` to inspect adapter detection.\n\n");
output.push_str("What this does not prove:\n");
output.push_str("- This result does not mean the project is safe; it means AgentShield did not find a supported surface to scan.\n");
output
}
pub fn is_no_adapter(error: &ShieldError) -> bool {
matches!(error, ShieldError::NoAdapter(_))
}
#[derive(Debug, Default)]
struct CoverageSummary {
frameworks: BTreeSet<String>,
languages: BTreeSet<String>,
targets: usize,
source_files: usize,
tools: usize,
dependencies: usize,
lockfiles: usize,
}
fn coverage_summary(report: &ScanReport) -> CoverageSummary {
let mut summary = CoverageSummary {
targets: report.targets.len(),
..CoverageSummary::default()
};
for target in &report.targets {
summary.frameworks.insert(target.framework.to_string());
summary.source_files += target.source_files.len();
summary.tools += target.tools.len();
summary.dependencies += target.dependencies.dependencies.len();
if target.dependencies.lockfile.is_some() {
summary.lockfiles += 1;
}
for source in &target.source_files {
summary
.languages
.insert(display_language(source.language).into());
}
}
summary
}
fn confidence_for_report(report: &ScanReport) -> CoverageConfidence {
if report.targets.is_empty() {
CoverageConfidence::Low
} else if report
.targets
.iter()
.any(|target| !target.source_files.is_empty())
{
CoverageConfidence::High
} else {
CoverageConfidence::Medium
}
}
fn gate_reason(report: &ScanReport) -> String {
if report.verdict.pass {
match report.verdict.highest_severity {
Some(severity) => format!(
"no findings at or above the {} threshold; highest finding is {}",
report.verdict.fail_threshold, severity
),
None => format!(
"no findings remained after policy, suppressions, and baseline filtering; threshold is {}",
report.verdict.fail_threshold
),
}
} else {
format!(
"at least one finding meets or exceeds the {} threshold; highest finding is {}",
report.verdict.fail_threshold,
report
.verdict
.highest_severity
.map(|severity| severity.to_string())
.unwrap_or_else(|| "unknown".into())
)
}
}
fn finding_group_summary(findings: &[&Finding]) -> String {
if findings.is_empty() {
"none".into()
} else {
format!("{} ({})", findings.len(), severity_counts_refs(findings))
}
}
fn severity_counts(findings: &[Finding]) -> String {
let refs: Vec<&Finding> = findings.iter().collect();
severity_counts_refs(&refs)
}
fn severity_counts_refs(findings: &[&Finding]) -> String {
if findings.is_empty() {
return "none".into();
}
let mut counts: BTreeMap<Severity, usize> = BTreeMap::new();
for finding in findings {
*counts.entry(finding.severity).or_default() += 1;
}
[
Severity::Critical,
Severity::High,
Severity::Medium,
Severity::Low,
Severity::Info,
]
.into_iter()
.filter_map(|severity| {
counts
.get(&severity)
.map(|count| format!("{count} {severity}"))
})
.collect::<Vec<_>>()
.join(", ")
}
fn next_actions(report: &ScanReport) -> Vec<String> {
if report.findings.is_empty() {
return vec![
"Add a CI gate with `agentshield ci install`.".into(),
"Keep `agentshield scan . --ignore-tests --fail-on high` in the pre-merge path.".into(),
];
}
let mut actions = Vec::new();
if !report.verdict.pass {
actions.push(format!(
"Fix findings at or above `{}` first; they are blocking the security gate.",
report.verdict.fail_threshold
));
}
let mut seen_rules = BTreeSet::new();
for finding in &report.findings {
if !seen_rules.insert(finding.rule_id.clone()) {
continue;
}
if let Some(command) = exact_command_for_finding(finding) {
actions.push(command);
} else if let Some(remediation) = &finding.remediation {
actions.push(remediation.clone());
} else {
actions.push(format!("Review `{}`: {}", finding.rule_id, finding.message));
}
if actions.len() >= 5 {
break;
}
}
actions.push("Run `agentshield scan . --explain` again after changes.".into());
actions
}
fn exact_command_for_finding(finding: &Finding) -> Option<String> {
let file_name = finding
.location
.as_ref()
.and_then(|location| location.file.file_name())
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
match finding.rule_id.as_str() {
"SHIELD-009" => {
let package = package_name_from_message(&finding.message)?;
if file_name == "package.json" {
Some(format!(
"Pin `{package}` with `npm install {package}@<exact-version> --save-exact`."
))
} else if file_name == "requirements.txt" {
Some(format!(
"Pin `{package}` by changing the requirement to `{package}==<exact-version>`."
))
} else if file_name == "pyproject.toml" {
Some(format!(
"Pin `{package}` to an exact version in `pyproject.toml`, then regenerate the lockfile."
))
} else {
None
}
}
"SHIELD-012" => {
if file_name == "package.json" {
Some("Generate an npm lockfile with `npm install`.".into())
} else if file_name == "requirements.txt" {
Some(
"Generate a reproducible Python lockfile with `uv lock` or `poetry lock`."
.into(),
)
} else {
None
}
}
_ => None,
}
}
fn package_name_from_message(message: &str) -> Option<&str> {
let start = message.find('\'')? + 1;
let rest = &message[start..];
let end = rest.find('\'')?;
Some(&rest[..end])
}
fn display_list(values: &BTreeSet<String>, empty: &str) -> String {
if values.is_empty() {
empty.into()
} else {
values.iter().cloned().collect::<Vec<_>>().join(", ")
}
}
fn format_path_filters(summary: &ScanPathFilterSummary) -> String {
if summary.include.is_empty() && summary.exclude.is_empty() {
return "disabled".into();
}
let include = if summary.include.is_empty() {
"all".into()
} else {
summary.include.join(", ")
};
let exclude = if summary.exclude.is_empty() {
"none".into()
} else {
summary.exclude.join(", ")
};
format!("include {include}; exclude {exclude}")
}
fn display_language(language: Language) -> &'static str {
match language {
Language::Python => "Python",
Language::TypeScript => "TypeScript",
Language::JavaScript => "JavaScript",
Language::Shell => "Shell",
Language::Json => "JSON",
Language::Toml => "TOML",
Language::Yaml => "YAML",
Language::Markdown => "Markdown",
Language::Unknown => "Unknown",
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::ir::{Framework, ScanTarget, SourceFile};
use crate::rules::policy::PolicyVerdict;
use crate::rules::{AttackCategory, Confidence, Evidence, Finding};
use super::*;
fn finding(rule_id: &str, severity: Severity, category: AttackCategory) -> Finding {
Finding {
rule_id: rule_id.into(),
rule_name: "Rule".into(),
severity,
confidence: Confidence::High,
attack_category: category,
message: "Dependency '@modelcontextprotocol/sdk' is not pinned: ^1.0.0".into(),
location: Some(crate::ir::SourceLocation {
file: PathBuf::from("package.json"),
line: 1,
column: 0,
end_line: None,
end_column: None,
}),
evidence: vec![Evidence {
description: "evidence".into(),
location: None,
snippet: None,
}],
taint_path: None,
remediation: Some("fix it".into()),
cwe_id: None,
}
}
fn report(findings: Vec<Finding>) -> ScanReport {
ScanReport {
target_name: "fixture".into(),
findings,
verdict: PolicyVerdict {
pass: true,
total_findings: 2,
effective_findings: 2,
highest_severity: Some(Severity::Medium),
fail_threshold: Severity::High,
},
scan_root: PathBuf::from("."),
targets: vec![ScanTarget {
name: "fixture".into(),
framework: Framework::Mcp,
root_path: PathBuf::from("."),
tools: vec![],
execution: Default::default(),
data: Default::default(),
dependencies: Default::default(),
provenance: Default::default(),
source_files: vec![SourceFile {
path: PathBuf::from("server.py"),
language: Language::Python,
content: String::new(),
size_bytes: 0,
content_hash: String::new(),
}],
}],
path_filter_summary: ScanPathFilterSummary::default(),
}
}
#[test]
fn explain_separates_runtime_and_supply_chain_findings() {
let output = render_explain(
&report(vec![finding(
"SHIELD-009",
Severity::Medium,
AttackCategory::SupplyChain,
)]),
&ExplainOptions { ignore_tests: true },
);
assert!(output.contains("Gate: PASS"));
assert!(output.contains("Runtime-risk findings: none"));
assert!(output.contains("Supply-chain hygiene: 1"));
assert!(output.contains("Security confidence: High"));
assert!(
output.contains("npm install @modelcontextprotocol/sdk@<exact-version> --save-exact")
);
}
#[test]
fn no_adapter_explain_is_inconclusive() {
let output =
render_no_adapter_explain(Path::new("."), true, &ScanPathFilterSummary::default());
assert!(output.contains("Gate: INCONCLUSIVE"));
assert!(output.contains("does not mean the project is safe"));
}
#[test]
fn ci_workflow_uses_expected_action_inputs() {
let workflow = github_actions_workflow(&CiInstallOptions {
fail_on: "high",
ignore_tests: true,
scan_path: ".",
baseline_path: None,
upload_sarif: true,
});
assert!(workflow.contains("uses: aiconnai/agentshield@main"));
assert!(workflow.contains("fail-on: \"high\""));
assert!(workflow.contains("ignore-tests: true"));
assert!(workflow.contains("upload-sarif: true"));
assert!(!workflow.contains("baseline:"));
}
#[test]
fn ci_workflow_can_use_baseline_file() {
let workflow = github_actions_workflow(&CiInstallOptions {
fail_on: "high",
ignore_tests: true,
scan_path: ".",
baseline_path: Some(".agentshield-baseline.json"),
upload_sarif: true,
});
assert!(workflow.contains("baseline: \".agentshield-baseline.json\""));
assert!(workflow.contains("upload-sarif: true"));
}
#[test]
fn quickstart_config_enables_project_defaults() {
let config = quickstart_config_toml(Severity::High, true);
assert!(config.contains("fail_on = \"high\""));
assert!(config.contains("ignore_tests = true"));
assert!(config.contains("[runtime.proxy]"));
}
}