1use 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#[derive(Debug, thiserror::Error)]
46pub enum ContentReviewError {
47 #[error("invalid review pattern `{pattern}`: {source}")]
49 InvalidPattern {
50 pattern: String,
51 #[source]
52 source: regex::Error,
53 },
54
55 #[error("{0}")]
57 UnsafePattern(String),
58}
59
60#[derive(Clone, Debug, Default, Deserialize, Serialize)]
62#[serde(deny_unknown_fields)]
63pub struct ContentReviewRules {
64 #[serde(default = "default_true")]
66 pub detect_pii: bool,
67 #[serde(default = "default_true")]
69 pub detect_profanity: bool,
70 #[serde(default)]
72 pub banned_words: Vec<String>,
73 #[serde(default)]
75 pub extra_patterns: Vec<String>,
76 #[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#[derive(Clone, Debug, Deserialize, Serialize)]
92#[serde(deny_unknown_fields)]
93pub struct ContentReviewConfig {
94 #[serde(default = "default_true")]
96 pub enabled: bool,
97 #[serde(default = "default_rules")]
99 pub default_rules: ContentReviewRules,
100 #[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
126struct 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
210pub struct ContentReviewGuard {
213 enabled: bool,
214 default_rules: CompiledRules,
215 per_service: HashMap<String, CompiledRules>,
216}
217
218impl ContentReviewGuard {
219 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 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 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 if let Some(verdict) = evaluate_amount_threshold(ctx, &service)? {
288 return Ok(GuardDecision::from_verdict(verdict));
289 }
290
291 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 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 if rules.detect_profanity && contains_banned_word(truncated, &rules.banned_words) {
313 categories.push("profanity");
314 }
315
316 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
338fn 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 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
388fn is_payment_service(service: &str) -> bool {
390 matches!(
391 service,
392 "stripe" | "paypal" | "square" | "braintree" | "adyen" | "plaid"
393 )
394}
395
396fn 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
416fn 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 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
482fn 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}