Skip to main content

llm_inspect/
report.rs

1//! Results of an input scan.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Category, Severity};
6
7/// What the caller should do with the input.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum Decision {
11    /// No finding reached the warn threshold. Findings (if any) are informational.
12    Allow,
13    /// At least one finding reached the warn threshold but none reached the block threshold.
14    Warn,
15    /// At least one finding reached the block threshold.
16    Block,
17}
18
19/// One match of one rule.
20///
21/// The matched text is deliberately **not** stored: reports are often logged,
22/// and logging the very secret or personal data that was detected defeats the
23/// purpose. Use [`Finding::excerpt`] when you really need it.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Finding {
26    /// Id of the rule that matched.
27    pub rule_id: String,
28    /// Category of the rule.
29    pub category: Category,
30    /// Severity of the rule.
31    pub severity: Severity,
32    /// Byte offset in the **original** text where the match starts.
33    pub start: usize,
34    /// Byte offset in the **original** text where the match ends (exclusive).
35    pub end: usize,
36    /// `true` if the rule matched only after normalization (for example the
37    /// input used full-width letters or zero-width characters). This is a
38    /// strong signal of deliberate evasion.
39    pub normalized_only: bool,
40}
41
42impl Finding {
43    /// The matched slice of `original`, which must be the text that was scanned.
44    ///
45    /// # Panics
46    ///
47    /// Panics if the offsets are out of range or not on char boundaries, i.e.
48    /// if `original` is not the scanned text.
49    pub fn excerpt<'a>(&self, original: &'a str) -> &'a str {
50        &original[self.start..self.end]
51    }
52}
53
54/// Result of [`Inspector::scan`](crate::Inspector::scan).
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Report {
57    /// Overall decision according to the policy thresholds.
58    pub decision: Decision,
59    /// Findings sorted by start offset, then by descending severity.
60    pub findings: Vec<Finding>,
61    /// Whether normalization changed the text (full-width, compatibility or
62    /// invisible characters were present).
63    pub normalized: bool,
64    /// Number of invisible characters that were removed during normalization.
65    pub removed_invisible: usize,
66}
67
68impl Report {
69    /// `true` if the decision is [`Decision::Block`].
70    pub fn is_blocked(&self) -> bool {
71        self.decision == Decision::Block
72    }
73
74    /// The highest severity among the findings, if any.
75    pub fn highest_severity(&self) -> Option<Severity> {
76        self.findings.iter().map(|f| f.severity).max()
77    }
78
79    /// Findings of one category.
80    pub fn by_category(&self, category: Category) -> impl Iterator<Item = &Finding> {
81        self.findings.iter().filter(move |f| f.category == category)
82    }
83}
84
85/// Result of [`Inspector::redact`](crate::Inspector::redact).
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Redacted {
88    /// The input with redactable findings replaced by markers.
89    pub text: String,
90    /// The scan report the redaction was based on (offsets refer to the original text).
91    pub report: Report,
92}