Skip to main content

chio_guards/
content_review.rs

1//! ContentReviewGuard -- pre-invocation review of outbound content for
2//! SaaS / communication / payment tool calls.
3//!
4//! The guard inspects
5//! [`ToolAction::ExternalApiCall`] requests to services like Slack,
6//! SendGrid, Twilio, and Stripe, applying:
7//!
8//! 1. **PII detection** on message bodies / email text.  Detected
9//!    categories are surfaced through tracing evidence.
10//! 2. **Tone / profanity** filter (configurable wordlist).
11//! 3. **Monetary approval gating** -- payment calls whose amount meets
12//!    or exceeds the grant's [`Constraint::RequireApprovalAbove`]
13//!    threshold yield [`Verdict::PendingApproval`] so the HITL flow in
14//!    [`chio_kernel::approval`] can collect a human signoff.
15//!
16//! Unknown / non-external-API actions pass through with [`Verdict::Allow`].
17//!
18//! Evidence is emitted via `tracing::warn!` with a structured
19//! `detected_categories` field so downstream log pipelines can extract
20//! the reasons.
21//!
22//! # Fail-closed semantics
23//!
24//! - [`ContentReviewConfig::per_service`] lookups fall back to
25//!   [`ContentReviewConfig::default_rules`];
26//! - invalid user-supplied regex patterns cause
27//!   [`ContentReviewGuard::with_config`] to return
28//!   [`ContentReviewError::InvalidPattern`];
29//! - messages that trip both PII and profanity return a single `Deny`
30//!   outcome but log both categories as evidence.
31
32use std::collections::{HashMap, HashSet};
33use std::sync::OnceLock;
34
35use regex::{Regex, RegexBuilder};
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39use chio_core::capability::scope::Constraint;
40use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError, Verdict};
41
42use crate::action::{extract_action_checked, ToolAction};
43
44/// Errors produced when building a [`ContentReviewGuard`].
45#[derive(Debug, thiserror::Error)]
46pub enum ContentReviewError {
47    /// A user-supplied regex pattern failed to compile.
48    #[error("invalid review pattern `{pattern}`: {source}")]
49    InvalidPattern {
50        pattern: String,
51        #[source]
52        source: regex::Error,
53    },
54
55    /// A user-supplied regex pattern exceeded policy-load safety limits.
56    #[error("{0}")]
57    UnsafePattern(String),
58}
59
60/// Per-service review rules.  Missing fields fall back to defaults.
61#[derive(Clone, Debug, Default, Deserialize, Serialize)]
62#[serde(deny_unknown_fields)]
63pub struct ContentReviewRules {
64    /// Enable PII detection on the message body.  Default `true`.
65    #[serde(default = "default_true")]
66    pub detect_pii: bool,
67    /// Enable the profanity filter.  Default `true`.
68    #[serde(default = "default_true")]
69    pub detect_profanity: bool,
70    /// Case-insensitive words that trigger a Deny.
71    #[serde(default)]
72    pub banned_words: Vec<String>,
73    /// Extra regex patterns whose match triggers a Deny.
74    #[serde(default)]
75    pub extra_patterns: Vec<String>,
76    /// Maximum bytes of outbound text to scan.  Longer inputs are
77    /// truncated at a UTF-8 boundary.
78    #[serde(default = "default_max_scan_bytes")]
79    pub max_scan_bytes: usize,
80}
81
82fn default_true() -> bool {
83    true
84}
85
86fn default_max_scan_bytes() -> usize {
87    64 * 1024
88}
89
90/// Full content-review configuration.
91#[derive(Clone, Debug, Deserialize, Serialize)]
92#[serde(deny_unknown_fields)]
93pub struct ContentReviewConfig {
94    /// Enable/disable the guard entirely.
95    #[serde(default = "default_true")]
96    pub enabled: bool,
97    /// Default rules applied when a service has no per-service entry.
98    #[serde(default = "default_rules")]
99    pub default_rules: ContentReviewRules,
100    /// Per-service overrides keyed by the service name produced by
101    /// [`crate::action::extract_action`] (e.g. `"slack"`, `"stripe"`).
102    #[serde(default)]
103    pub per_service: HashMap<String, ContentReviewRules>,
104}
105
106fn default_rules() -> ContentReviewRules {
107    ContentReviewRules {
108        detect_pii: true,
109        detect_profanity: true,
110        banned_words: Vec::new(),
111        extra_patterns: Vec::new(),
112        max_scan_bytes: default_max_scan_bytes(),
113    }
114}
115
116impl Default for ContentReviewConfig {
117    fn default() -> Self {
118        Self {
119            enabled: true,
120            default_rules: default_rules(),
121            per_service: HashMap::new(),
122        }
123    }
124}
125
126/// Compiled per-service rules (regex already built).
127struct CompiledRules {
128    detect_pii: bool,
129    detect_profanity: bool,
130    banned_words: HashSet<String>,
131    extra_patterns: Vec<Regex>,
132    max_scan_bytes: usize,
133}
134
135const MAX_EXTRA_PATTERNS: usize = 64;
136const MAX_EXTRA_PATTERN_LEN: usize = 512;
137const MAX_EXTRA_PATTERN_COMPLEXITY: usize = 96;
138const EXTRA_PATTERN_REGEX_SIZE_LIMIT: usize = 1 << 20;
139const EXTRA_PATTERN_DFA_SIZE_LIMIT: usize = 1 << 20;
140
141impl CompiledRules {
142    fn compile(rules: &ContentReviewRules) -> Result<Self, ContentReviewError> {
143        if rules.extra_patterns.len() > MAX_EXTRA_PATTERNS {
144            return Err(ContentReviewError::UnsafePattern(format!(
145                "content_review.extra_patterns allows at most {MAX_EXTRA_PATTERNS} patterns"
146            )));
147        }
148        let mut extra_patterns = Vec::with_capacity(rules.extra_patterns.len());
149        for pat in &rules.extra_patterns {
150            let trimmed = pat.trim();
151            if trimmed.is_empty() {
152                return Err(ContentReviewError::UnsafePattern(
153                    "content_review.extra_patterns cannot contain empty patterns".to_string(),
154                ));
155            }
156            if trimmed.len() > MAX_EXTRA_PATTERN_LEN {
157                return Err(ContentReviewError::UnsafePattern(format!(
158                    "content_review.extra_patterns entries must be at most {MAX_EXTRA_PATTERN_LEN} characters"
159                )));
160            }
161            let complexity = review_pattern_complexity(trimmed);
162            if complexity > MAX_EXTRA_PATTERN_COMPLEXITY {
163                return Err(ContentReviewError::UnsafePattern(format!(
164                    "content_review.extra_patterns entries must have complexity at most {MAX_EXTRA_PATTERN_COMPLEXITY}"
165                )));
166            }
167            let re = RegexBuilder::new(trimmed)
168                .size_limit(EXTRA_PATTERN_REGEX_SIZE_LIMIT)
169                .dfa_size_limit(EXTRA_PATTERN_DFA_SIZE_LIMIT)
170                .build()
171                .map_err(|e| ContentReviewError::InvalidPattern {
172                    pattern: trimmed.to_string(),
173                    source: e,
174                })?;
175            extra_patterns.push(re);
176        }
177        let banned_words = rules
178            .banned_words
179            .iter()
180            .map(|w| w.to_ascii_lowercase())
181            .collect();
182        Ok(Self {
183            detect_pii: rules.detect_pii,
184            detect_profanity: rules.detect_profanity,
185            banned_words,
186            extra_patterns,
187            max_scan_bytes: rules.max_scan_bytes.max(1),
188        })
189    }
190}
191
192fn review_pattern_complexity(pattern: &str) -> usize {
193    let mut score = 0usize;
194    let mut escaped = false;
195    for ch in pattern.chars() {
196        if escaped {
197            escaped = false;
198            continue;
199        }
200        match ch {
201            '\\' => escaped = true,
202            '|' | '*' | '+' | '?' => score = score.saturating_add(4),
203            '{' | '[' | '(' => score = score.saturating_add(2),
204            _ => {}
205        }
206    }
207    score
208}
209
210/// Guard that runs content review on outbound SaaS / payment / comms
211/// calls.
212pub struct ContentReviewGuard {
213    enabled: bool,
214    default_rules: CompiledRules,
215    per_service: HashMap<String, CompiledRules>,
216}
217
218impl ContentReviewGuard {
219    /// Build a guard with default configuration.
220    pub fn new() -> Self {
221        match Self::with_config(ContentReviewConfig::default()) {
222            Ok(g) => g,
223            Err(_) => Self {
224                enabled: true,
225                default_rules: CompiledRules {
226                    detect_pii: true,
227                    detect_profanity: true,
228                    banned_words: HashSet::new(),
229                    extra_patterns: Vec::new(),
230                    max_scan_bytes: default_max_scan_bytes(),
231                },
232                per_service: HashMap::new(),
233            },
234        }
235    }
236
237    /// Build a guard with explicit configuration.  Returns
238    /// [`ContentReviewError::InvalidPattern`] if any regex fails to
239    /// compile.
240    pub fn with_config(config: ContentReviewConfig) -> Result<Self, ContentReviewError> {
241        let default_rules = CompiledRules::compile(&config.default_rules)?;
242        let mut per_service = HashMap::with_capacity(config.per_service.len());
243        for (service, rules) in &config.per_service {
244            per_service.insert(service.clone(), CompiledRules::compile(rules)?);
245        }
246        Ok(Self {
247            enabled: config.enabled,
248            default_rules,
249            per_service,
250        })
251    }
252
253    /// Fetch compiled rules for a service, falling back to defaults.
254    fn rules_for(&self, service: &str) -> &CompiledRules {
255        self.per_service.get(service).unwrap_or(&self.default_rules)
256    }
257}
258
259impl Default for ContentReviewGuard {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265impl Guard for ContentReviewGuard {
266    fn name(&self) -> &str {
267        "content-review"
268    }
269
270    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
271        if !self.enabled {
272            return Ok(GuardDecision::allow());
273        }
274
275        let action = match extract_action_checked(&ctx.request.tool_name, &ctx.request.arguments) {
276            Ok(action) => action,
277            Err(_) => return Ok(GuardDecision::deny(Vec::new())),
278        };
279        let (service, endpoint) = match action {
280            ToolAction::ExternalApiCall { service, endpoint } => (service, endpoint),
281            _ => return Ok(GuardDecision::allow()),
282        };
283
284        // 1. Monetary approval gating: check the matched grant for a
285        //    RequireApprovalAbove constraint and compare to the amount
286        //    surfaced in the request body / governed intent.
287        if let Some(verdict) = evaluate_amount_threshold(ctx, &service)? {
288            return Ok(GuardDecision::from_verdict(verdict));
289        }
290
291        // 2. Extract outbound text from the common argument shapes.
292        let text = extract_outbound_text(&ctx.request.arguments);
293        let text = match text {
294            Some(t) if !t.is_empty() => t,
295            _ => return Ok(GuardDecision::allow()),
296        };
297
298        let rules = self.rules_for(&service);
299        let truncated = truncate_utf8(&text, rules.max_scan_bytes);
300
301        // 3. PII detection.
302        let mut categories: Vec<&'static str> = Vec::new();
303        if rules.detect_pii {
304            for (category, re) in builtin_pii_patterns() {
305                if re.is_match(truncated) {
306                    categories.push(*category);
307                }
308            }
309        }
310
311        // 4. Profanity / banned word check.
312        if rules.detect_profanity && contains_banned_word(truncated, &rules.banned_words) {
313            categories.push("profanity");
314        }
315
316        // 5. Extra user regex patterns.
317        for re in &rules.extra_patterns {
318            if re.is_match(truncated) {
319                categories.push("custom");
320            }
321        }
322
323        if !categories.is_empty() {
324            tracing::warn!(
325                guard = "content-review",
326                service = %service,
327                endpoint = %endpoint,
328                detected_categories = ?categories,
329                "content-review denied outbound message"
330            );
331            return Ok(GuardDecision::deny(Vec::new()));
332        }
333
334        Ok(GuardDecision::allow())
335    }
336}
337
338/// Inspect the matched grant for a [`Constraint::RequireApprovalAbove`]
339/// and compare the requested amount to its threshold.  When the call is
340/// a payment-service call (`stripe`, `paypal`, ...) and the amount meets
341/// the threshold, emit [`Verdict::PendingApproval`] so the kernel's
342/// HITL surface can take over.
343fn evaluate_amount_threshold(
344    ctx: &GuardContext,
345    service: &str,
346) -> Result<Option<Verdict>, KernelError> {
347    if !is_payment_service(service) {
348        return Ok(None);
349    }
350    let Some(grant) = ctx
351        .matched_grant_index
352        .and_then(|idx| ctx.scope.grants.get(idx))
353    else {
354        return Ok(None);
355    };
356
357    let threshold = grant.constraints.iter().find_map(|c| match c {
358        Constraint::RequireApprovalAbove { threshold_units } => Some(*threshold_units),
359        _ => None,
360    });
361    let Some(threshold) = threshold else {
362        return Ok(None);
363    };
364
365    let amount_units = extract_amount_units(ctx.request).or_else(|| {
366        ctx.request
367            .governed_intent
368            .as_ref()
369            .and_then(|intent| intent.max_amount.as_ref().map(|amt| amt.units))
370    });
371    let Some(units) = amount_units else {
372        // Cannot compare; leave the decision to other guards.
373        return Ok(None);
374    };
375    if units >= threshold {
376        tracing::info!(
377            guard = "content-review",
378            service = %service,
379            units,
380            threshold,
381            "content-review requires human approval for monetary threshold"
382        );
383        return Ok(Some(Verdict::PendingApproval));
384    }
385    Ok(None)
386}
387
388/// Return `true` for services where monetary threshold checks apply.
389fn is_payment_service(service: &str) -> bool {
390    matches!(
391        service,
392        "stripe" | "paypal" | "square" | "braintree" | "adyen" | "plaid"
393    )
394}
395
396/// Extract an amount-in-units figure from common argument names used by
397/// payment APIs.  Interprets plain numeric fields (`amount`,
398/// `amount_units`) as the minor-unit integer.
399fn extract_amount_units(request: &chio_kernel::ToolCallRequest) -> Option<u64> {
400    let args = &request.arguments;
401    for key in ["amount_units", "amountUnits", "amount"] {
402        if let Some(v) = args.get(key) {
403            if let Some(u) = v.as_u64() {
404                return Some(u);
405            }
406            if let Some(f) = v.as_f64() {
407                if f >= 0.0 && f.is_finite() {
408                    return Some(f as u64);
409                }
410            }
411        }
412    }
413    None
414}
415
416/// Extract the outbound text to review from the tool-call arguments.
417fn extract_outbound_text(arguments: &Value) -> Option<String> {
418    let mut chunks: Vec<String> = Vec::new();
419    for key in [
420        "text",
421        "body",
422        "message",
423        "content",
424        "subject",
425        "html",
426        "description",
427        "summary",
428        "note",
429    ] {
430        if let Some(v) = arguments.get(key).and_then(|v| v.as_str()) {
431            if !v.is_empty() {
432                chunks.push(v.to_string());
433            }
434        }
435    }
436    // Slack-style blocks[].text.text.
437    if let Some(arr) = arguments.get("blocks").and_then(|v| v.as_array()) {
438        for block in arr {
439            if let Some(text) = block
440                .get("text")
441                .and_then(|t| t.get("text"))
442                .and_then(|t| t.as_str())
443            {
444                chunks.push(text.to_string());
445            }
446        }
447    }
448    if chunks.is_empty() {
449        None
450    } else {
451        Some(chunks.join("\n"))
452    }
453}
454
455fn truncate_utf8(input: &str, max_bytes: usize) -> &str {
456    if input.len() <= max_bytes {
457        return input;
458    }
459    let mut end = max_bytes;
460    while end > 0 && !input.is_char_boundary(end) {
461        end -= 1;
462    }
463    &input[..end]
464}
465
466fn contains_banned_word(text: &str, banned: &HashSet<String>) -> bool {
467    if banned.is_empty() {
468        return false;
469    }
470    let lowered = text.to_ascii_lowercase();
471    for word in banned {
472        if word.is_empty() {
473            continue;
474        }
475        if lowered.contains(word) {
476            return true;
477        }
478    }
479    false
480}
481
482/// Compiled once per process.  Built-in PII detectors keyed by the
483/// category tag surfaced in tracing evidence.
484fn builtin_pii_patterns() -> &'static [(&'static str, Regex)] {
485    static PATS: OnceLock<Vec<(&'static str, Regex)>> = OnceLock::new();
486    PATS.get_or_init(|| {
487        let sources: &[(&'static str, &'static str)] = &[
488            ("email", r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"),
489            ("ssn", r"\b\d{3}-\d{2}-\d{4}\b"),
490            ("phone_us", r"\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"),
491            ("credit_card", r"\b(?:\d[ -]*?){13,19}\b"),
492            ("ipv4", r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
493        ];
494        sources
495            .iter()
496            .filter_map(|(cat, src)| match Regex::new(src) {
497                Ok(re) => Some((*cat, re)),
498                Err(err) => {
499                    tracing::error!(error = %err, source = %src, category = %cat, "content-review: pii regex failed");
500                    None
501                }
502            })
503            .collect()
504    })
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn extract_outbound_text_joins_chunks() {
513        let args = serde_json::json!({
514            "subject": "hi",
515            "body": "hello",
516            "blocks": [{"text": {"text": "b1"}}]
517        });
518        let text = extract_outbound_text(&args).unwrap();
519        assert!(text.contains("hi"));
520        assert!(text.contains("hello"));
521        assert!(text.contains("b1"));
522    }
523
524    #[test]
525    fn pii_patterns_detect_email() {
526        let pats = builtin_pii_patterns();
527        assert!(pats
528            .iter()
529            .any(|(cat, re)| *cat == "email" && re.is_match("user@example.com")));
530    }
531
532    #[test]
533    fn truncate_utf8_honors_boundaries() {
534        let s = "héllo";
535        let out = truncate_utf8(s, 2);
536        assert_eq!(out, "h");
537    }
538}