Skip to main content

ailint_core/rules/security/
tool_confirmation.rs

1//! AIL203 `tool-confirmation-required` — Ensure critical/destructive rules include "Wait for human confirmation" phrasing.
2//!
3//! See: `docs/rules/security/AIL203.md`
4
5use serde::Deserialize;
6
7use crate::parser::{DocumentContent, ParsedDocument};
8use crate::rules::security::{line_of_offset, AIL203};
9use crate::rules::{dictionary_lines, Rule, RuleContext, RuleId, Severity, Violation};
10
11const DEFAULT_DESTRUCTIVE_PHRASES: &str = include_str!("destructive_phrases.txt");
12const DEFAULT_CONFIRMATION_PHRASES: &str = include_str!("confirmation_phrases.txt");
13
14#[derive(Debug, Default, Deserialize)]
15#[serde(default, deny_unknown_fields)]
16struct Options {
17    destructive_phrases: Option<Vec<String>>,
18    extra_destructive_phrases: Option<Vec<String>>,
19    confirmation_phrases: Option<Vec<String>>,
20    extra_confirmation_phrases: Option<Vec<String>>,
21}
22
23/// AIL203 tool-confirmation-required: destructive actions need a confirmation step.
24#[derive(Debug, Default)]
25pub struct ToolConfirmationRequiredRule;
26
27impl Rule for ToolConfirmationRequiredRule {
28    fn id(&self) -> RuleId {
29        AIL203
30    }
31
32    fn default_severity(&self) -> Severity {
33        Severity::Error
34    }
35
36    fn description(&self) -> &'static str {
37        "File describes destructive actions but has no confirmation constraint."
38    }
39
40    fn fix_hint(&self) -> &'static str {
41        "Require explicit confirmation (\"wait for human confirmation\") before destructive actions."
42    }
43
44    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
45        match &doc.content {
46            DocumentContent::Markdown(_) | DocumentContent::Text => {}
47            _ => return Vec::new(),
48        };
49
50        let opts: Options = ctx
51            .options
52            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
53            .unwrap_or_default();
54
55        let mut destructive_phrases: Vec<String> = match opts.destructive_phrases {
56            Some(p) => p,
57            None => dictionary_lines(DEFAULT_DESTRUCTIVE_PHRASES)
58                .into_iter()
59                .map(String::from)
60                .collect(),
61        };
62        if let Some(extra) = opts.extra_destructive_phrases {
63            destructive_phrases.extend(extra);
64        }
65        let mut confirmation_phrases: Vec<String> = match opts.confirmation_phrases {
66            Some(p) => p,
67            None => dictionary_lines(DEFAULT_CONFIRMATION_PHRASES)
68                .into_iter()
69                .map(String::from)
70                .collect(),
71        };
72        if let Some(extra) = opts.extra_confirmation_phrases {
73            confirmation_phrases.extend(extra);
74        }
75        for p in &mut destructive_phrases {
76            *p = p.to_lowercase();
77        }
78        for p in &mut confirmation_phrases {
79            *p = p.to_lowercase();
80        }
81
82        let lower_raw = doc.raw.to_lowercase();
83
84        let mut violations = Vec::new();
85        for d in &destructive_phrases {
86            if let Some(idx) = lower_raw.find(d.as_str()) {
87                if !confirmation_phrases
88                    .iter()
89                    .any(|c| lower_raw.contains(c.as_str()))
90                {
91                    let line = line_of_offset(&doc.raw, idx);
92                    let v = Violation::new(
93                        AIL203,
94                        ctx.severity,
95                        doc.path.clone(),
96                        "destructive action without confirmation constraint",
97                    )
98                    .at(line, 1)
99                    .with_detail(d.clone());
100                    violations.push(v);
101
102                    break;
103                }
104            }
105        }
106
107        violations
108    }
109}