ailint_core/rules/security/
unrestricted_tool.rs1use 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#[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 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}