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