Skip to main content

chio_guards/
post_invocation.rs

1//! Post-invocation hook pipeline -- inspects tool results before they reach
2//! the agent.
3//!
4//! This module provides a pipeline of post-invocation hooks that run after
5//! a tool has produced a response. Each hook can:
6//!
7//! - **Allow** the response to pass through unmodified
8//! - **Block** the response entirely (replacing it with an error)
9//! - **Redact** parts of the response before delivery
10//! - **Escalate** the response for operator review
11//!
12//! Hooks run in registration order. A Block from any hook stops the pipeline.
13//!
14//! The ready-made [`SanitizerHook`] wraps the full [`OutputSanitizer`] and
15//! automatically redacts secrets, PII, and high-entropy tokens from tool
16//! results while preserving JSON structure. Sanitization evidence is emitted
17//! alongside the pipeline verdict so the kernel can embed it in the receipt's
18//! `GuardEvidence`.
19
20use 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
32// ---------------------------------------------------------------------------
33// SanitizerHook -- post-invocation hook wrapping the full OutputSanitizer.
34// ---------------------------------------------------------------------------
35
36/// Post-invocation hook that runs the [`OutputSanitizer`] over tool results.
37///
38/// Behavior:
39/// - If no sensitive data is detected, returns `Allow`.
40/// - Otherwise, returns `Redact(sanitized)` with a JSON value whose strings
41///   have been sanitized in place (structure preserved).
42/// - Emits [`GuardEvidence`] summarizing the findings so they flow into the
43///   kernel's receipt. Raw secrets are never included; only previews, spans,
44///   and detector ids.
45pub struct SanitizerHook {
46    sanitizer: OutputSanitizer,
47    hook_name: String,
48    evidence: std::sync::Mutex<Option<GuardEvidence>>,
49}
50
51impl SanitizerHook {
52    /// Build a sanitizer hook with the default sanitizer configuration.
53    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    /// Build a sanitizer hook with a custom sanitizer configuration.
62    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    /// Build a sanitizer hook from a pre-constructed sanitizer.
71    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    /// Override the hook name (useful for telemetry).
80    pub fn with_name(mut self, name: impl Into<String>) -> Self {
81        self.hook_name = name.into();
82        self
83    }
84
85    /// Access the underlying sanitizer (useful for tests / operator tooling).
86    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            // Clear any stale evidence from a previous run.
114            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, // sanitized: still allowed but redacted
123            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
137// Produce a stable, non-leaky summary of findings for receipt evidence.
138fn 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
157/// Run the sanitizer over a JSON value and return the sanitized value plus a
158/// [`SanitizationResult`] aggregating all findings/redactions. Useful for
159/// tests and for callers that want the raw details without wiring a full
160/// pipeline.
161pub 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// ---------------------------------------------------------------------------
181// Tests
182// ---------------------------------------------------------------------------
183
184#[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}