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 run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
37        let opts: Options = ctx
38            .options
39            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
40            .unwrap_or_default();
41
42        let base: Vec<String> = match opts.patterns {
43            Some(p) => p,
44            None => dictionary_lines(BUILTIN_PATTERNS)
45                .into_iter()
46                .map(String::from)
47                .collect(),
48        };
49        let extras = opts.extra_patterns.unwrap_or_default();
50
51        // Compile individually (silently skipping invalid user patterns), then
52        // use a RegexSet as a single-pass prefilter over the document.
53        let compiled: Vec<(&String, Regex)> = base
54            .iter()
55            .chain(extras.iter())
56            .filter_map(|p| {
57                RegexBuilder::new(p)
58                    .case_insensitive(true)
59                    .build()
60                    .ok()
61                    .map(|re| (p, re))
62            })
63            .collect();
64        let set = RegexSetBuilder::new(compiled.iter().map(|(p, _)| p.as_str()))
65            .case_insensitive(true)
66            .build()
67            .ok();
68        let matched: Vec<usize> = match &set {
69            Some(s) => s.matches(&doc.raw).into_iter().collect(),
70            None => (0..compiled.len()).collect(),
71        };
72
73        let mut out = Vec::new();
74        for idx in matched {
75            let (_, re) = &compiled[idx];
76            for m in re.find_iter(&doc.raw) {
77                let line = line_of_offset(&doc.raw, m.start());
78                let matched = truncate_chars(m.as_str(), 60);
79                let mut v = Violation::new(
80                    AIL201,
81                    self.default_severity(),
82                    doc.path.clone(),
83                    format!("unrestricted tool/permission grant: '{}'", matched),
84                )
85                .at(line, 1);
86                v.fix_hint = Some(
87                    "scope tool access explicitly (list allowed tools or commands) rather than granting blanket access"
88                        .to_string(),
89                );
90                out.push(v);
91            }
92        }
93        out
94    }
95}