1use chio_core::receipt::metadata::GuardEvidence;
21pub use chio_kernel::{
22 PipelineOutcome, PostInvocationContext, PostInvocationHook, PostInvocationHookIdentity,
23 PostInvocationPipeline, PostInvocationVerdict,
24};
25use serde::Serialize;
26use serde_json::Value;
27
28use crate::response_sanitization::{
29 OutputSanitizer, OutputSanitizerConfig, OutputSanitizerConfigError, SanitizationResult,
30 SensitiveDataFinding,
31};
32
33pub struct SanitizerHook {
47 sanitizer: OutputSanitizer,
48 hook_name: String,
49 evidence: std::sync::Mutex<Option<GuardEvidence>>,
50}
51
52impl SanitizerHook {
53 pub fn new() -> Self {
55 Self {
56 sanitizer: OutputSanitizer::new(),
57 hook_name: "output-sanitizer".to_string(),
58 evidence: std::sync::Mutex::new(None),
59 }
60 }
61
62 pub fn with_config(config: OutputSanitizerConfig) -> Result<Self, OutputSanitizerConfigError> {
64 Ok(Self {
65 sanitizer: OutputSanitizer::with_config(config)?,
66 hook_name: "output-sanitizer".to_string(),
67 evidence: std::sync::Mutex::new(None),
68 })
69 }
70
71 pub fn from_sanitizer(sanitizer: OutputSanitizer) -> Self {
73 Self {
74 sanitizer,
75 hook_name: "output-sanitizer".to_string(),
76 evidence: std::sync::Mutex::new(None),
77 }
78 }
79
80 pub fn with_name(mut self, name: impl Into<String>) -> Self {
82 self.hook_name = name.into();
83 self
84 }
85
86 pub fn sanitizer(&self) -> &OutputSanitizer {
88 &self.sanitizer
89 }
90
91 fn store_evidence(&self, ev: GuardEvidence) {
92 let mut guard = match self.evidence.lock() {
93 Ok(g) => g,
94 Err(poisoned) => poisoned.into_inner(),
95 };
96 *guard = Some(ev);
97 }
98}
99
100impl Default for SanitizerHook {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl PostInvocationHook for SanitizerHook {
107 fn name(&self) -> &str {
108 &self.hook_name
109 }
110
111 fn inspect(&self, _ctx: &PostInvocationContext<'_>, response: &Value) -> PostInvocationVerdict {
112 let sanitized = self.sanitizer.sanitize_value(response);
113 if !sanitized.was_redacted {
114 if let Ok(mut g) = self.evidence.lock() {
116 *g = None;
117 }
118 return PostInvocationVerdict::Allow;
119 }
120 let details = summarize_findings(&sanitized.findings, &sanitized.redactions);
121 self.store_evidence(GuardEvidence {
122 guard_name: self.hook_name.clone(),
123 verdict: true, details: Some(details),
125 });
126 PostInvocationVerdict::Redact(sanitized.value)
127 }
128
129 fn durable_identity(&self) -> Result<Option<PostInvocationHookIdentity>, String> {
130 if self.sanitizer.uses_tokenization().map_err(str::to_owned)? {
131 return Err(
132 "output sanitizer tokenize redaction strategy is not deterministic".to_string(),
133 );
134 }
135 #[derive(Serialize)]
136 struct SanitizerIdentityConfig<'a> {
137 hook_name: &'a str,
138 sanitizer: &'a OutputSanitizerConfig,
139 }
140
141 PostInvocationHookIdentity::from_canonical_config(
142 "chio.output-sanitizer",
143 "1",
144 "chio-guards.output-sanitizer.v1",
145 &SanitizerIdentityConfig {
146 hook_name: &self.hook_name,
147 sanitizer: self.sanitizer.config(),
148 },
149 )
150 .map(Some)
151 }
152
153 fn take_evidence(&self) -> Option<GuardEvidence> {
154 let mut guard = match self.evidence.lock() {
155 Ok(g) => g,
156 Err(poisoned) => poisoned.into_inner(),
157 };
158 guard.take()
159 }
160}
161
162fn summarize_findings(
164 findings: &[SensitiveDataFinding],
165 _redactions: &[crate::response_sanitization::Redaction],
166) -> String {
167 let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
168 for f in findings {
169 *counts.entry(f.id.clone()).or_insert(0) += 1;
170 }
171 let parts: Vec<String> = counts
172 .into_iter()
173 .map(|(id, n)| format!("{id}:{n}"))
174 .collect();
175 format!(
176 "sanitizer detected {} findings ({})",
177 findings.len(),
178 parts.join(",")
179 )
180}
181
182pub fn sanitize_json(sanitizer: &OutputSanitizer, value: &Value) -> (Value, SanitizationResult) {
187 let sv = sanitizer.sanitize_value(value);
188 let sanitized_text = sv.value.to_string();
189 let stats = crate::response_sanitization::ProcessingStats {
190 input_length: value.to_string().len(),
191 output_length: sanitized_text.len(),
192 findings_count: sv.findings.len(),
193 redactions_count: sv.redactions.len(),
194 };
195 let result = SanitizationResult {
196 sanitized: sanitized_text,
197 was_redacted: sv.was_redacted,
198 findings: sv.findings,
199 redactions: sv.redactions,
200 stats,
201 };
202 (sv.value, result)
203}
204
205#[cfg(test)]
210mod tests {
211 use super::*;
212
213 struct AllowHook;
214 impl PostInvocationHook for AllowHook {
215 fn name(&self) -> &str {
216 "allow-all"
217 }
218 fn inspect(
219 &self,
220 _ctx: &PostInvocationContext<'_>,
221 _resp: &Value,
222 ) -> PostInvocationVerdict {
223 PostInvocationVerdict::Allow
224 }
225 }
226
227 struct BlockHook(String);
228 impl PostInvocationHook for BlockHook {
229 fn name(&self) -> &str {
230 "block-all"
231 }
232 fn inspect(
233 &self,
234 _ctx: &PostInvocationContext<'_>,
235 _resp: &Value,
236 ) -> PostInvocationVerdict {
237 PostInvocationVerdict::Block(self.0.clone())
238 }
239 }
240
241 struct RedactHook;
242 impl PostInvocationHook for RedactHook {
243 fn name(&self) -> &str {
244 "redact-all"
245 }
246 fn inspect(
247 &self,
248 _ctx: &PostInvocationContext<'_>,
249 _resp: &Value,
250 ) -> PostInvocationVerdict {
251 PostInvocationVerdict::Redact(serde_json::json!({"redacted": true}))
252 }
253 }
254
255 struct EscalateHook(String);
256 impl PostInvocationHook for EscalateHook {
257 fn name(&self) -> &str {
258 "escalate"
259 }
260 fn inspect(
261 &self,
262 _ctx: &PostInvocationContext<'_>,
263 _resp: &Value,
264 ) -> PostInvocationVerdict {
265 PostInvocationVerdict::Escalate(self.0.clone())
266 }
267 }
268
269 #[test]
270 fn empty_pipeline_allows() {
271 let pipeline = PostInvocationPipeline::new();
272 let response = serde_json::json!({"data": "hello"});
273 let (verdict, escalations) = pipeline.evaluate("tool", &response);
274 assert!(matches!(verdict, PostInvocationVerdict::Allow));
275 assert!(escalations.is_empty());
276 }
277
278 #[test]
279 fn all_allow_passes() {
280 let mut pipeline = PostInvocationPipeline::new();
281 pipeline.add(Box::new(AllowHook));
282 pipeline.add(Box::new(AllowHook));
283
284 let response = serde_json::json!({"data": "hello"});
285 let (verdict, _) = pipeline.evaluate("tool", &response);
286 assert!(matches!(verdict, PostInvocationVerdict::Allow));
287 }
288
289 #[test]
290 fn block_stops_pipeline() {
291 let mut pipeline = PostInvocationPipeline::new();
292 pipeline.add(Box::new(AllowHook));
293 pipeline.add(Box::new(BlockHook("blocked".to_string())));
294 pipeline.add(Box::new(AllowHook));
295
296 let response = serde_json::json!({"data": "hello"});
297 let (verdict, _) = pipeline.evaluate("tool", &response);
298 assert!(matches!(verdict, PostInvocationVerdict::Block(_)));
299 }
300
301 #[test]
302 fn redact_modifies_response() {
303 let mut pipeline = PostInvocationPipeline::new();
304 pipeline.add(Box::new(RedactHook));
305
306 let response = serde_json::json!({"data": "sensitive"});
307 let (verdict, _) = pipeline.evaluate("tool", &response);
308 match verdict {
309 PostInvocationVerdict::Redact(v) => {
310 assert_eq!(v, serde_json::json!({"redacted": true}));
311 }
312 other => panic!("expected Redact, got {other:?}"),
313 }
314 }
315
316 #[test]
317 fn escalations_collected() {
318 let mut pipeline = PostInvocationPipeline::new();
319 pipeline.add(Box::new(EscalateHook("warning 1".to_string())));
320 pipeline.add(Box::new(EscalateHook("warning 2".to_string())));
321
322 let response = serde_json::json!({"data": "hello"});
323 let (verdict, escalations) = pipeline.evaluate("tool", &response);
324 assert!(matches!(verdict, PostInvocationVerdict::Escalate(_)));
325 assert_eq!(escalations.len(), 2);
326 }
327
328 #[test]
329 fn block_after_escalation_returns_block_with_escalations() {
330 let mut pipeline = PostInvocationPipeline::new();
331 pipeline.add(Box::new(EscalateHook("noticed something".to_string())));
332 pipeline.add(Box::new(BlockHook("critical".to_string())));
333
334 let response = serde_json::json!({"data": "hello"});
335 let (verdict, escalations) = pipeline.evaluate("tool", &response);
336 assert!(matches!(verdict, PostInvocationVerdict::Block(_)));
337 assert_eq!(escalations.len(), 1);
338 }
339
340 #[test]
341 fn len_and_is_empty() {
342 let mut pipeline = PostInvocationPipeline::new();
343 assert!(pipeline.is_empty());
344 assert_eq!(pipeline.len(), 0);
345 pipeline.add(Box::new(AllowHook));
346 assert!(!pipeline.is_empty());
347 assert_eq!(pipeline.len(), 1);
348 }
349
350 #[test]
351 fn sanitizer_hook_allows_clean_response() {
352 let mut pipeline = PostInvocationPipeline::new();
353 pipeline.add(Box::new(SanitizerHook::new()));
354
355 let response = serde_json::json!({"ok": true, "message": "nothing to see"});
356 let outcome = pipeline.evaluate_with_evidence("tool", &response);
357 assert!(matches!(outcome.verdict, PostInvocationVerdict::Allow));
358 assert!(outcome.evidence.is_empty());
359 }
360
361 #[test]
362 fn sanitizer_hook_redacts_and_emits_evidence() {
363 let mut pipeline = PostInvocationPipeline::new();
364 pipeline.add(Box::new(SanitizerHook::new()));
365
366 let key = format!("ghp_{}", "a".repeat(36));
367 let response = serde_json::json!({"token": key});
368 let outcome = pipeline.evaluate_with_evidence("tool", &response);
369
370 match &outcome.verdict {
371 PostInvocationVerdict::Redact(v) => {
372 let rendered = v.to_string();
373 assert!(!rendered.contains(&key));
374 }
375 other => panic!("expected Redact, got {other:?}"),
376 }
377 assert_eq!(outcome.evidence.len(), 1);
378 let ev = &outcome.evidence[0];
379 assert_eq!(ev.guard_name, "output-sanitizer");
380 assert!(ev.verdict, "verdict field marks successful redaction");
381 let details = ev.details.as_deref().unwrap_or("");
382 assert!(details.contains("secret_github_token"), "got {details}");
383 }
384
385 #[test]
386 fn sanitizer_tokenization_has_no_durable_identity() {
387 let mut config = OutputSanitizerConfig::default();
388 config.redaction_strategies.insert(
389 crate::response_sanitization::SensitiveCategory::Pii,
390 crate::response_sanitization::RedactionStrategy::Tokenize,
391 );
392 let sanitizer = OutputSanitizer::with_config(config).expect("tokenizing sanitizer");
393 let hook = SanitizerHook::from_sanitizer(sanitizer);
394
395 let error = hook
396 .durable_identity()
397 .expect_err("counter-backed tokenization must not be durable");
398 assert!(error.contains("tokenize redaction strategy is not deterministic"));
399 }
400}