Skip to main content

agentshield/config/
mod.rs

1use std::path::{Component, Path};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{Result, ShieldError};
6use crate::rules::policy::Policy;
7
8/// Top-level configuration from `.agentshield.toml`.
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct Config {
11    #[serde(default)]
12    pub policy: Policy,
13    #[serde(default)]
14    pub scan: ScanConfig,
15    #[serde(default)]
16    pub rules: RulesConfig,
17    #[serde(default)]
18    pub runtime: RuntimeConfig,
19}
20
21/// `[rules]` section of the config file.
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct RulesConfig {
24    /// Directory containing custom declarative rules (*.yaml, *.yml, *.json).
25    #[serde(default)]
26    pub custom_dir: Option<std::path::PathBuf>,
27}
28
29/// `[scan]` section of the config file.
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct ScanConfig {
32    /// Skip test files when true.
33    #[serde(default)]
34    pub ignore_tests: bool,
35    #[serde(default)]
36    pub include: Vec<String>,
37    #[serde(default)]
38    pub exclude: Vec<String>,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
42pub struct ScanPathFilterSummary {
43    pub include: Vec<String>,
44    pub exclude: Vec<String>,
45}
46
47#[derive(Debug, Clone)]
48pub struct ScanPathFilter {
49    ignore_tests: bool,
50    include: Vec<CompiledPathPattern>,
51    exclude: Vec<CompiledPathPattern>,
52}
53
54#[derive(Debug, Clone)]
55struct CompiledPathPattern {
56    raw: String,
57    patterns: Vec<glob::Pattern>,
58}
59
60const PATH_PATTERN_MATCH_OPTIONS: glob::MatchOptions = glob::MatchOptions {
61    case_sensitive: true,
62    require_literal_separator: true,
63    require_literal_leading_dot: false,
64};
65
66impl ScanPathFilter {
67    pub fn for_ignore_tests(ignore_tests: bool) -> Self {
68        Self {
69            ignore_tests,
70            include: Vec::new(),
71            exclude: Vec::new(),
72        }
73    }
74
75    pub fn from_scan_config(config: &ScanConfig, ignore_tests: bool) -> Result<Self> {
76        Ok(Self {
77            ignore_tests,
78            include: compile_path_patterns("scan.include", &config.include)?,
79            exclude: compile_path_patterns("scan.exclude", &config.exclude)?,
80        })
81    }
82
83    pub const fn ignore_tests(&self) -> bool {
84        self.ignore_tests
85    }
86
87    pub fn allows_path(&self, root: &Path, path: &Path) -> bool {
88        let relative = relative_path(root, path);
89        let included = self.include.is_empty()
90            || self
91                .include
92                .iter()
93                .any(|pattern| pattern.matches(&relative));
94        let excluded = self
95            .exclude
96            .iter()
97            .any(|pattern| pattern.matches(&relative));
98
99        included && !excluded
100    }
101
102    pub fn summary(&self) -> ScanPathFilterSummary {
103        ScanPathFilterSummary {
104            include: self
105                .include
106                .iter()
107                .map(|pattern| pattern.raw.clone())
108                .collect(),
109            exclude: self
110                .exclude
111                .iter()
112                .map(|pattern| pattern.raw.clone())
113                .collect(),
114        }
115    }
116}
117
118impl CompiledPathPattern {
119    fn new(section: &str, raw: &str) -> Result<Self> {
120        let normalized = normalize_config_pattern(raw);
121        if normalized.is_empty() {
122            return Err(ShieldError::Config(format!(
123                "{section} pattern must not be empty"
124            )));
125        }
126        let patterns = expand_config_pattern(&normalized)
127            .into_iter()
128            .map(|pattern| {
129                glob::Pattern::new(&pattern).map_err(|err| {
130                    ShieldError::Config(format!("invalid {section} pattern '{raw}': {err}"))
131                })
132            })
133            .collect::<Result<Vec<_>>>()?;
134
135        Ok(Self {
136            raw: raw.to_string(),
137            patterns,
138        })
139    }
140
141    fn matches(&self, relative_path: &str) -> bool {
142        self.patterns
143            .iter()
144            .any(|pattern| pattern.matches_with(relative_path, PATH_PATTERN_MATCH_OPTIONS))
145    }
146}
147
148fn compile_path_patterns(section: &str, patterns: &[String]) -> Result<Vec<CompiledPathPattern>> {
149    patterns
150        .iter()
151        .map(|pattern| CompiledPathPattern::new(section, pattern))
152        .collect()
153}
154
155fn normalize_config_pattern(pattern: &str) -> String {
156    let mut normalized = pattern.trim().replace('\\', "/");
157    normalized = normalized.trim_start_matches('/').to_string();
158    while let Some(stripped) = normalized.strip_prefix("./") {
159        normalized = stripped.to_string();
160    }
161    while normalized.contains("//") {
162        normalized = normalized.replace("//", "/");
163    }
164    if normalized.ends_with('/') {
165        normalized.push_str("**");
166    }
167    normalized
168}
169
170fn expand_config_pattern(pattern: &str) -> Vec<String> {
171    let mut patterns = vec![pattern.to_string()];
172    if let Some(root_pattern) = pattern.strip_prefix("**/") {
173        if !root_pattern.is_empty() {
174            patterns.push(root_pattern.to_string());
175        }
176    }
177    patterns
178}
179
180fn relative_path(root: &Path, path: &Path) -> String {
181    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
182    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
183    let relative = canonical_path
184        .strip_prefix(&canonical_root)
185        .or_else(|_| path.strip_prefix(root))
186        .unwrap_or(path);
187    let parts: Vec<String> = relative
188        .components()
189        .filter_map(|component| match component {
190            Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
191            Component::CurDir => None,
192            Component::ParentDir => Some("..".to_string()),
193            Component::RootDir | Component::Prefix(_) => None,
194        })
195        .collect();
196
197    parts.join("/")
198}
199
200/// `[runtime]` section of the config file.
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct RuntimeConfig {
203    #[serde(default)]
204    pub proxy: RuntimeProxyConfig,
205}
206
207/// Blocking threshold for the MCP proxy guard.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
209#[serde(rename_all = "lowercase")]
210pub enum ProxyFailOn {
211    /// Block only `block` verdicts (default).
212    #[default]
213    Block,
214    /// Block `warn` and `block` verdicts.
215    Warn,
216    /// Never block; still evaluated and audited.
217    Never,
218}
219
220/// Per-tool proxy policy override.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct ProxyToolOverride {
223    pub name: String,
224    #[serde(default)]
225    pub fail_on: ProxyFailOn,
226}
227
228/// `[runtime.proxy]` section: MCP proxy guard policy.
229#[derive(Debug, Clone, Default, Serialize, Deserialize)]
230pub struct RuntimeProxyConfig {
231    #[serde(default)]
232    pub fail_on: ProxyFailOn,
233    #[serde(default, rename = "tool")]
234    pub tool_overrides: Vec<ProxyToolOverride>,
235}
236
237impl Config {
238    /// Load config from a TOML file. Returns default if file doesn't exist.
239    pub fn load(path: &Path) -> Result<Self> {
240        if !path.exists() {
241            return Ok(Self::default());
242        }
243        let content = std::fs::read_to_string(path)?;
244        let config: Config = toml::from_str(&content)?;
245        config.validate()?;
246        Ok(config)
247    }
248
249    /// Validate the loaded configuration.
250    ///
251    /// Called automatically by `load()`. Exposed for testing via
252    /// `validate_for_test()`.
253    fn validate(&self) -> Result<()> {
254        let mut seen_fingerprints = std::collections::HashSet::new();
255        for s in &self.policy.suppressions {
256            if s.reason.trim().is_empty() {
257                return Err(ShieldError::Config(format!(
258                    "Suppression for fingerprint '{}' must have a non-empty reason",
259                    s.fingerprint,
260                )));
261            }
262            if !seen_fingerprints.insert(s.fingerprint.clone()) {
263                return Err(ShieldError::Config(format!(
264                    "Duplicate suppression entry for fingerprint '{}'",
265                    s.fingerprint
266                )));
267            }
268            if let Some(expires) = &s.expires {
269                chrono::NaiveDate::parse_from_str(expires, "%Y-%m-%d").map_err(|_| {
270                    ShieldError::Config(format!(
271                        "Invalid suppression expiry date '{expires}' for fingerprint '{}'; expected YYYY-MM-DD",
272                        s.fingerprint
273                    ))
274                })?;
275            }
276        }
277        let _ = ScanPathFilter::from_scan_config(&self.scan, self.scan.ignore_tests)?;
278        Ok(())
279    }
280
281    /// Validate without loading from file. Used by tests.
282    #[cfg(test)]
283    pub fn validate_for_test(&self) -> Result<()> {
284        self.validate()
285    }
286
287    /// Generate a starter config file.
288    pub fn starter_toml() -> &'static str {
289        r#"# AgentShield configuration
290# See https://github.com/aiconnai/agentshield for documentation.
291
292[policy]
293# Minimum severity to fail the scan (info, low, medium, high, critical).
294fail_on = "high"
295
296# Rule IDs to ignore entirely.
297# ignore_rules = ["SHIELD-008"]
298
299# Per-rule severity overrides.
300# [policy.overrides]
301# "SHIELD-012" = "info"
302
303# Suppress specific findings by fingerprint.
304# Run `agentshield scan . --format json` to see fingerprints.
305# [[policy.suppressions]]
306# fingerprint = "abc123..."
307# reason = "False positive: input is validated by middleware"
308# expires = "2026-06-01"
309
310# [scan]
311# Skip test files (test/, tests/, __tests__/, *.test.ts, *.spec.ts, etc.).
312# ignore_tests = false
313# Include only matching paths. Empty means include all scan-supported files.
314# Use ** for recursive directories; * and ? stay within one path segment.
315# include = ["src/**", "tools/**"]
316# Exclude matching paths after include filtering.
317# exclude = ["legacy/**", "**/generated/**", "vendor/**"]
318
319# [runtime.proxy]
320# Runtime MCP proxy guard blocking threshold: block, warn, or never.
321# fail_on = "block"
322
323# [[runtime.proxy.tool]]
324# name = "calculator.add"
325# fail_on = "never"
326"#
327    }
328}