Skip to main content

ailint_core/rules/security/
unrestricted_tool.rs

1//! AIL201 `no-unrestricted-tool-grant` — flag phrases that grant blanket
2//! tool/permission access.
3//!
4//! See: `docs/rules/security/AIL201.md`
5
6use regex::{Regex, RegexBuilder, RegexSetBuilder};
7use serde::Deserialize;
8
9use crate::parser::ParsedDocument;
10use crate::rules::security::{line_of_offset, truncate_chars, AIL201};
11use crate::rules::{dictionary_lines, Rule, RuleContext, RuleId, Severity, Violation};
12
13const BUILTIN_PATTERNS: &str = include_str!("unrestricted_tool_patterns.txt");
14
15#[derive(Debug, Default, Deserialize)]
16struct Options {
17    #[serde(default)]
18    patterns: Option<Vec<String>>,
19    #[serde(default)]
20    extra_patterns: Option<Vec<String>>,
21}
22
23/// AIL201 no-unrestricted-tool-grant: flags blanket or auto-approved tool access.
24#[derive(Debug, Default)]
25pub struct NoUnrestrictedToolGrantRule;
26
27impl Rule for NoUnrestrictedToolGrantRule {
28    fn id(&self) -> RuleId {
29        AIL201
30    }
31
32    fn default_severity(&self) -> Severity {
33        Severity::Warning
34    }
35
36    fn description(&self) -> &'static str {
37        "File grants a tool or permission without scoping."
38    }
39
40    fn fix_hint(&self) -> &'static str {
41        "Scope the grant to specific tools, paths, or actions instead of blanket access."
42    }
43
44    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
45        let opts: Options = ctx
46            .options
47            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
48            .unwrap_or_default();
49
50        let base: Vec<String> = match opts.patterns {
51            Some(p) => p,
52            None => dictionary_lines(BUILTIN_PATTERNS)
53                .into_iter()
54                .map(String::from)
55                .collect(),
56        };
57        let extras = opts.extra_patterns.unwrap_or_default();
58
59        // Compile individually (silently skipping invalid user patterns), then
60        // use a RegexSet as a single-pass prefilter over the document.
61        let compiled: Vec<(&String, Regex)> = base
62            .iter()
63            .chain(extras.iter())
64            .filter_map(|p| {
65                RegexBuilder::new(p)
66                    .case_insensitive(true)
67                    .build()
68                    .ok()
69                    .map(|re| (p, re))
70            })
71            .collect();
72        let set = RegexSetBuilder::new(compiled.iter().map(|(p, _)| p.as_str()))
73            .case_insensitive(true)
74            .build()
75            .ok();
76        let matched: Vec<usize> = match &set {
77            Some(s) => s.matches(&doc.raw).into_iter().collect(),
78            None => (0..compiled.len()).collect(),
79        };
80
81        let mut out = Vec::new();
82        for idx in matched {
83            let (_, re) = &compiled[idx];
84            for m in re.find_iter(&doc.raw) {
85                let line = line_of_offset(&doc.raw, m.start());
86                let matched = truncate_chars(m.as_str(), 60);
87                let v = Violation::new(
88                    AIL201,
89                    self.default_severity(),
90                    doc.path.clone(),
91                    "unrestricted tool/permission grant",
92                )
93                .at(line, 1)
94                .with_detail(matched);
95                out.push(v);
96            }
97        }
98        out
99    }
100}