Skip to main content

agentshield/
lib.rs

1//! AgentShield — Security scanner for AI agent extensions.
2//!
3//! Offline-first, multi-framework, SARIF output. Scans MCP servers,
4//! OpenClaw skills, and other agent extension formats for security issues.
5//!
6//! # Quick Start
7//!
8//! ```no_run
9//! use std::path::Path;
10//! use agentshield::{scan, ScanOptions};
11//!
12//! let options = ScanOptions::default();
13//! let report = scan(Path::new("./my-mcp-server"), &options).unwrap();
14//! println!("Pass: {}, Findings: {}", report.verdict.pass, report.findings.len());
15//! ```
16
17pub mod adapter;
18pub mod analysis;
19pub mod config;
20pub mod error;
21pub mod ir;
22pub mod output;
23pub mod parser;
24pub mod rules;
25
26use std::path::Path;
27
28use config::Config;
29use error::Result;
30use output::OutputFormat;
31use rules::policy::PolicyVerdict;
32use rules::{Finding, RuleEngine};
33
34/// Options for a scan invocation.
35#[derive(Debug, Clone)]
36pub struct ScanOptions {
37    /// Path to config file (defaults to `.agentshield.toml` in scan dir).
38    pub config_path: Option<std::path::PathBuf>,
39    /// Output format.
40    pub format: OutputFormat,
41    /// CLI override for fail_on threshold.
42    pub fail_on_override: Option<rules::Severity>,
43}
44
45impl Default for ScanOptions {
46    fn default() -> Self {
47        Self {
48            config_path: None,
49            format: OutputFormat::Console,
50            fail_on_override: None,
51        }
52    }
53}
54
55/// Complete scan report.
56#[derive(Debug)]
57pub struct ScanReport {
58    pub target_name: String,
59    pub findings: Vec<Finding>,
60    pub verdict: PolicyVerdict,
61}
62
63/// Run a complete scan: detect framework, parse, analyze, evaluate policy.
64pub fn scan(path: &Path, options: &ScanOptions) -> Result<ScanReport> {
65    // Load config
66    let config_path = options
67        .config_path
68        .clone()
69        .unwrap_or_else(|| path.join(".agentshield.toml"));
70    let mut config = Config::load(&config_path)?;
71
72    // Apply CLI override
73    if let Some(fail_on) = options.fail_on_override {
74        config.policy.fail_on = fail_on;
75    }
76
77    // Auto-detect framework and load IR
78    let targets = adapter::auto_detect_and_load(path)?;
79
80    // Run detectors on all targets
81    let engine = RuleEngine::new();
82    let mut all_findings: Vec<Finding> = Vec::new();
83
84    let target_name = if let Some(first) = targets.first() {
85        first.name.clone()
86    } else {
87        path.file_name()
88            .map(|n| n.to_string_lossy().into_owned())
89            .unwrap_or_else(|| "unknown".into())
90    };
91
92    for target in &targets {
93        let findings = engine.run(target);
94        all_findings.extend(findings);
95    }
96
97    // Apply policy (ignore rules, overrides)
98    let effective_findings = config.policy.apply(&all_findings);
99    let verdict = config.policy.evaluate(&all_findings);
100
101    Ok(ScanReport {
102        target_name,
103        findings: effective_findings,
104        verdict,
105    })
106}
107
108/// Render a scan report in the specified format.
109pub fn render_report(report: &ScanReport, format: OutputFormat) -> Result<String> {
110    output::render(
111        &report.findings,
112        &report.verdict,
113        format,
114        &report.target_name,
115    )
116}
117
118#[cfg(test)]
119mod integration_tests {
120    use super::*;
121    use std::path::Path;
122
123    #[test]
124    fn safe_calculator_zero_findings() {
125        let opts = ScanOptions::default();
126        let report = scan(
127            Path::new("tests/fixtures/mcp_servers/safe_calculator"),
128            &opts,
129        )
130        .unwrap();
131        // No code-level security findings (SHIELD-001 through SHIELD-006, SHIELD-011)
132        assert!(
133            !report
134                .findings
135                .iter()
136                .any(|f| f.severity >= rules::Severity::High),
137            "safe calculator should have no High+ findings"
138        );
139        assert!(report.verdict.pass);
140    }
141
142    #[test]
143    fn vuln_cmd_inject_detected() {
144        let opts = ScanOptions::default();
145        let report = scan(
146            Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject"),
147            &opts,
148        )
149        .unwrap();
150        assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-001"));
151        assert!(!report.verdict.pass);
152    }
153
154    #[test]
155    fn vuln_ssrf_detected() {
156        let opts = ScanOptions::default();
157        let report = scan(Path::new("tests/fixtures/mcp_servers/vuln_ssrf"), &opts).unwrap();
158        assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-003"));
159        assert!(!report.verdict.pass);
160    }
161
162    #[test]
163    fn vuln_cred_exfil_detected() {
164        let opts = ScanOptions::default();
165        let report = scan(
166            Path::new("tests/fixtures/mcp_servers/vuln_cred_exfil"),
167            &opts,
168        )
169        .unwrap();
170        assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-002"));
171        assert!(!report.verdict.pass);
172    }
173}