1use std::path::{Component, Path};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{Result, ShieldError};
6use crate::rules::policy::Policy;
7
8#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct RulesConfig {
24 #[serde(default)]
26 pub custom_dir: Option<std::path::PathBuf>,
27}
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct ScanConfig {
32 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct RuntimeConfig {
203 #[serde(default)]
204 pub proxy: RuntimeProxyConfig,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
209#[serde(rename_all = "lowercase")]
210pub enum ProxyFailOn {
211 #[default]
213 Block,
214 Warn,
216 Never,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct ProxyToolOverride {
223 pub name: String,
224 #[serde(default)]
225 pub fail_on: ProxyFailOn,
226}
227
228#[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 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 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 #[cfg(test)]
283 pub fn validate_for_test(&self) -> Result<()> {
284 self.validate()
285 }
286
287 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}