Skip to main content

agentshield/
ux.rs

1mod hotspots;
2mod roots;
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::Path;
6
7use crate::config::ScanPathFilterSummary;
8use crate::error::ShieldError;
9use crate::ir::Language;
10use crate::rules::{AttackCategory, Finding, Severity};
11use crate::ScanReport;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum CoverageConfidence {
15    High,
16    Medium,
17    Low,
18}
19
20impl CoverageConfidence {
21    fn label(self) -> &'static str {
22        match self {
23            Self::High => "High",
24            Self::Medium => "Medium",
25            Self::Low => "Low",
26        }
27    }
28
29    fn reason(self) -> &'static str {
30        match self {
31            Self::High => "known adapter(s) matched and source files were parsed",
32            Self::Medium => "known adapter(s) matched, but code parsing coverage is limited",
33            Self::Low => "no supported agent extension surface was detected",
34        }
35    }
36}
37
38#[derive(Debug, Clone)]
39pub struct ExplainOptions {
40    pub ignore_tests: bool,
41}
42
43#[derive(Debug, Clone)]
44pub struct CiInstallOptions<'a> {
45    pub fail_on: &'a str,
46    pub ignore_tests: bool,
47    pub scan_path: &'a str,
48    pub baseline_path: Option<&'a str>,
49    pub upload_sarif: bool,
50}
51
52pub fn quickstart_config_toml(fail_on: Severity, ignore_tests: bool) -> String {
53    format!(
54        r#"# AgentShield configuration
55# Generated by `agentshield quickstart`.
56
57[policy]
58fail_on = "{fail_on}"
59
60[scan]
61ignore_tests = {ignore_tests}
62
63[runtime.proxy]
64fail_on = "block"
65"#
66    )
67}
68
69pub fn github_actions_workflow(options: &CiInstallOptions<'_>) -> String {
70    let baseline_input = options
71        .baseline_path
72        .map(|path| format!("          baseline: \"{path}\"\n"))
73        .unwrap_or_default();
74
75    format!(
76        r#"name: AgentShield
77
78on:
79  pull_request:
80  push:
81    branches: [main]
82
83permissions:
84  contents: read
85  security-events: write
86
87jobs:
88  agentshield:
89    runs-on: ubuntu-latest
90    steps:
91      - uses: actions/checkout@v4
92      - uses: aiconnai/agentshield@main
93        with:
94          path: "{scan_path}"
95          fail-on: "{fail_on}"
96          ignore-tests: {ignore_tests}
97{baseline_input}          upload-sarif: {upload_sarif}
98"#,
99        scan_path = options.scan_path,
100        fail_on = options.fail_on,
101        ignore_tests = options.ignore_tests,
102        baseline_input = baseline_input,
103        upload_sarif = options.upload_sarif,
104    )
105}
106
107pub fn render_explain(report: &ScanReport, options: &ExplainOptions) -> String {
108    let coverage = coverage_summary(report);
109    let confidence = confidence_for_report(report);
110    let runtime_findings: Vec<&Finding> = report
111        .findings
112        .iter()
113        .filter(|finding| finding.attack_category != AttackCategory::SupplyChain)
114        .collect();
115    let supply_chain_findings: Vec<&Finding> = report
116        .findings
117        .iter()
118        .filter(|finding| finding.attack_category == AttackCategory::SupplyChain)
119        .collect();
120
121    let mut output = String::new();
122    output.push_str("AgentShield explain\n");
123    output.push_str("===================\n\n");
124    output.push_str(&format!(
125        "Gate: {}\n",
126        if report.verdict.pass { "PASS" } else { "FAIL" }
127    ));
128    output.push_str(&format!("Reason: {}\n", gate_reason(report)));
129    output.push_str(&format!(
130        "Security confidence: {} - {}\n\n",
131        confidence.label(),
132        confidence.reason()
133    ));
134
135    output.push_str("Coverage:\n");
136    output.push_str(&format!(
137        "- Adapters: {}\n",
138        display_list(&coverage.frameworks, "none")
139    ));
140    output.push_str(&roots::render(report));
141    output.push_str(&format!("- Targets: {}\n", coverage.targets));
142    output.push_str(&format!(
143        "- Source files parsed: {} ({})\n",
144        coverage.source_files,
145        display_list(&coverage.languages, "no code parser coverage")
146    ));
147    output.push_str(&format!("- Tools discovered: {}\n", coverage.tools));
148    output.push_str(&format!(
149        "- Dependencies checked: {}\n",
150        coverage.dependencies
151    ));
152    output.push_str(&format!("- Lockfiles detected: {}\n", coverage.lockfiles));
153    output.push_str(&format!(
154        "- Test file exclusion: {}\n",
155        if options.ignore_tests {
156            "enabled"
157        } else {
158            "disabled"
159        }
160    ));
161    output.push_str(&format!(
162        "- Path filters: {}\n\n",
163        format_path_filters(&report.path_filter_summary)
164    ));
165
166    output.push_str("Findings:\n");
167    output.push_str(&format!(
168        "- Runtime-risk findings: {}\n",
169        finding_group_summary(&runtime_findings)
170    ));
171    output.push_str(&format!(
172        "- Supply-chain hygiene: {}\n",
173        finding_group_summary(&supply_chain_findings)
174    ));
175    output.push_str(&format!(
176        "- Severity counts: {}\n\n",
177        severity_counts(&report.findings)
178    ));
179
180    output.push_str(&hotspots::render(report));
181
182    output.push_str("Next actions:\n");
183    for action in next_actions(report) {
184        output.push_str(&format!("- {action}\n"));
185    }
186
187    output.push_str("\nWhat this does not prove:\n");
188    output.push_str("- This scan does not execute tools or prove absence of vulnerabilities.\n");
189    output.push_str(
190        "- It checks known risky patterns in supported agent surfaces and dependency metadata.\n",
191    );
192
193    output
194}
195
196pub fn render_no_adapter_explain(
197    path: &Path,
198    ignore_tests: bool,
199    path_filters: &ScanPathFilterSummary,
200) -> String {
201    let mut output = String::new();
202    output.push_str("AgentShield explain\n");
203    output.push_str("===================\n\n");
204    output.push_str("Gate: INCONCLUSIVE\n");
205    output.push_str("Reason: no supported agent extension surface was detected.\n");
206    output.push_str(&format!(
207        "Security confidence: {} - {}\n\n",
208        CoverageConfidence::Low.label(),
209        CoverageConfidence::Low.reason()
210    ));
211    output.push_str("Coverage:\n");
212    output.push_str("- Adapters: none\n");
213    output.push_str(&format!("- Target: {}\n", path.display()));
214    output.push_str(&format!(
215        "- Test file exclusion: {}\n",
216        if ignore_tests { "enabled" } else { "disabled" }
217    ));
218    output.push_str(&format!(
219        "- Path filters: {}\n\n",
220        format_path_filters(path_filters)
221    ));
222    output.push_str("Next actions:\n");
223    output.push_str("- Confirm this repository contains an MCP server, OpenClaw skill, Hermes agent, CrewAI/LangChain tool, GPT Action, or Cursor Rules surface.\n");
224    output.push_str("- If it does, add a framework manifest or dependency metadata that AgentShield can detect.\n");
225    output.push_str("- Run `agentshield doctor .` to inspect adapter detection.\n\n");
226    output.push_str("What this does not prove:\n");
227    output.push_str("- This result does not mean the project is safe; it means AgentShield did not find a supported surface to scan.\n");
228    output
229}
230
231pub fn is_no_adapter(error: &ShieldError) -> bool {
232    matches!(error, ShieldError::NoAdapter(_))
233}
234
235#[derive(Debug, Default)]
236struct CoverageSummary {
237    frameworks: BTreeSet<String>,
238    languages: BTreeSet<String>,
239    targets: usize,
240    source_files: usize,
241    tools: usize,
242    dependencies: usize,
243    lockfiles: usize,
244}
245
246fn coverage_summary(report: &ScanReport) -> CoverageSummary {
247    let mut summary = CoverageSummary {
248        targets: report.targets.len(),
249        ..CoverageSummary::default()
250    };
251
252    for target in &report.targets {
253        summary.frameworks.insert(target.framework.to_string());
254        summary.source_files += target.source_files.len();
255        summary.tools += target.tools.len();
256        summary.dependencies += target.dependencies.dependencies.len();
257        if target.dependencies.lockfile.is_some() {
258            summary.lockfiles += 1;
259        }
260        for source in &target.source_files {
261            summary
262                .languages
263                .insert(display_language(source.language).into());
264        }
265    }
266
267    summary
268}
269
270fn confidence_for_report(report: &ScanReport) -> CoverageConfidence {
271    if report.targets.is_empty() {
272        CoverageConfidence::Low
273    } else if report
274        .targets
275        .iter()
276        .any(|target| !target.source_files.is_empty())
277    {
278        CoverageConfidence::High
279    } else {
280        CoverageConfidence::Medium
281    }
282}
283
284fn gate_reason(report: &ScanReport) -> String {
285    if report.verdict.pass {
286        match report.verdict.highest_severity {
287            Some(severity) => format!(
288                "no findings at or above the {} threshold; highest finding is {}",
289                report.verdict.fail_threshold, severity
290            ),
291            None => format!(
292                "no findings remained after policy, suppressions, and baseline filtering; threshold is {}",
293                report.verdict.fail_threshold
294            ),
295        }
296    } else {
297        format!(
298            "at least one finding meets or exceeds the {} threshold; highest finding is {}",
299            report.verdict.fail_threshold,
300            report
301                .verdict
302                .highest_severity
303                .map(|severity| severity.to_string())
304                .unwrap_or_else(|| "unknown".into())
305        )
306    }
307}
308
309fn finding_group_summary(findings: &[&Finding]) -> String {
310    if findings.is_empty() {
311        "none".into()
312    } else {
313        format!("{} ({})", findings.len(), severity_counts_refs(findings))
314    }
315}
316
317fn severity_counts(findings: &[Finding]) -> String {
318    let refs: Vec<&Finding> = findings.iter().collect();
319    severity_counts_refs(&refs)
320}
321
322fn severity_counts_refs(findings: &[&Finding]) -> String {
323    if findings.is_empty() {
324        return "none".into();
325    }
326
327    let mut counts: BTreeMap<Severity, usize> = BTreeMap::new();
328    for finding in findings {
329        *counts.entry(finding.severity).or_default() += 1;
330    }
331
332    [
333        Severity::Critical,
334        Severity::High,
335        Severity::Medium,
336        Severity::Low,
337        Severity::Info,
338    ]
339    .into_iter()
340    .filter_map(|severity| {
341        counts
342            .get(&severity)
343            .map(|count| format!("{count} {severity}"))
344    })
345    .collect::<Vec<_>>()
346    .join(", ")
347}
348
349fn next_actions(report: &ScanReport) -> Vec<String> {
350    if report.findings.is_empty() {
351        return vec![
352            "Add a CI gate with `agentshield ci install`.".into(),
353            "Keep `agentshield scan . --ignore-tests --fail-on high` in the pre-merge path.".into(),
354        ];
355    }
356
357    let mut actions = Vec::new();
358    if !report.verdict.pass {
359        actions.push(format!(
360            "Fix findings at or above `{}` first; they are blocking the security gate.",
361            report.verdict.fail_threshold
362        ));
363    }
364
365    let mut seen_rules = BTreeSet::new();
366    for finding in &report.findings {
367        if !seen_rules.insert(finding.rule_id.clone()) {
368            continue;
369        }
370        if let Some(command) = exact_command_for_finding(finding) {
371            actions.push(command);
372        } else if let Some(remediation) = &finding.remediation {
373            actions.push(remediation.clone());
374        } else {
375            actions.push(format!("Review `{}`: {}", finding.rule_id, finding.message));
376        }
377
378        if actions.len() >= 5 {
379            break;
380        }
381    }
382
383    actions.push("Run `agentshield scan . --explain` again after changes.".into());
384    actions
385}
386
387fn exact_command_for_finding(finding: &Finding) -> Option<String> {
388    let file_name = finding
389        .location
390        .as_ref()
391        .and_then(|location| location.file.file_name())
392        .map(|name| name.to_string_lossy().to_string())
393        .unwrap_or_default();
394
395    match finding.rule_id.as_str() {
396        "SHIELD-009" => {
397            let package = package_name_from_message(&finding.message)?;
398            if file_name == "package.json" {
399                Some(format!(
400                    "Pin `{package}` with `npm install {package}@<exact-version> --save-exact`."
401                ))
402            } else if file_name == "requirements.txt" {
403                Some(format!(
404                    "Pin `{package}` by changing the requirement to `{package}==<exact-version>`."
405                ))
406            } else if file_name == "pyproject.toml" {
407                Some(format!(
408                    "Pin `{package}` to an exact version in `pyproject.toml`, then regenerate the lockfile."
409                ))
410            } else {
411                None
412            }
413        }
414        "SHIELD-012" => {
415            if file_name == "package.json" {
416                Some("Generate an npm lockfile with `npm install`.".into())
417            } else if file_name == "requirements.txt" {
418                Some(
419                    "Generate a reproducible Python lockfile with `uv lock` or `poetry lock`."
420                        .into(),
421                )
422            } else {
423                None
424            }
425        }
426        _ => None,
427    }
428}
429
430fn package_name_from_message(message: &str) -> Option<&str> {
431    let start = message.find('\'')? + 1;
432    let rest = &message[start..];
433    let end = rest.find('\'')?;
434    Some(&rest[..end])
435}
436
437fn display_list(values: &BTreeSet<String>, empty: &str) -> String {
438    if values.is_empty() {
439        empty.into()
440    } else {
441        values.iter().cloned().collect::<Vec<_>>().join(", ")
442    }
443}
444
445fn format_path_filters(summary: &ScanPathFilterSummary) -> String {
446    if summary.include.is_empty() && summary.exclude.is_empty() {
447        return "disabled".into();
448    }
449
450    let include = if summary.include.is_empty() {
451        "all".into()
452    } else {
453        summary.include.join(", ")
454    };
455    let exclude = if summary.exclude.is_empty() {
456        "none".into()
457    } else {
458        summary.exclude.join(", ")
459    };
460
461    format!("include {include}; exclude {exclude}")
462}
463
464fn display_language(language: Language) -> &'static str {
465    match language {
466        Language::Python => "Python",
467        Language::TypeScript => "TypeScript",
468        Language::JavaScript => "JavaScript",
469        Language::Shell => "Shell",
470        Language::Json => "JSON",
471        Language::Toml => "TOML",
472        Language::Yaml => "YAML",
473        Language::Markdown => "Markdown",
474        Language::Unknown => "Unknown",
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use std::path::PathBuf;
481
482    use crate::ir::{Framework, ScanTarget, SourceFile};
483    use crate::rules::policy::PolicyVerdict;
484    use crate::rules::{AttackCategory, Confidence, Evidence, Finding};
485
486    use super::*;
487
488    fn finding(rule_id: &str, severity: Severity, category: AttackCategory) -> Finding {
489        Finding {
490            rule_id: rule_id.into(),
491            rule_name: "Rule".into(),
492            severity,
493            confidence: Confidence::High,
494            attack_category: category,
495            message: "Dependency '@modelcontextprotocol/sdk' is not pinned: ^1.0.0".into(),
496            location: Some(crate::ir::SourceLocation {
497                file: PathBuf::from("package.json"),
498                line: 1,
499                column: 0,
500                end_line: None,
501                end_column: None,
502            }),
503            evidence: vec![Evidence {
504                description: "evidence".into(),
505                location: None,
506                snippet: None,
507            }],
508            taint_path: None,
509            remediation: Some("fix it".into()),
510            cwe_id: None,
511        }
512    }
513
514    fn report(findings: Vec<Finding>) -> ScanReport {
515        ScanReport {
516            target_name: "fixture".into(),
517            findings,
518            verdict: PolicyVerdict {
519                pass: true,
520                total_findings: 2,
521                effective_findings: 2,
522                highest_severity: Some(Severity::Medium),
523                fail_threshold: Severity::High,
524            },
525            scan_root: PathBuf::from("."),
526            targets: vec![ScanTarget {
527                name: "fixture".into(),
528                framework: Framework::Mcp,
529                root_path: PathBuf::from("."),
530                tools: vec![],
531                execution: Default::default(),
532                data: Default::default(),
533                dependencies: Default::default(),
534                provenance: Default::default(),
535                source_files: vec![SourceFile {
536                    path: PathBuf::from("server.py"),
537                    language: Language::Python,
538                    content: String::new(),
539                    size_bytes: 0,
540                    content_hash: String::new(),
541                }],
542            }],
543            path_filter_summary: ScanPathFilterSummary::default(),
544        }
545    }
546
547    #[test]
548    fn explain_separates_runtime_and_supply_chain_findings() {
549        let output = render_explain(
550            &report(vec![finding(
551                "SHIELD-009",
552                Severity::Medium,
553                AttackCategory::SupplyChain,
554            )]),
555            &ExplainOptions { ignore_tests: true },
556        );
557
558        assert!(output.contains("Gate: PASS"));
559        assert!(output.contains("Runtime-risk findings: none"));
560        assert!(output.contains("Supply-chain hygiene: 1"));
561        assert!(output.contains("Security confidence: High"));
562        assert!(
563            output.contains("npm install @modelcontextprotocol/sdk@<exact-version> --save-exact")
564        );
565    }
566
567    #[test]
568    fn no_adapter_explain_is_inconclusive() {
569        let output =
570            render_no_adapter_explain(Path::new("."), true, &ScanPathFilterSummary::default());
571
572        assert!(output.contains("Gate: INCONCLUSIVE"));
573        assert!(output.contains("does not mean the project is safe"));
574    }
575
576    #[test]
577    fn ci_workflow_uses_expected_action_inputs() {
578        let workflow = github_actions_workflow(&CiInstallOptions {
579            fail_on: "high",
580            ignore_tests: true,
581            scan_path: ".",
582            baseline_path: None,
583            upload_sarif: true,
584        });
585
586        assert!(workflow.contains("uses: aiconnai/agentshield@main"));
587        assert!(workflow.contains("fail-on: \"high\""));
588        assert!(workflow.contains("ignore-tests: true"));
589        assert!(workflow.contains("upload-sarif: true"));
590        assert!(!workflow.contains("baseline:"));
591    }
592
593    #[test]
594    fn ci_workflow_can_use_baseline_file() {
595        let workflow = github_actions_workflow(&CiInstallOptions {
596            fail_on: "high",
597            ignore_tests: true,
598            scan_path: ".",
599            baseline_path: Some(".agentshield-baseline.json"),
600            upload_sarif: true,
601        });
602
603        assert!(workflow.contains("baseline: \".agentshield-baseline.json\""));
604        assert!(workflow.contains("upload-sarif: true"));
605    }
606
607    #[test]
608    fn quickstart_config_enables_project_defaults() {
609        let config = quickstart_config_toml(Severity::High, true);
610
611        assert!(config.contains("fail_on = \"high\""));
612        assert!(config.contains("ignore_tests = true"));
613        assert!(config.contains("[runtime.proxy]"));
614    }
615}