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::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
33// ---------------------------------------------------------------------------
34// SanitizerHook -- post-invocation hook wrapping the full OutputSanitizer.
35// ---------------------------------------------------------------------------
36
37/// Post-invocation hook that runs the [`OutputSanitizer`] over tool results.
38///
39/// Behavior:
40/// - If no sensitive data is detected, returns `Allow`.
41/// - Otherwise, returns `Redact(sanitized)` with a JSON value whose strings
42///   have been sanitized in place (structure preserved).
43/// - Emits [`GuardEvidence`] summarizing the findings so they flow into the
44///   kernel's receipt. Raw secrets are never included; only previews, spans,
45///   and detector ids.
46pub struct SanitizerHook {
47    sanitizer: OutputSanitizer,
48    hook_name: String,
49    evidence: std::sync::Mutex<Option<GuardEvidence>>,
50}
51
52impl SanitizerHook {
53    /// Build a sanitizer hook with the default sanitizer configuration.
54    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    /// Build a sanitizer hook with a custom sanitizer configuration.
63    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    /// Build a sanitizer hook from a pre-constructed sanitizer.
72    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    /// Override the hook name (useful for telemetry).
81    pub fn with_name(mut self, name: impl Into<String>) -> Self {
82        self.hook_name = name.into();
83        self
84    }
85
86    /// Access the underlying sanitizer (useful for tests / operator tooling).
87    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            // Clear any stale evidence from a previous run.
115            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, // sanitized: still allowed but redacted
124            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
162// Produce a stable, non-leaky summary of findings for receipt evidence.
163fn 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
182/// Run the sanitizer over a JSON value and return the sanitized value plus a
183/// [`SanitizationResult`] aggregating all findings/redactions. Useful for
184/// tests and for callers that want the raw details without wiring a full
185/// pipeline.
186pub 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// ---------------------------------------------------------------------------
206// Tests
207// ---------------------------------------------------------------------------
208
209#[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}