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}